clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

fix(tinyagent): preserve threads and accept explained choices

Dami committed 91db918
Keep questions, approvals and follow-up attachments with their originating conversation. Accept selected options with explanatory text, show readable answers and give mobile choices separate touch targets.

Validate with mainline and installed Hermes adapter suites, the disposable integration lab, repository checks, race tests and mobile browser checks.
commit
91db918a29027af86590e14623551125bebfae76
parent
1e42b938a8831675a48bf0f911175677196522a5
author
Dami <dami.etoile@gmail.com>
16 files changed, +485 -49
Mdocs/extensions/tinyagent.md2+-
Minternal/daemon/approvals.go83+++++++++++++++++++-
Minternal/daemon/approvals_test.go35++++++++-
Mlabs/hermes/model.py6+-
Mlabs/hermes/rpc.py5+-
Mlabs/hermes/smoke.py43++++++++---
Mlabs/hermes/test_model.py8++
Mscripts/chat-activity.test.mjs18++++
Mtinyagent/hermes/adapter.py80++++++++++++++++----
Mtinyagent/hermes/interactions.py52+++++++++++--
Mtinyagent/hermes/tests/test_adapter.py122++++++++++++++++++++++++++++++-
Mtinyclient/chat-activity.go40++++++++++
Mtinyclient/chat-activity.html2+-
Mtinyclient/chat-activity.js5+-
Mtinyclient/chat_activity_test.go24++++++
Mtinyclient/style.css9++-
M docs/extensions/tinyagent.md +1 -1
@@ -17,4 +17,4 @@ Use `selection=multiple` for checkboxes. Use `selection=text` for an open-ended
1717 1818 The `p` tags identify the people who may answer. Native notifications are sent only to asked people also named by a `mention` tag. This keeps an agent's pending card visible without waking a device unless the agent explicitly mentions that person.1919 20-Answers are kind 1111 NIP-22 replies naming the request. For a single selection, the content is the selected option id. For multiple selections, the content is a JSON array of option ids. A text answer uses the plain text content for `selection=text`, or `{"text":"..."}` when `freeform=true` accompanies options. The relay settles only answers from asked keys, before expiration, whose ids match the request. Older requests without the native tags retain the existing approve, deny and freeform reply behavior.20+Answers are kind 1111 NIP-22 replies naming the request. For a single selection, the content is the selected option id. For multiple selections, the content is a JSON array of option ids. A text answer uses the plain text content for `selection=text`, or `{"text":"..."}` when `freeform=true` accompanies options. When `freeform=true` accompanies options, an answer may include both a selection and an explanation as `{"choices":["stop"],"text":"The run needs review."}`. The `choices` array must contain one valid id for `selection=single`, or one or more unique valid ids for `selection=multiple`; `text` must be nonempty and at most 8,000 characters. The complete answer content is limited to 8,000 characters. The combined form accepts only the `choices` and `text` fields. The relay settles only answers from asked keys, before expiration, whose ids match the request. Older requests without the native tags retain the existing approve, deny and freeform reply behavior.
M internal/daemon/approvals.go +79 -4
@@ -9,6 +9,7 @@ package daemon
99 import (1010 "context"1111 "encoding/json"12+ "io"1213 "sort"1314 "strconv"1415 "strings"
@@ -273,13 +274,21 @@ func nativeAnswerValid(item approvalItem, answer event.Event) bool {
273274 if event.Tag(answer, "E") != item.ID || event.Tag(answer, "P") != item.Asker || event.Tag(answer, "K") != strconv.Itoa(item.Kind) || event.Tag(answer, "k") != strconv.Itoa(item.Kind) {274275 return false275276 }277+ if len([]rune(answer.Content)) > 8000 {278+ return false279+ }280+ if selection != "text" && item.Freeform && strings.HasPrefix(strings.TrimSpace(answer.Content), "{") {281+ if nativeCombinedAnswer(answer.Content, options, selection) {282+ return true283+ }284+ }276285 if selection != "text" && item.Freeform && strings.HasPrefix(strings.TrimSpace(answer.Content), "{") {277- var custom map[string]any278- if json.Unmarshal([]byte(answer.Content), &custom) != nil || len(custom) != 1 {286+ fields, valid := nativeJSONFields(answer.Content, map[string]bool{"text": true})287+ var text string288+ if !valid || len(fields) != 1 || json.Unmarshal(fields["text"], &text) != nil {279289 return false280290 }281- text, ok := custom["text"].(string)282- return ok && len([]rune(strings.TrimSpace(text))) > 0 && len([]rune(text)) <= 8000291+ return len([]rune(strings.TrimSpace(text))) > 0 && len([]rune(text)) <= 8000283292 }284293 if selection == "text" {285294 return len([]rune(strings.TrimSpace(answer.Content))) > 0 && len([]rune(answer.Content)) <= 8000
@@ -319,6 +328,72 @@ func nativeAnswerValid(item approvalItem, answer event.Event) bool {
319328 return true320329 }321330 331+func nativeCombinedAnswer(content string, options []nativeOption, selection string) bool {332+ fields, valid := nativeJSONFields(content, map[string]bool{"choices": true, "text": true})333+ if !valid || len(fields) != 2 {334+ return false335+ }336+ var choices []string337+ var text string338+ if json.Unmarshal(fields["choices"], &choices) != nil || json.Unmarshal(fields["text"], &text) != nil {339+ return false340+ }341+ text = strings.TrimSpace(text)342+ if len([]rune(text)) == 0 || len([]rune(text)) > 8000 {343+ return false344+ }345+ if selection == "single" && len(choices) != 1 || selection == "multiple" && (len(choices) == 0 || len(choices) > len(options)) {346+ return false347+ }348+ seen := make(map[string]bool, len(choices))349+ for _, id := range choices {350+ if seen[id] {351+ return false352+ }353+ seen[id] = true354+ found := false355+ for _, option := range options {356+ if option.ID == id {357+ found = true358+ break359+ }360+ }361+ if !found {362+ return false363+ }364+ }365+ return true366+}367+368+func nativeJSONFields(content string, allowed map[string]bool) (map[string]json.RawMessage, bool) {369+ decoder := json.NewDecoder(strings.NewReader(content))370+ token, err := decoder.Token()371+ if err != nil || token != json.Delim('{') {372+ return nil, false373+ }374+ fields := make(map[string]json.RawMessage, len(allowed))375+ for decoder.More() {376+ token, err = decoder.Token()377+ key, ok := token.(string)378+ if err != nil || !ok || !allowed[key] || fields[key] != nil {379+ return nil, false380+ }381+ var value json.RawMessage382+ if err := decoder.Decode(&value); err != nil {383+ return nil, false384+ }385+ fields[key] = value386+ }387+ if _, err = decoder.Token(); err != nil {388+ return nil, false389+ }390+ var extra any391+ if err := decoder.Decode(&extra); err != io.EOF {392+ return nil, false393+ }394+ return fields, true395+}396+322397 // approvalSettle fills the answer and state of one request from its answers,323398 // which arrive newest first.324399 func approvalSettle(item *approvalItem, rows []event.Event, now int64) {
M internal/daemon/approvals_test.go +34 -1
@@ -351,7 +351,7 @@ func TestNativeFreeformAllowsBothOptionsAndCustomText(t *testing.T) {
351351 item := approvalItemFrom(e, "question", "")352352 answer := event.Event{Kind: 1111, CreatedAt: 110, Tags: [][]string{{"h", "lab"}, {"e", e.ID}, {"E", e.ID}, {"p", e.PubKey}, {"P", e.PubKey}, {"k", "9"}, {"K", "9"}}}353353 valid := []string{`{"text":"custom"}`, "c0"}354- invalid := []string{`{"text":"custom","extra":true}`, "unknown", `[]`}354+ invalid := []string{`{"text":"custom","extra":true}`, `{"Text":"custom"}`, `{"text":"one","text":"two"}`, "unknown", `[]`}355355 if selection == "multiple" {356356 valid[1] = `["c0"]`357357 invalid = append(invalid, `["c0","c0"]`)
@@ -380,3 +380,36 @@ func TestNativeFreeformAllowsBothOptionsAndCustomText(t *testing.T) {
380380 })381381 }382382 }383+384+func TestNativeFreeformAcceptsChoiceAndExplanationTogether(t *testing.T) {385+ base := event.Event{Kind: kindChatMessage, ID: strings.Repeat("a", 64), PubKey: strings.Repeat("b", 64), CreatedAt: 100,386+ Tags: [][]string{{"h", "lab"}, {"tinyagent", "1"}, {"interaction", "question"}, {"freeform", "true"}, {"expiration", "200"}, {"option", "yes", "Yes"}, {"option", "no", "No"}}}387+ answerTags := [][]string{{"h", "lab"}, {"e", base.ID}, {"E", base.ID}, {"p", base.PubKey}, {"P", base.PubKey}, {"k", "9"}, {"K", "9"}}388+389+ tests := []struct {390+ name string391+ selection string392+ content string393+ valid bool394+ }{395+ {name: "single", selection: "single", content: `{"choices":["no"],"text":"Needs more context."}`, valid: true},396+ {name: "multiple", selection: "multiple", content: `{"choices":["yes","no"],"text":"Both apply."}`, valid: true},397+ {name: "unknown choice", selection: "single", content: `{"choices":["maybe"],"text":"No."}`},398+ {name: "duplicate choice", selection: "multiple", content: `{"choices":["yes","yes"],"text":"No."}`},399+ {name: "empty explanation", selection: "single", content: `{"choices":["yes"],"text":" "}`},400+ {name: "unknown field", selection: "single", content: `{"choices":["yes"],"text":"Yes.","extra":true}`},401+ {name: "duplicate field", selection: "single", content: `{"choices":["yes"],"choices":["no"],"text":"No."}`},402+ }403+ for _, test := range tests {404+ t.Run(test.name, func(t *testing.T) {405+ e := base406+ e.Tags = append([][]string(nil), base.Tags...)407+ e.Tags = append(e.Tags, []string{"selection", test.selection})408+ item := approvalItemFrom(e, "question", "")409+ answer := event.Event{Kind: kindComment, CreatedAt: 110, Content: test.content, Tags: answerTags}410+ if got := nativeAnswerValid(item, answer); got != test.valid {411+ t.Fatalf("nativeAnswerValid = %v, want %v for %s", got, test.valid, test.content)412+ }413+ })414+ }415+}
M labs/hermes/model.py +4 -2
M labs/hermes/rpc.py +3 -2
M labs/hermes/smoke.py +32 -11
@@ -15,12 +15,12 @@ def check(condition, message):
1515 raise AssertionError(message)1616 1717 18-def send(client, text, extra=None):19- return client.publish(9, text, [["h", "lab"], ["p", public("agent")], *(extra or [])])18+def send(client, text, extra=None, kind=9):19+ return client.publish(kind, text, [["h", "lab"], ["p", public("agent")], *(extra or [])])2020 2121 2222 def done(client, marker, since):23- event = wait_event(client, {"kinds": [9, 40003], "authors": [public("agent")],23+ event = wait_event(client, {"kinds": [9, 12, 40003], "authors": [public("agent")],2424 "#h": ["lab"], "since": since},2525 lambda row: f"LAB_DONE {marker}" in row["content"])2626 check(not any(row[0] in ("p", "mention") for row in event["tags"]),
@@ -29,12 +29,26 @@ def done(client, marker, since):
2929 3030 3131 def interaction(client, scenario, run):32- marker = f"LAB_{scenario}_{run}"33- prior = {row["id"] for row in client.query({"kinds": [9], "#h": ["lab"], "#request": ["question"]})}34- sent = send(client, marker)35- request = wait_event(client, {"kinds": [9], "authors": [public("agent")], "#h": ["lab"],32+ model_scenario = {"DETAILS": "QUESTION", "MULTI_DETAILS": "MULTI", "THREAD": "QUESTION", "ROOM_THREAD": "QUESTION"}.get(scenario, scenario)33+ marker = f"LAB_{model_scenario}_{scenario.lower()}_{run}"34+ threaded = scenario in ("THREAD", "ROOM_THREAD")35+ if threaded:36+ root_marker = f"LAB_HELLO_{scenario.lower()}_{run}"37+ root = send(client, root_marker, kind=11 if scenario == "THREAD" else 9)38+ parent = done(client, root_marker, root["created_at"])39+ parent_id = tag(parent, "e") if parent["kind"] == 40003 else parent["id"]40+ sent = send(client, marker, [["e", root["id"], "", "root"], ["e", parent_id, "", "reply"]],41+ kind=12 if scenario == "THREAD" else 9)42+ else:43+ sent = send(client, marker)44+ root = sent45+ request = wait_event(client, {"kinds": [9, 12], "authors": [public("agent")], "#h": ["lab"],3646 "#request": ["question"], "since": sent["created_at"]},37- lambda row: row["id"] not in prior)47+ lambda row: marker in row["content"] or (scenario == "APPROVAL" and row["created_at"] >= sent["created_at"]))48+ check(request["kind"] == sent["kind"], "Request changed the conversation type")49+ check(["e", root["id"], "", "root"] in request["tags"], "Request started a new thread")50+ if threaded:51+ check(["e", sent["id"], "", "reply"] in request["tags"], "Request lost its direct reply parent")3852 check(tag(request, "tinyagent") == "1", "Request lost its native interaction marker")3953 check(tag(request, "p") == public("owner"), "Request does not name its human assignee")4054 check(tag(request, "mention") is None, "Assignee was turned into an unsolicited mention")
@@ -50,12 +64,15 @@ def interaction(client, scenario, run):
5064 else:5165 check(tag(request, "interaction") == "question", "Question lost its type")5266 choice = next(key for key, label in options.items() if label.startswith("Continue"))53- if scenario == "MULTI":67+ if scenario in ("MULTI", "MULTI_DETAILS"):5468 check(tag(request, "selection") == "multiple", "Multiple choice became single choice")5569 choice = json.dumps([key for key, label in options.items() if label.startswith(("Continue", "Stop"))])5670 if scenario == "CUSTOM":5771 check(tag(request, "freeform") == "true", "Choice question lost its custom input")5872 choice = json.dumps({"text": "Custom lab answer"})73+ if scenario in ("DETAILS", "MULTI_DETAILS", "THREAD", "ROOM_THREAD"):74+ selected = list(options) if scenario == "MULTI_DETAILS" else [choice]75+ choice = json.dumps({"choices": selected, "text": "Keep the answer in this thread."})5976 # A signed response from another room member must not resolve the operator's request.6077 with Client("observer") as observer:6178 answer(observer, request, choice)
@@ -67,8 +84,12 @@ def interaction(client, scenario, run):
6784 final = done(client, marker, sent["created_at"])6885 expected = "tinyagent-lab" if scenario == "APPROVAL" else "Custom lab answer" if scenario in ("OPEN", "CUSTOM") else "Continue"6986 check(expected in final["content"], "Hermes did not receive the selected response")70- if scenario == "MULTI":87+ if scenario in ("MULTI", "MULTI_DETAILS"):7188 check("Stop" in final["content"], "Hermes lost a selected choice")89+ if scenario in ("DETAILS", "MULTI_DETAILS", "THREAD", "ROOM_THREAD"):90+ check("Keep the answer in this thread." in final["content"], "Hermes lost the explanation")91+ final_message = client.query({"ids": [tag(final, "e")], "limit": 1})[0] if final["kind"] == 40003 else final92+ check(["e", root["id"], "", "root"] in final_message["tags"], "Hermes resumed outside the original thread")7293 state = client.browse("browseapproval", {"id": request["id"]})7394 check(state["item"]["state"] == "answered", "The valid answer did not settle the card")7495 print(f"PASS {scenario.lower()}: native card, silent assignment, authorized answer, Hermes resumed")
@@ -105,7 +126,7 @@ def main():
105126 sent = send(client, marker)106127 done(client, marker, sent["created_at"])107128 print("PASS connection: real Hermes replied through Tiny without a mention")108- for scenario in ("QUESTION", "MULTI", "CUSTOM", "OPEN", "APPROVAL"):129+ for scenario in ("QUESTION", "MULTI", "CUSTOM", "OPEN", "APPROVAL", "DETAILS", "MULTI_DETAILS", "THREAD", "ROOM_THREAD"):109130 interaction(client, scenario, run)110131 media(client, run)111132 marker = f"LAB_MENTION_{run}"
M labs/hermes/test_model.py +8 -0
M scripts/chat-activity.test.mjs +18 -0
@@ -113,6 +113,8 @@ for(const [selection,form,expected] of [
113113 ["multiple",{option:[{value:"c0",checked:true},{value:"c1",checked:true}]},'["c0","c1"]'],114114 ["single",{"custom-answer":{value:"custom reply"}},'{"text":"custom reply"}'],115115 ["text",{"custom-answer":{value:"plain reply"}},"plain reply"],116+ ["single",{option:{value:"c1",checked:true},"custom-answer":{value:" The question is in the wrong thread. "}},'{"choices":["c1"],"text":"The question is in the wrong thread."}'],117+ ["multiple",{option:[{value:"c0",checked:true},{value:"c1",checked:true}],"custom-answer":{value:"Both apply."}},'{"choices":["c0","c1"],"text":"Both apply."}'],116118 ]) test(`native ${selection} question submits the selected or custom answer`,async()=>{117119 const {decision,published}=nativeForm(selection);118120 await decision.submit({elements:form});
@@ -134,3 +136,19 @@ test("native decisions validate freeform and selection against the fresh card",a
134136 await assert.rejects(decision.submit({elements:{option:[{value:"c0",checked:true},{value:"c1",checked:true}]}}),/one option/);135137 assert.equal(published.length,0);136138 });139+140+test("an explanation cannot bypass the single-choice limit or offered choices",async()=>{141+ const {decision,published}=nativeForm();142+ const comment={value:"Here is why."};143+ await assert.rejects(decision.submit({elements:{option:[{value:"c0",checked:true},{value:"c1",checked:true}],"custom-answer":comment}}),/one option/);144+ await assert.rejects(decision.submit({elements:{option:{value:"unknown",checked:true},"custom-answer":comment}}),/options changed/);145+ assert.equal(published.length,0);146+});147+148+test("combined answers still require freeform permission on the current question",async()=>{149+ for(const interaction of ["question","approval","confirmation"]) {150+ const {decision,published}=nativeForm("single",interaction,false);151+ await assert.rejects(decision.submit({elements:{option:{value:"c1",checked:true},"custom-answer":{value:"An explanation."}}}),/options only/);152+ assert.equal(published.length,0);153+ }154+});
M tinyagent/hermes/adapter.py +65 -15
@@ -7,6 +7,7 @@ interactive prompts; no Buzz compatibility or prose parsing is involved.
77 from __future__ import annotations88 99 import asyncio10+from contextvars import ContextVar1011 import json1112 import mimetypes1213 import hashlib
@@ -83,11 +84,64 @@ class TinyAdapter(InteractionMixin, BasePlatformAdapter):
8384 self._sent = {}8485 self._session_assignees: dict[str, str] = {}8586 self._processing: set[str] = set()87+ self._reply_routes: dict[str, dict] = {}88+ self._turn_routes: dict[str, dict] = {}89+ self._turn_route = ContextVar("tinyagent_turn_route", default=None)8690 8791 @property8892 def name(self) -> str:8993 return "Tiny"9094 95+ def set_message_handler(self, handler):96+ # Bind routing when Hermes actually starts the turn, not when a queued97+ # message arrives. Its public prompt hooks omit reply anchors for Tiny.98+ async def routed(event):99+ session = self._event_session_key(event)100+ route = {**self._reply_routes.get(event.message_id, {}), "session": session}101+ token = self._turn_route.set(route)102+ self._turn_routes[session] = route103+ try:104+ return await handler(event)105+ finally:106+ if self._turn_routes.get(session) is route:107+ self._turn_routes.pop(session, None)108+ self._turn_route.reset(token)109+ super().set_message_handler(routed)110+111+ async def _process_message_background(self, event, session_key):112+ # Include Hermes' final chunks and attachment sends, which run after the113+ # message handler returns. Each queued turn gets its own task context.114+ token = self._turn_route.set({**self._reply_routes.get(event.message_id, {}), "session": session_key})115+ try:116+ return await super()._process_message_background(event, session_key)117+ finally:118+ self._turn_route.reset(token)119+120+ async def _reply_tags(self, room, reply_to=None, metadata=None):121+ metadata = metadata or {}122+ current = self._turn_route.get() or {}123+ if current.get("room") != room:124+ current = {}125+ reply = reply_to or metadata.get("reply_to_message_id") or metadata.get("message_id") or current.get("reply_to_message_id")126+ if reply and reply != current.get("reply_to_message_id"):127+ current = {}128+ route = self._reply_routes.get(reply, {})129+ if route.get("room") != room:130+ route = {}131+ thread = metadata.get("thread_id") or route.get("thread_id") or current.get("thread_id")132+ root = thread or route.get("root")133+ if reply and not root:134+ # Synthetic sends may refer to messages received before this process.135+ rows = await self.rpc.query({"ids": [reply], "#h": [room], "limit": 1})136+ parent = rows[0] if rows else None137+ root = (event_thread(parent) if parent else None) or reply138+ if parent and parent.get("kind") in (11, 12):139+ thread = root140+ tags = [["e", str(root), "", "root"]] if root else []141+ if reply and reply != root:142+ tags.append(["e", str(reply), "", "reply"])143+ return tags, 12 if thread else 9144+91145 async def connect(self, *, is_reconnect: bool = False) -> bool:92146 self.rpc = TinyRPC(self.relay_url, cli_path=self.cli_path)93147 identity = await self.rpc.call("identity")
@@ -143,7 +197,8 @@ class TinyAdapter(InteractionMixin, BasePlatformAdapter):
143197 if self.require_mention and not mentioned and not own_reply:144198 return145199 event_id = str(event.get("id", ""))146- # Only thread kinds create a separate thread session. Kind 9 replies stay in the room.200+ # Kind 11/12 carry Tiny thread sessions. Kind 9 remains the room session even when201+ # it carries an e tag, matching Hermes' room routing contract.147202 thread_id = event_id if event.get("kind") == 11 else root if event.get("kind") == 12 else None148203 source = self.build_source(chat_id=room, chat_name=room, chat_type="group", user_id=author,149204 thread_id=thread_id, message_id=event_id)
@@ -161,6 +216,10 @@ class TinyAdapter(InteractionMixin, BasePlatformAdapter):
161216 reply_to_message_id=parent_id, reply_to_is_own_message=own_reply,162217 reply_to_text=parent.get("content") if parent else None)163218 self._session_assignees[self._event_session_key(message)] = author219+ self._reply_routes[event_id] = {"room": room, "root": root or event_id,220+ "reply_to_message_id": event_id, "thread_id": thread_id, "user_id": author}221+ if len(self._reply_routes) > 10000:222+ self._reply_routes.pop(next(iter(self._reply_routes)))164223 if self._tracker:165224 self._tracker.mark(event)166225 await self.handle_message(message)
@@ -183,9 +242,7 @@ class TinyAdapter(InteractionMixin, BasePlatformAdapter):
183242 return SendResult(success=False, error=str(exc), retryable=True)184243 185244 async def send(self, chat_id: str, content: str, reply_to: str | None = None, metadata: dict | None = None) -> SendResult:186- tags: list[list[str]] = []187- if reply_to:188- tags.append(["e", str(reply_to)])245+ tags, kind = await self._reply_tags(chat_id, reply_to, metadata)189246 mentions = _explicit_mentions(content)190247 for pubkey in (metadata or {}).get("mentions", []):191248 if _HEX.fullmatch(str(pubkey)):
@@ -193,10 +250,7 @@ class TinyAdapter(InteractionMixin, BasePlatformAdapter):
193250 for pubkey in dict.fromkeys(mentions):194251 tags.append(["p", pubkey])195252 tags.append(["mention", pubkey])196- thread_id = (metadata or {}).get("thread_id")197- if thread_id:198- tags.insert(0, ["e", str(thread_id), "", "root"])199- result = await self._publish(chat_id, content, tags, kind=12 if thread_id else 9)253+ result = await self._publish(chat_id, content, tags, kind=kind)200254 if result.success:201255 self._sent[result.message_id] = tags202256 if len(self._sent) > 1000:
@@ -239,15 +293,11 @@ class TinyAdapter(InteractionMixin, BasePlatformAdapter):
239293 if digest:240294 fields.append("x " + str(digest))241295 fields.append("size " + str(len(data)))242- tags = [["imeta", *fields]]243- thread_id = (metadata or {}).get("thread_id")244- if thread_id:245- tags.insert(0, ["e", str(thread_id), "", "root"])246- if reply_to:247- tags.append(["e", str(reply_to), "", "reply"])296+ tags, kind = await self._reply_tags(chat_id, reply_to, metadata)297+ tags.append(["imeta", *fields])248298 for key in _explicit_mentions(caption or ""):249299 tags.extend([["p", key], ["mention", key]])250- return await self._publish(chat_id, caption or "", tags, kind=12 if thread_id else 9)300+ return await self._publish(chat_id, caption or "", tags, kind=kind)251301 except Exception as exc:252302 return SendResult(success=False, error=str(exc), retryable=True)253303
M tinyagent/hermes/interactions.py +45 -7
@@ -14,6 +14,15 @@ def tag(event, name):
1414 return next((row[1] for row in event.get("tags", []) if len(row) > 1 and row[0] == name), None)1515 1616 17+def _reject_duplicate_keys(pairs):18+ result = {}19+ for key, value in pairs:20+ if key in result:21+ raise ValueError("duplicate JSON key")22+ result[key] = value23+ return result24+25+1726 @dataclass1827 class Pending:1928 room: str
@@ -25,6 +34,8 @@ class Pending:
2534 options: dict[str, str]2635 created_at: int2736 expires: int37+ metadata: dict38+ request_kind: int = 92839 2940 3041 class InteractionMixin:
@@ -50,8 +61,12 @@ class InteractionMixin:
5061 chat_id=chat_id, command=command, session_key=session_key, text=text,5162 metadata=metadata, actions=actions))5263 64+ def _request_route(self, session_key):65+ current = self._turn_route.get() or {}66+ return current if current.get("session") == session_key else self._turn_routes.get(session_key, {})67+5368 def _assignee(self, session_key, metadata):54- return self._session_assignees.get(session_key) or (metadata or {}).get("user_id", "")69+ return self._request_route(session_key).get("user_id") or self._session_assignees.get(session_key) or (metadata or {}).get("user_id", "")5570 5671 async def _request(self, pending, content, subject=""):5772 if not self._authorized(pending.assignee):
@@ -69,15 +84,19 @@ class InteractionMixin:
6984 # Assignment and notification are separate even when the same key appears in both.7085 if pending.assignee in self._mentions(content):7186 tags.append(["mention", pending.assignee])72- result = await self._publish(pending.room, content, tags)87+ reply_tags, request_kind = await self._reply_tags(pending.room, metadata=pending.metadata)88+ pending.request_kind = request_kind89+ result = await self._publish(pending.room, content, [*reply_tags, *tags], kind=request_kind)7390 if result.success and result.message_id:7491 self._pending[result.message_id] = pending7592 return result7693 7794 def _pending_request(self, room, session, callback, interaction, options, selection, metadata, ttl=300):7895 now = int(time.time())96+ route = self._request_route(session)97+ metadata = {**(route if route.get("room") == room else {}), **(metadata or {})}7998 return Pending(room, self._assignee(session, metadata), session, callback, interaction,80- selection, options, now, now + ttl)99+ selection, options, now, now + ttl, metadata)81100 82101 async def send_clarify(self, chat_id, question, choices, clarify_id, session_key, metadata=None):83102 from tools import clarify_gateway
@@ -135,7 +154,7 @@ class InteractionMixin:
135154 if resolved:136155 result = await slash_confirm.resolve(pending.session, pending.callback_id, response)137156 if result:138- await self.send(pending.room, result)157+ await self.send(pending.room, result, metadata=pending.metadata)139158 # Waiters are process-local in Hermes. Never apply a late answer to another request.140159 self._pending.pop(event_id, None)141160
@@ -151,7 +170,8 @@ class InteractionMixin:
151170 created = event.get("created_at", 0)152171 if not isinstance(created, int) or not pending.created_at <= created < pending.expires or created > now + 60:153172 return False154- expected = {"h": pending.room, "e": event_id, "E": event_id, "k": "9", "K": "9",173+ expected = {"h": pending.room, "e": event_id, "E": event_id,174+ "k": str(pending.request_kind), "K": str(pending.request_kind),155175 "p": self.pubkey, "P": self.pubkey}156176 return all(tag(event, key) == value for key, value in expected.items())157177
@@ -162,12 +182,30 @@ class InteractionMixin:
162182 if pending.selection == "text":163183 return content164184 try:165- decoded = json.loads(content)185+ decoded = json.loads(content, object_pairs_hook=_reject_duplicate_keys)166186 except ValueError:167187 decoded = None168188 if pending.interaction == "question" and isinstance(decoded, dict):189+ keys = set(decoded)169190 text = decoded.get("text")170- return text if set(decoded) == {"text"} and isinstance(text, str) and text.strip() else None191+ choices = decoded.get("choices")192+ if not isinstance(text, str) or not text.strip() or len(text) > 8000:193+ return None194+ if keys == {"text"}:195+ return text196+ if keys != {"choices", "text"} or not isinstance(choices, list) or not choices:197+ return None198+ if any(not isinstance(key, str) for key in choices) or len(choices) != len(set(choices)):199+ return None200+ if pending.selection == "single" and len(choices) != 1:201+ return None202+ if pending.selection == "multiple" and any(key not in pending.options for key in choices):203+ return None204+ if pending.selection == "single" and choices[0] not in pending.options:205+ return None206+ selected = [pending.options[key] for key in choices]207+ rendered = selected[0] if pending.selection == "single" else json.dumps(selected)208+ return rendered + "\n" + text.strip()171209 if pending.selection == "multiple":172210 if not isinstance(decoded, list) or not decoded or any(not isinstance(key, str) for key in decoded):173211 return None
M tinyagent/hermes/tests/test_adapter.py +120 -2
@@ -23,12 +23,19 @@ OTHER = "c" * 64
2323 class FakeRPC:2424 def __init__(self):2525 self.events = []26+ self.rows = []27+28+ async def query(self, filter):29+ return [row for row in self.rows if row["id"] in filter.get("ids", [])]2630 2731 async def publish(self, event):2832 result = {"id": f"{len(self.events) + 1:064x}", "created_at": int(time.time()), "pubkey": BOT, **event}2933 self.events.append(result)3034 return result3135 36+ async def query(self, _filter):37+ return []38+3239 3340 @pytest.fixture3441 def adapter(monkeypatch, tmp_path):
@@ -49,7 +56,8 @@ def adapter(monkeypatch, tmp_path):
4956 def response(request, content, author=OWNER):5057 event_id = request["id"]5158 return {"kind": 1111, "pubkey": author, "created_at": int(time.time()), "content": content,52- "tags": [["h", "room"], ["e", event_id], ["E", event_id], ["p", BOT], ["P", BOT], ["k", "9"], ["K", "9"]]}59+ "tags": [["h", "room"], ["e", event_id], ["E", event_id], ["p", BOT], ["P", BOT],60+ ["k", str(request["kind"])], ["K", str(request["kind"])]]}5361 5462 5563 def test_real_inbound_source_tracks_human_session(adapter):
@@ -69,7 +77,9 @@ def test_real_inbound_source_tracks_human_session(adapter):
6977 7078 @pytest.mark.parametrize("multiple,content,expected", [(False, "c1", "beta"),7179 (True, '["c0"]', '["alpha"]'), (True, '["c0","c1"]', '["alpha", "beta"]'),72- (False, '{"text":"my own answer"}', "my own answer")])80+ (False, '{"text":"my own answer"}', "my own answer"),81+ (False, '{"choices":["c1"],"text":"because it is safer"}', "beta\nbecause it is safer"),82+ (True, '{"choices":["c0","c1"],"text":"both apply"}', '["alpha", "beta"]\nboth apply')])7383 def test_clarify_resolves_exact_real_waiter(adapter, multiple, content, expected):7484 async def run():7585 entry = clarify_gateway.register("question-id", "session", "Pick", ["alpha", "beta"], multiple)
@@ -172,6 +182,114 @@ def test_native_prompt_mentions_assignee_only_when_explicit(adapter):
172182 asyncio.run(run())173183 174184 185+def test_native_prompt_stays_in_current_thread(adapter):186+ async def run():187+ clarify_gateway.register("q", "session", "Pick", ["yes"])188+ result = await adapter.send_clarify("room", "Pick", ["yes"], "q", "session",189+ metadata={"thread_id": "r" * 64, "message_id": "m" * 64})190+ assert result.success191+ event = adapter.rpc.events[-1]192+ assert event["kind"] == 12193+ assert ["e", "r" * 64, "", "root"] in event["tags"]194+ assert ["e", "m" * 64, "", "reply"] in event["tags"]195+ asyncio.run(run())196+197+198+@pytest.mark.parametrize("kind", [9, 12])199+def test_real_inbound_reply_keeps_prompt_and_answer_in_existing_thread(adapter, kind):200+ async def run():201+ root, parent, incoming = "d" * 64, "e" * 64, "f" * 64202+ adapter.rpc.rows = [{"id": parent, "kind": kind, "pubkey": BOT, "content": "Earlier reply",203+ "tags": [["h", "room"], ["e", root, "", "root"]]}]204+ async def handle(message):205+ session = adapter._event_session_key(message)206+ assert (message.source.thread_id is None) == (kind == 9)207+ entry = clarify_gateway.register("thread-question", session, "Pick", ["Yes", "No"])208+ # A newer queued message must not steal the active turn's assignee.209+ adapter._session_assignees[session] = OTHER210+ current_route = adapter._turn_routes[session]211+ adapter._turn_routes[session] = {**current_route, "user_id": OTHER, "reply_to_message_id": "1" * 64}212+ # Hermes forwards only thread_id for Tiny, and nothing for kind 9.213+ metadata = {"thread_id": message.source.thread_id} if message.source.thread_id else None214+ await adapter.send_clarify("room", "Pick", ["Yes", "No"], "thread-question", session, metadata)215+ request = adapter.rpc.events[-1]216+ assert ["p", OWNER] in request["tags"]217+ assert request["kind"] == kind218+ assert ["e", root, "", "root"] in request["tags"]219+ assert ["e", incoming, "", "reply"] in request["tags"]220+ await adapter._on_answer(response(request, '{"choices":["c1"],"text":"Keep this thread."}'))221+ assert entry.event.is_set() and entry.response == "No\nKeep this thread."222+ adapter._turn_routes[session] = current_route223+ await adapter.send("room", "Acknowledged", metadata=metadata)224+ assert ["e", root, "", "root"] in adapter.rpc.events[-1]["tags"]225+ adapter.set_message_handler(handle)226+ adapter.handle_message = adapter._message_handler227+ await adapter._on_event({"kind": kind, "id": incoming, "pubkey": OWNER,228+ "created_at": int(time.time()), "content": "Ask here", "tags": [229+ ["h", "room"], ["p", BOT], ["e", root, "", "root"], ["e", parent, "", "reply"]]})230+ # The final gateway send occurs after the handler completes.231+ await adapter.send("room", "Final reply", reply_to=incoming)232+ event = adapter.rpc.events[-1]233+ assert event["kind"] == kind234+ assert ["e", root, "", "root"] in event["tags"]235+ assert ["e", incoming, "", "reply"] in event["tags"]236+ assert not adapter._turn_routes and adapter._turn_route.get() is None237+ asyncio.run(run())238+239+240+def test_gateway_final_attachment_keeps_the_turn_route(adapter, tmp_path):241+ async def run():242+ import hashlib243+ root, incoming = "d" * 64, "e" * 64244+ note = tmp_path / "answer.txt"245+ note.write_text("Details for this answer.")246+ async def upload(room, filename, mime, data):247+ return {"url": "https://tiny.test/answer.txt", "sha256": hashlib.sha256(data).hexdigest()}248+ adapter.rpc.upload = upload249+ async def handler(message):250+ return f"Here is the answer.\nMEDIA:{note}"251+ adapter.set_message_handler(handler)252+ async def process(message):253+ await adapter._process_message_background(message, adapter._event_session_key(message))254+ adapter.handle_message = process255+ await adapter._on_event({"kind": 9, "id": incoming, "pubkey": OWNER,256+ "created_at": int(time.time()), "content": "Respond here", "tags": [257+ ["h", "room"], ["p", BOT], ["e", root, "", "root"]]})258+ replies = [event for event in adapter.rpc.events if event["kind"] == 9]259+ assert len(replies) > 1260+ assert all(["e", root, "", "root"] in event["tags"] for event in replies)261+ assert not adapter._turn_routes and adapter._turn_route.get() is None262+ asyncio.run(run())263+264+265+def test_threaded_kind9_reply_keeps_root_session(adapter):266+ async def run():267+ received = []268+ async def handle(message):269+ received.append(message)270+ adapter.handle_message = handle271+ root = "r" * 64272+ event_id = "e" * 64273+ await adapter._on_event({"kind": 9, "id": event_id, "pubkey": OWNER,274+ "created_at": int(time.time()), "content": "follow-up",275+ "tags": [["h", "room"], ["e", root], ["p", BOT]]})276+ assert len(received) == 1277+ assert received[0].source.thread_id is None278+ assert received[0].reply_to_message_id == root279+ asyncio.run(run())280+281+282+def test_threaded_question_callback_uses_kind12_correlation(adapter):283+ async def run():284+ entry = clarify_gateway.register("question-id", "session", "Pick", ["yes"])285+ await adapter.send_clarify("room", "Pick", ["yes"], "question-id", "session",286+ metadata={"thread_id": "r" * 64})287+ request = adapter.rpc.events[-1]288+ await adapter._on_answer(response(request, "c0"))289+ assert entry.response == "yes"290+ asyncio.run(run())291+292+175293 def test_outbound_document_preserves_thread_caption_and_bytes(adapter, tmp_path):176294 import hashlib177295 data = b'attachment bytes'
M tinyclient/chat-activity.go +40 -0
@@ -115,6 +115,7 @@ func chatActivityView(jobs, approvals any, actor, endpoint, room string) []chatA
115115 option := valueMap(rawOption)116116 row.Options = append(row.Options, chatActivityOption{ID: plainString(option["id"]), Label: plainString(option["label"])})117117 }118+ row.Answer = nativeActivityAnswer(row.Answer, row.Options)118119 }119120 items = append(items, row)120121 }
@@ -127,6 +128,45 @@ func chatActivityView(jobs, approvals any, actor, endpoint, room string) []chatA
127128 return items128129 }129130 131+func nativeActivityAnswer(content string, options []chatActivityOption) string {132+ if content == "" || len(options) == 0 {133+ return content134+ }135+ var answer struct {136+ Choices []string `json:"choices"`137+ Text string `json:"text"`138+ }139+ if err := json.Unmarshal([]byte(content), &answer); err != nil || answer.Text == "" {140+ answer.Text = ""141+ if err := json.Unmarshal([]byte(content), &answer.Choices); err != nil {142+ answer.Choices = []string{content}143+ }144+ }145+ labels := make(map[string]string, len(options))146+ for _, option := range options {147+ labels[option.ID] = option.Label148+ }149+ selected := make([]string, 0, len(answer.Choices))150+ for _, choice := range answer.Choices {151+ label, ok := labels[choice]152+ if !ok {153+ return content154+ }155+ selected = append(selected, label)156+ }157+ result := strings.Join(selected, "\n")158+ if answer.Text != "" {159+ if result != "" {160+ result += "\n\n"161+ }162+ result += answer.Text163+ }164+ if result == "" {165+ return content166+ }167+ return result168+}169+130170 func eventHasTag(item map[string]any, name string) bool {131171 for _, tag := range tagValues(item["event"], name) {132172 if tag == "1" {
M tinyclient/chat-activity.html +1 -1
@@ -3,6 +3,6 @@
33 {{define "chatActivityCards"}}{{if .Error}}<p role="status">{{.Error}}</p>{{else}}{{range .Items}}<article id="activity-{{.ID}}" data-activity="{{.Type}}" data-state="{{.State}}" data-event="{{.ID}}">44 {{if eq .Type "approval"}}{{if ne .State "open"}}<details data-task="{{.ID}}"><summary><strong>{{.Title}}</strong> <small>{{if .Decision}}{{.Decision}}{{else}}{{.State}}{{end}}</small></summary>{{end}}<header><strong>{{if eq .Interaction "approval"}}Approval request{{else if eq .Interaction "confirmation"}}Confirmation{{else if .Question}}Question{{else}}Approval request{{end}}</strong> <small>{{if .Decision}}{{.Decision}}{{else}}{{.State}}{{end}}</small></header><p>{{.Title}}</p>{{if ne .Title .Content}}<details><summary>Details</summary><p>{{.Content}}</p></details>{{end}}55 <p><small>from {{template "nostrName" .Author}}</small> <a href="/approvals?id={{.ID}}">Open request</a></p>6-{{with .Answer}}<blockquote>{{.}}</blockquote>{{end}}{{if .CanDecide}}<chat-decision event="{{.ID}}" pubkey="{{.Author}}" kind="{{.Kind}}" room="{{.Room}}" actor="{{$.Actor}}" expires="{{.Expires}}" interaction="{{.Interaction}}" selection="{{if .Multiple}}multiple{{else if .Native}}{{if .Options}}single{{else}}text{{end}}{{end}}"{{if .Freeform}} freeform{{end}}{{if .Question}} question{{end}}{{if .Native}} native{{end}}{{if .Multiple}} multiple{{end}}>{{if .Native}}{{$multiple := .Multiple}}{{$freeform := .Freeform}}{{if .Options}}<fieldset><legend>Choose an option</legend>{{range .Options}}<label><input type="{{if $multiple}}checkbox{{else}}radio{{end}}" name="option" value="{{.ID}}"> {{.Label}}</label>{{end}}</fieldset>{{end}}{{if $freeform}}<label>Your own answer <textarea name="custom-answer" maxlength="8000" rows="2"></textarea></label>{{end}}<button type="submit">Submit</button>{{else if .Question}}<label>Your answer <textarea name="content" required maxlength="8000" rows="2"></textarea></label><button type="submit">Reply</button>{{else}}<button type="submit" name="reaction" value="+">Approve</button><button type="submit" name="reaction" value="-">Deny</button>{{end}}</chat-decision><noscript><p>Answering requires JavaScript and a connected signer.</p></noscript>{{end}}{{if ne .State "open"}}</details>{{end}}6+{{with .Answer}}<blockquote>{{.}}</blockquote>{{end}}{{if .CanDecide}}<chat-decision event="{{.ID}}" pubkey="{{.Author}}" kind="{{.Kind}}" room="{{.Room}}" actor="{{$.Actor}}" expires="{{.Expires}}" interaction="{{.Interaction}}" selection="{{if .Multiple}}multiple{{else if .Native}}{{if .Options}}single{{else}}text{{end}}{{end}}"{{if .Freeform}} freeform{{end}}{{if .Question}} question{{end}}{{if .Native}} native{{end}}{{if .Multiple}} multiple{{end}}>{{if .Native}}{{$multiple := .Multiple}}{{$freeform := .Freeform}}{{if .Options}}<fieldset><legend>{{if $multiple}}Choose one or more options{{else}}Choose an option{{end}}</legend>{{range .Options}}<label><input type="{{if $multiple}}checkbox{{else}}radio{{end}}" name="option" value="{{.ID}}"> {{.Label}}</label>{{end}}</fieldset>{{end}}{{if $freeform}}<label>{{if .Options}}Add details or write your own answer{{else}}Your answer{{end}} <textarea name="custom-answer" maxlength="8000" rows="2"></textarea></label>{{end}}<button type="submit">Submit</button>{{else if .Question}}<label>Your answer <textarea name="content" required maxlength="8000" rows="2"></textarea></label><button type="submit">Reply</button>{{else}}<button type="submit" name="reaction" value="+">Approve</button><button type="submit" name="reaction" value="-">Deny</button>{{end}}</chat-decision><noscript><p>Answering requires JavaScript and a connected signer.</p></noscript>{{end}}{{if ne .State "open"}}</details>{{end}}77 {{else}}<details id="activity-details-{{.ID}}" data-task="{{.ID}}"><summary><strong>{{.Title}}</strong> <small data-status="{{.Status}}">{{.Status}}</small></summary><p><small>{{template "nostrName" .Author}}</small></p><div>{{chatMarkdown .Content}}</div>{{with .Result}}{{if .Content}}<h4>Result</h4><div>{{chatMarkdown .Content}}</div>{{end}}<p><a href="/e/{{.ID}}">Open result</a></p>{{end}}<p><a href="/e/{{.ID}}">Open task</a></p></details>{{end}}88 </article>{{end}}{{end}}{{end}}
M tinyclient/chat-activity.js +3 -2
@@ -77,10 +77,11 @@
7777 const freshOptions=[...(allowed.querySelectorAll?.('input[name="option"]') || [])].map(input=>input.value);7878 if(selected.some(value=>!freshOptions.includes(value))) { host.schedule(); throw Error("This request's options changed."); }7979 if(!selected.length && !custom) throw Error("Choose an option or write an answer.");80- if(custom && selected.length) throw Error("Choose options or write your own answer, not both.");80+ if(selection === "single" && selected.length > 1) throw Error("Choose one option.");8181 if(!custom && (selection === "text" || (selection === "single" && selected.length !== 1))) throw Error("Choose one option.");8282 if(custom && custom.length > 8000) throw Error("Your answer is too long.");83- const content=custom ? (selection === "text" ? custom : JSON.stringify({text:custom})) : (selection === "multiple" ? JSON.stringify(selected) : selected[0]);83+ const content=custom ? (selection === "text" ? custom : JSON.stringify(selected.length ? {choices:selected,text:custom} : {text:custom})) : (selection === "multiple" ? JSON.stringify(selected) : selected[0]);84+ if(content.length > 8000) throw Error("Your answer is too long.");8485 unsigned={kind:1111,created_at:Math.floor(Date.now()/1000),tags:[["h",room],["E",id,"",author],["K",this.getAttribute("kind")],["P",author],["e",id,"",author],["k",this.getAttribute("kind")],["p",author]],content};8586 } else if(this.hasAttribute("question")) {8687 const content=(form.elements.content?.value || "").trim(); if(!content) throw Error("Write an answer first.");
M tinyclient/chat_activity_test.go +24 -0
@@ -70,3 +70,27 @@ func TestChatActivityViewCarriesNativeOptions(t *testing.T) {
7070 t.Fatalf("native options = %#v", items)7171 }7272 }73+74+func TestChatActivityShowsChoiceLabelsAndExplanation(t *testing.T) {75+ actor, other := strings.Repeat("a", 64), strings.Repeat("b", 64)76+ for _, tt := range []struct{ name, content, want string }{77+ {"choice", "c1", "No, something looks wrong"},78+ {"choices", `["c0","c1"]`, "Yes, it looks right\nNo, something looks wrong"},79+ {"explanation", `{"choices":["c1"],"text":"Keep it in this thread."}`, "No, something looks wrong\n\nKeep it in this thread."},80+ {"custom", `{"text":"A different answer."}`, "A different answer."},81+ {"unknown", `{"choices":["unknown"],"text":"Details."}`, `{"choices":["unknown"],"text":"Details."}`},82+ } {83+ t.Run(tt.name, func(t *testing.T) {84+ approvals := map[string]any{"items": []any{map[string]any{85+ "id": strings.Repeat("5", 64), "asker": other, "asked": []string{actor}, "kind": 9,86+ "type": "question", "interaction": "question", "state": "answered", "selection": "single",87+ "options": []any{map[string]any{"id": "c0", "label": "Yes, it looks right"}, map[string]any{"id": "c1", "label": "No, something looks wrong"}},88+ "answer": map[string]any{"decision": "replied", "content": tt.content},89+ }}}90+ items := chatActivityView(nil, approvals, actor, "/chat/activity", "general")91+ if len(items) != 1 || items[0].Answer != tt.want || items[0].CanDecide {92+ t.Fatalf("answered question = %#v; want %q", items, tt.want)93+ }94+ })95+ }96+}
M tinyclient/style.css +8 -1