A kind 43001 request addressed to the agent becomes a Hermes turn in a thread anchored at the request. The adapter publishes one accepted event when processing starts, throttled progress while the turn runs, and exactly one result or error when it ends, skipping requests that already carry a terminal answer. A kind 43005 from the requester, or a !cancel mention in Buzz's convention, stops the turn. Handoffs open a kind 11 thread so a handed-off session continues in its own thread.
- commit
- 4c4f3249a74b6ad0148005dcd96d5f8287e4406e
- parent
- 7472b16121c5ed4ccbce9e324aba80cd86f409bb
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 17 | + |
| M | tinyagent/ | 7 | + |
| M | tinyagent/ | 278 | ++++++++++++++++++++++- |
| A | tinyagent/ | 194 | ++++++++++++++++ |
| M | tinyagent/ | 4 | +- |
| A | tinyagent/ | 365 | ++++++++++++++++++++++++++++++ |
| A | tinyagent/ | 93 | ++++++++ |
@@ -18,3 +18,20 @@ Use `selection=multiple` for checkboxes. Use `selection=text` for an open-ended
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 2020 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.21+22+## Long task cards23+24+A long task is a kind 43001 request that names the agent key in a `p` tag and carries the room in `h`. The Hermes adapter accepts a request from an allowed user in a room it watches, runs it as one turn in a thread anchored at the request (or at the root the request names in a marked `e` tag) and answers with the task kinds. Every answer carries `e` with the request id, `p` with the requester's key and `h` with the room, so clients derive the card state from the newest events.25+26+| Kind | When the adapter publishes it | Content |27+| --- | --- | --- |28+| 43002 accepted | Once, when Hermes starts processing the request, not on receipt. A queued request is accepted when its turn begins. | Empty. |29+| 43003 progress | While the turn runs, at most once every 15 seconds per task. Tool calls are never published one by one. | The newest line of intermediate assistant text, then the newest tool line, each clipped to 200 characters. Nothing is published when nothing changed. |30+| 43004 result | Once, when the turn ends successfully. | The first line of the final reply, clipped to 200 characters, with an extra `e` tag naming the thread message that holds the full text. Posted files add a `Posted name, name` line and one `e` tag per file message. A turn that posted nothing carries the text Hermes returned, else `Done`. |31+| 43006 error | Once, when the turn fails or is stopped without a requester cancel. | The error text Hermes exposes, `The task failed` when there is none, `Cancelled` when Hermes stopped the turn, or `Cancelled by <pubkey>` when an allowed user sent `!cancel`. |32+33+Ordinary replies during the task stay kind 12 messages under the request root, so the room timeline keeps the conversation and the card summarises it. The final text is never repeated in the result; the card links it.34+35+A task gets exactly one terminal event from the adapter. A kind 43005 cancel from the requester ends the task on its own: the adapter stops the turn and publishes nothing further. A `!cancel` message mentioning the agent in the thread, or in the room while the task runs, stops the turn and ends the task with a 43006 naming the sender. On start, the adapter checks the relay for an existing result, error or cancel before running a request, so a restart does not run a finished task again.36+37+Hermes handoffs open a kind 11 thread in the home room whose content is the handoff name; the handed-off session continues there as kind 12 replies.@@ -45,8 +45,15 @@ The helper exposes a JSON-lines RPC mode for the adapter. Its public commands ar
4545 tinyagent keygen4646 TINY_PRIVATE_KEY=YOUR_HEX_PRIVATE_KEY tinyagent rpc --relay https://relay.example.test4747 tinyagent call --relay https://relay.example.test identity48+tinyagent diagnose --relay https://relay.example.test --rooms room-id --kinds 9,124849 ```4950 51+`tinyagent diagnose` reports, as JSON, whether the relay answers, whether it accepts the key's signature, the key's membership and grant state, and which of the named rooms and kinds the grant lacks, with a verdict of `ok`, `needs-grant`, `not-a-member`, `unauthorized` or `unreachable` and one line of advice. A refused signature is never reported as an unreachable relay. The exit status is 0 for `ok`, 2 when the key or its grant is the problem and 3 when the relay is out of reach. Hermes gets the same report from the `tiny_diagnose` MCP tool.52+53+## Long tasks54+55+A member can hand Hermes a long task with a kind 43001 request that names the agent key in a `p` tag. The adapter treats the request like a mention: the subject and content become one turn, and the conversation runs as a thread anchored at the request (or at the thread the request names). Hermes's replies stay in that thread; the task card summarises them. The adapter publishes accepted, progress, result and error events as the turn runs, and a request that already has a result, error or cancel on the relay is not run again after a restart. The requester ends a task with a kind 43005 cancel; any allowed user can stop the current turn of a room or thread by sending `!cancel` with the agent mentioned. See [Tiny agent interactions](../docs/extensions/tinyagent.md#long-task-cards) for the events the adapter publishes and when.56+5057 ## Relay tools in Hermes5158 5259 `tinyagent mcp` gives Hermes the relay's own MCP tools. It is a stdio MCP server that Hermes starts like any other `mcp_servers` entry, and it forwards each call to `<relay>/mcp` with a fresh NIP-98 signature from the agent key. Add it to `config.yaml`:@@ -28,16 +28,22 @@ try:
2828 from .interactions import InteractionMixin2929 from .media import incoming_media3030 from .ingress import EventTracker, IdentityLock, event_thread, reply_parent31+ from . import tasks as taskmod32+ from .tasks import TaskState, answer_tags, request_root, request_text3133 except ImportError: # Hermes plugin loader may import adapter.py as a top-level module.3234 from client import TinyRPC3335 from interactions import InteractionMixin3436 from media import incoming_media3537 from ingress import EventTracker, IdentityLock, event_thread, reply_parent38+ import tasks as taskmod39+ from tasks import TaskState, answer_tags, request_root, request_text3640 3741 logger = logging.getLogger(__name__)3842 3943 _HEX = re.compile(r"\b[0-9a-f]{64}\b", re.I)4044 _MENTION = re.compile(r"(?<![0-9A-Za-z])@([0-9a-f]{64})(?![0-9A-Za-z])", re.I)45+# Buzz's convention: this exact message from an allowed user stops the current turn.46+CANCEL_COMMAND = "!cancel"4147 4248 4349 def _tags(event: dict, name: str) -> list[list[str]]:@@ -69,6 +75,9 @@ class TinyAdapter(InteractionMixin, BasePlatformAdapter):
6975 RECONNECT_MIN = 1.07076 RECONNECT_MAX = 30.07177 SWEEP_INTERVAL = 60.078+ # Long-task cards: at most one 43003 progress event per task in this many seconds.79+ PROGRESS_INTERVAL = 15.080+ MAX_TASKS = 2007281 7382 def __init__(self, config: PlatformConfig):7483 super().__init__(config, Platform("tiny"))@@ -101,6 +110,8 @@ class TinyAdapter(InteractionMixin, BasePlatformAdapter):
101110 self._closed = False102111 self._reconnect_task: asyncio.Task | None = None103112 self._last_sweep = 0.0113+ # request id -> long-task card state, in intake order.114+ self._tasks: dict[str, TaskState] = {}104115 105116 106117 def name(self) -> str:@@ -114,8 +125,18 @@ class TinyAdapter(InteractionMixin, BasePlatformAdapter):
114125 route = {**self._reply_routes.get(event.message_id, {}), "session": session}115126 token = self._turn_route.set(route)116127 self._turn_routes[session] = route128+ task = self._tasks.get(event.message_id)117129 try:118- return await handler(event)130+ response = await handler(event)131+ except BaseException as exc:132+ # The failure hook fires before Hermes posts its notice; keep the text now.133+ if task is not None and not isinstance(exc, asyncio.CancelledError):134+ task.note_error(str(exc) or type(exc).__name__)135+ raise136+ else:137+ if task is not None and isinstance(response, str):138+ task.final_text = response139+ return response119140 finally:120141 if self._turn_routes.get(session) is route:121142 self._turn_routes.pop(session, None)@@ -180,7 +201,8 @@ class TinyAdapter(InteractionMixin, BasePlatformAdapter):
180201 self.pubkey = pubkey181202 if self._tracker is None or self._tracker.identity != pubkey.lower():182203 self._tracker = EventTracker(self.relay_url, pubkey, self.rooms)183- filter: dict[str, Any] = {"kinds": [9, 11, 12, 1111], **self._tracker.filter_since()}204+ filter: dict[str, Any] = {"kinds": [9, 11, 12, 1111, taskmod.KIND_JOB_REQUEST, taskmod.KIND_JOB_CANCEL],205+ **self._tracker.filter_since()}184206 if self.rooms:185207 filter["#h"] = self.rooms186208 await rpc.subscribe(self._subscription, filter, self._on_event)@@ -238,6 +260,8 @@ class TinyAdapter(InteractionMixin, BasePlatformAdapter):
238260 if self._lock is not None:239261 self._lock.release()240262 self._lock = None263+ for task in self._tasks.values():264+ self._stop_progress_timer(task)241265 self._mark_disconnected()242266 243267 async def _on_event(self, event: dict) -> None:@@ -266,6 +290,20 @@ class TinyAdapter(InteractionMixin, BasePlatformAdapter):
266290 return267291 if self._tracker and not self._tracker.should_accept(event, now=int(time.time())):268292 return293+ kind = event.get("kind")294+ if kind == taskmod.KIND_JOB_REQUEST:295+ await self._on_task_request(event, room, author)296+ return297+ if kind == taskmod.KIND_JOB_CANCEL:298+ await self._on_task_cancel(event, author)299+ if self._tracker:300+ self._tracker.mark(event)301+ return302+ if kind in (9, 12) and self._is_cancel_command(event):303+ await self._on_cancel_command(event, room, author)304+ if self._tracker:305+ self._tracker.mark(event)306+ return269307 root = event_thread(event)270308 parent_id = reply_parent(event)271309 parent = None@@ -300,8 +338,13 @@ class TinyAdapter(InteractionMixin, BasePlatformAdapter):
300338 "reply_to_message_id": event_id, "thread_id": thread_id, "user_id": author}301339 if len(self._reply_routes) > 10000:302340 self._reply_routes.pop(next(iter(self._reply_routes)))303- # The event counts as delivered only once Hermes has accepted it. A failed304- # hand-off leaves it eligible for redelivery until the attempt budget is spent.341+ await self._deliver(message, event)342+343+ async def _deliver(self, message: MessageEvent, event: dict) -> None:344+ """Hand a turn to Hermes. The event counts as delivered only once Hermes has345+ accepted it; a failed hand-off leaves it eligible for redelivery until the346+ attempt budget is spent."""347+ event_id = str(event.get("id", ""))305348 try:306349 await self.handle_message(message)307350 except BaseException as exc:@@ -312,6 +355,212 @@ class TinyAdapter(InteractionMixin, BasePlatformAdapter):
312355 if self._tracker:313356 self._tracker.mark(event)314357 358+ # -- long-task cards -------------------------------------------------359+360+ async def _on_task_request(self, event: dict, room: str, author: str) -> None:361+ """Turn a 43001 that asks this key into a thread turn anchored at the request."""362+ request_id = str(event.get("id", ""))363+ if self.pubkey not in [row[1] for row in _tags(event, "p")]:364+ if self._tracker:365+ self._tracker.mark(event)366+ return367+ # Restart safety: a request this key already finished (or the requester368+ # cancelled) is not run again.369+ done = await self.rpc.query({"kinds": list(taskmod.TERMINAL_KINDS), "#e": [request_id],370+ "#h": [room], "limit": 1})371+ if done or request_id in self._tasks:372+ if self._tracker:373+ self._tracker.mark(event)374+ return375+ root = request_root(event)376+ source = self.build_source(chat_id=room, chat_name=room, chat_type="group", user_id=author,377+ thread_id=root, message_id=request_id)378+ message = MessageEvent(text=request_text(event), message_type=MessageType.TEXT, source=source,379+ raw_message=event, message_id=request_id,380+ timestamp=datetime.fromtimestamp(int(event.get("created_at", time.time()))))381+ session = self._event_session_key(message)382+ task = TaskState(request_id, room, author, root, session)383+ self._tasks[request_id] = task384+ while len(self._tasks) > self.MAX_TASKS:385+ self._forget_task(next(iter(self._tasks)))386+ self._session_assignees[session] = author387+ self._reply_routes[request_id] = {"room": room, "root": root, "reply_to_message_id": request_id,388+ "thread_id": root, "user_id": author}389+ try:390+ await self._deliver(message, event)391+ except BaseException:392+ self._forget_task(request_id)393+ raise394+395+ def _forget_task(self, request_id: str) -> None:396+ task = self._tasks.pop(request_id, None)397+ if task is not None:398+ self._stop_progress_timer(task)399+400+ def _task_for_send(self, room: str, tags: list[list[str]]) -> TaskState | None:401+ """Return the running task whose thread a send lands in, if any."""402+ root = next((row[1] for row in tags if len(row) >= 4 and row[0] == "e" and row[3] == "root"), None)403+ if not root:404+ return None405+ matches = [task for task in self._tasks.values()406+ if not task.terminal and task.room == room and task.thread_root == root]407+ return next((task for task in matches if task.accepted), matches[0] if matches else None)408+409+ def _task_for_message(self, message_id: str) -> TaskState | None:410+ return next((task for task in self._tasks.values()411+ if not task.terminal and message_id in task.classes), None)412+413+ def _task_for_session(self, session: str) -> TaskState | None:414+ matches = [task for task in self._tasks.values() if not task.terminal and task.session_key == session]415+ return next((task for task in matches if task.accepted), matches[0] if matches else None)416+417+ def _observe(self, task: TaskState | None, message_id: str | None, content: str, cls: str, *,418+ final: bool = False, file_name: str | None = None) -> None:419+ if task is None or not message_id:420+ return421+ task.observe(message_id, content, cls, final=final, file_name=file_name)422+ self._schedule_progress(task)423+424+ def _clock(self) -> float:425+ return time.monotonic()426+427+ def _schedule_progress(self, task: TaskState) -> None:428+ """Publish pending progress now, or once the throttle window has passed."""429+ if task.terminal or not task.accepted or not task.has_progress or task.timer is not None:430+ return431+ delay = task.progress_due(self._clock(), self.PROGRESS_INTERVAL)432+ if delay <= 0:433+ task.timer = asyncio.ensure_future(self._flush_progress(task.request_id))434+ return435+ loop = asyncio.get_running_loop()436+ task.timer = loop.call_later(delay, self._progress_timer_fired, task.request_id)437+438+ def _progress_timer_fired(self, request_id: str) -> None:439+ task = self._tasks.get(request_id)440+ if task is None:441+ return442+ task.timer = asyncio.ensure_future(self._flush_progress(request_id))443+444+ def _stop_progress_timer(self, task: TaskState) -> None:445+ timer, task.timer = task.timer, None446+ try:447+ current = asyncio.current_task()448+ except RuntimeError:449+ current = None450+ if timer is not None and timer is not current:451+ timer.cancel()452+453+ async def _stop_task_turn(self, task: TaskState) -> None:454+ """Stop the Hermes turn of a task: cancel it when it runs, drop it when still queued."""455+ if task.accepted:456+ await self.cancel_session_processing(task.session_key)457+ return458+ pending = getattr(self, "_pending_messages", {}).get(task.session_key)459+ if pending is not None and str(getattr(pending, "message_id", "")) == task.request_id:460+ self._pending_messages.pop(task.session_key, None)461+462+ async def _flush_progress(self, request_id: str) -> None:463+ task = self._tasks.get(request_id)464+ if task is None:465+ return466+ if task.timer is not None and task.timer is not asyncio.current_task():467+ task.timer.cancel()468+ try:469+ if task.terminal or not task.has_progress:470+ return471+ content = task.take_progress(self._clock())472+ result = await self._publish(task.room, content, answer_tags(task), kind=taskmod.KIND_JOB_PROGRESS)473+ if not result.success:474+ logger.warning("Tiny task %s progress failed: %s", request_id[:8], result.error)475+ finally:476+ task.timer = None477+ if task.has_progress:478+ self._schedule_progress(task)479+480+ async def _publish_terminal(self, task: TaskState, kind: int, content: str, extra: list[list[str]] = ()) -> None:481+ """Publish exactly one terminal event for a task, whatever happens to the send."""482+ if not task.end():483+ return484+ self._stop_progress_timer(task)485+ result = await self._publish(task.room, content, [*answer_tags(task), *extra], kind=kind)486+ if not result.success:487+ logger.error("Tiny task %s terminal event %d failed: %s", task.request_id[:8], kind, result.error)488+ self._forget_task(task.request_id)489+490+ async def on_processing_start(self, event: MessageEvent) -> None:491+ await super().on_processing_start(event)492+ task = self._tasks.get(str(event.message_id or ""))493+ if task is None or task.terminal or task.accepted:494+ return495+ task.accepted = True496+ result = await self._publish(task.room, "", answer_tags(task), kind=taskmod.KIND_JOB_ACCEPTED)497+ if not result.success:498+ logger.warning("Tiny task %s accepted event failed: %s", task.request_id[:8], result.error)499+500+ async def on_processing_complete(self, event: MessageEvent, outcome) -> None:501+ await super().on_processing_complete(event, outcome)502+ task = self._tasks.get(str(event.message_id or ""))503+ if task is None:504+ return505+ name = str(getattr(outcome, "value", outcome)).lower()506+ if task.terminal:507+ self._forget_task(task.request_id)508+ return509+ if name == "success":510+ content, extra = task.result()511+ await self._publish_terminal(task, taskmod.KIND_JOB_RESULT, content, extra)512+ elif name == "cancelled":513+ await self._publish_terminal(task, taskmod.KIND_JOB_ERROR, "Cancelled")514+ else:515+ await self._publish_terminal(task, taskmod.KIND_JOB_ERROR, task.failure())516+517+ async def _on_task_cancel(self, event: dict, author: str) -> None:518+ """A 43005 from the requester ends the task; the cancel itself is the terminal event."""519+ task = self._tasks.get(str(_tag(event, "e") or ""))520+ if task is None or task.terminal or author != task.requester:521+ return522+ task.end()523+ self._stop_progress_timer(task)524+ await self._stop_task_turn(task)525+ self._forget_task(task.request_id)526+527+ def _is_cancel_command(self, event: dict) -> bool:528+ return (str(event.get("content", "")).strip() == CANCEL_COMMAND529+ and self.pubkey in [row[1] for row in _tags(event, "p")])530+531+ async def _on_cancel_command(self, event: dict, room: str, author: str) -> None:532+ """Buzz's !cancel: stop the current turn of the room or thread session instead of533+ forwarding it. A task turn ends with a 43006 naming who cancelled it."""534+ event_id = str(event.get("id", ""))535+ thread_id = event_thread(event) if event.get("kind") == 12 else None536+ source = self.build_source(chat_id=room, chat_name=room, chat_type="group", user_id=author,537+ thread_id=thread_id, message_id=event_id)538+ session = self._event_session_key(MessageEvent(text=CANCEL_COMMAND, source=source, message_id=event_id))539+ task = self._task_for_session(session)540+ if task is None:541+ root = event_thread(event)542+ if root:543+ task = next((entry for entry in self._tasks.values()544+ if not entry.terminal and entry.room == room and entry.thread_root == root), None)545+ elif session not in getattr(self, "_active_sessions", {}):546+ # A bare room-level !cancel with no turn of its own stops the room's newest task.547+ running = [entry for entry in self._tasks.values()548+ if not entry.terminal and entry.room == room and entry.accepted]549+ task = running[-1] if running else None550+ if task is not None:551+ await self._publish_terminal(task, taskmod.KIND_JOB_ERROR, f"Cancelled by {author}")552+ await self._stop_task_turn(task)553+ return554+ await self.cancel_session_processing(session)555+556+ async def create_handoff_thread(self, parent_chat_id: str, name: str) -> str | None:557+ """Open a real thread in the room so a handed-off session continues there."""558+ result = await self._publish(parent_chat_id, str(name or "").strip() or "Handoff", [], kind=11)559+ if not result.success or not result.message_id:560+ logger.warning("Tiny handoff thread failed: %s", result.error)561+ return None562+ return str(result.message_id)563+315564 def _authorized(self, author: str) -> bool:316565 return author.lower() in self.allowed317566 @@ -339,10 +588,19 @@ class TinyAdapter(InteractionMixin, BasePlatformAdapter):
339588 tags.append(["p", pubkey])340589 tags.append(["mention", pubkey])341590 result = await self._publish(chat_id, content, tags, kind=kind)591+ task = self._task_for_send(chat_id, tags)342592 if result.success:343593 self._sent[result.message_id] = tags344594 if len(self._sent) > 1000:345595 self._sent.pop(next(iter(self._sent)))596+ # Hermes marks a streamed draft with expect_edits and a final reply with notify;597+ # anything else in the thread is tool chrome or a status line.598+ meta = metadata or {}599+ final = bool(meta.get("notify"))600+ cls = taskmod.TEXT if final or meta.get("expect_edits") else taskmod.CHROME601+ self._observe(task, result.message_id, content, cls, final=final)602+ elif task is not None:603+ task.note_error(result.error or "Tiny send failed")346604 return result347605 348606 async def edit_message(self, chat_id: str, message_id: str, content: str, *, finalize: bool = False) -> SendResult:@@ -350,8 +608,12 @@ class TinyAdapter(InteractionMixin, BasePlatformAdapter):
350608 for key in _explicit_mentions(content):351609 tags.extend([["p", key], ["mention", key]])352610 result = await self._publish(chat_id, content, tags, kind=40003)611+ task = self._task_for_message(str(message_id))353612 if result.success:354613 result.message_id = message_id614+ self._observe(task, str(message_id), content, taskmod.CHROME, final=finalize)615+ elif task is not None:616+ task.note_error(result.error or "Tiny edit failed")355617 return result356618 357619 async def delete_message(self, chat_id: str, message_id: str) -> bool:@@ -385,7 +647,13 @@ class TinyAdapter(InteractionMixin, BasePlatformAdapter):
385647 tags.append(["imeta", *fields])386648 for key in _explicit_mentions(caption or ""):387649 tags.extend([["p", key], ["mention", key]])388- return await self._publish(chat_id, caption or "", tags, kind=kind)650+ result = await self._publish(chat_id, caption or "", tags, kind=kind)651+ task = self._task_for_send(chat_id, tags)652+ if result.success:653+ self._observe(task, result.message_id, caption or "", taskmod.FILE, file_name=filename)654+ elif task is not None:655+ task.note_error(result.error or "Tiny upload failed")656+ return result389657 except Exception as exc:390658 return SendResult(success=False, error=str(exc), retryable=True)391659 @@ -0,0 +1,194 @@
1+"""Per-task state for long-task cards (kinds 43001 to 43006).2+3+This module has no Hermes or network dependencies. The adapter records what it4+sends during a task turn here and asks this state what to publish; the rules5+for progress and result content live in one place.6+"""7+8+from __future__ import annotations9+10+from dataclasses import dataclass, field11+import re12+from typing import Any13+14+KIND_JOB_REQUEST = 4300115+KIND_JOB_ACCEPTED = 4300216+KIND_JOB_PROGRESS = 4300317+KIND_JOB_RESULT = 4300418+KIND_JOB_CANCEL = 4300519+KIND_JOB_ERROR = 4300620+TERMINAL_KINDS = (KIND_JOB_RESULT, KIND_JOB_CANCEL, KIND_JOB_ERROR)21+22+# Message classes the adapter reports while a task runs.23+TEXT = "text" # assistant text: a streamed draft or a final reply24+CHROME = "chrome" # tool progress bubbles and other status sends25+FILE = "file" # an attachment message26+27+SUMMARY_LIMIT = 20028+PROGRESS_LINE_LIMIT = 20029+RESULT_TEXT_LIMIT = 400030+ERROR_TEXT_LIMIT = 30031+# Streaming cursor glyphs Hermes appends to an in-progress draft.32+_CURSOR = re.compile("[\\s█-▏]+$")33+_SPACES = re.compile(r"\s+")34+35+36+def clip(text: str, limit: int) -> str:37+ """Collapse whitespace and cut to ``limit`` characters with a marker."""38+ text = _SPACES.sub(" ", str(text or "")).strip()39+ if len(text) <= limit:40+ return text41+ return text[: max(0, limit - 3)].rstrip() + "..."42+43+44+def first_line(text: str) -> str:45+ """Return the first non-empty line of ``text``."""46+ return next((line.strip() for line in str(text or "").splitlines() if line.strip()), "")47+48+49+def last_line(text: str) -> str:50+ """Return the last non-empty line of ``text`` without a streaming cursor."""51+ text = _CURSOR.sub("", str(text or ""))52+ return next((line.strip() for line in reversed(text.splitlines()) if line.strip()), "")53+54+55+def request_text(event: dict) -> str:56+ """Return the turn text for a request: the subject line, then the content."""57+ subject = next((row[1] for row in event.get("tags", []) if len(row) > 1 and row[0] == "subject"), "")58+ subject = str(subject or "").strip()59+ content = str(event.get("content", "") or "").strip()60+ if subject and content:61+ return subject + "\n\n" + content62+ return subject or content63+64+65+def request_root(event: dict) -> str:66+ """Return the marked root e tag of a request, else the request id."""67+ for row in event.get("tags", []):68+ if len(row) >= 4 and row[0] == "e" and row[3] == "root" and row[1]:69+ return str(row[1])70+ return str(event.get("id", ""))71+72+73+74+class TaskState:75+ request_id: str76+ room: str77+ requester: str78+ thread_root: str79+ session_key: str80+ accepted: bool = False81+ terminal: bool = False82+ # Monotonic time of the last published progress; None before the first one.83+ last_progress: float | None = None84+ pending_text: str = ""85+ pending_tool: str = ""86+ # Text Hermes returned from the handler, used only when nothing was posted.87+ final_text: str = ""88+ error: str = ""89+ # message id -> latest content and class, in posting order.90+ messages: dict[str, str] = field(default_factory=dict)91+ classes: dict[str, str] = field(default_factory=dict)92+ files: list[tuple[str, str]] = field(default_factory=list)93+ final_id: str | None = None94+ timer: Any = None95+96+ # -- observation -----------------------------------------------------97+98+ def observe(self, message_id: str, content: str, cls: str, *, final: bool = False,99+ file_name: str | None = None) -> None:100+ """Record a successful send or edit that belongs to this task's turn."""101+ if self.terminal or not message_id:102+ return103+ message_id = str(message_id)104+ if message_id not in self.classes:105+ self.classes[message_id] = cls106+ if cls == FILE and file_name and message_id not in {row[0] for row in self.files}:107+ self.files.append((message_id, file_name))108+ if cls != FILE or content:109+ self.messages[message_id] = content or ""110+ if final and self.classes.get(message_id) != FILE:111+ # The result follows a final reply; no progress line repeats it.112+ self.final_id = message_id113+ self.pending_text = self.pending_tool = ""114+ return115+ line = last_line(content)116+ if not line:117+ return118+ if self.classes.get(message_id) == TEXT:119+ self.pending_text = clip(line, PROGRESS_LINE_LIMIT)120+ elif cls != FILE:121+ self.pending_tool = clip(line, PROGRESS_LINE_LIMIT)122+123+ def note_error(self, text: str) -> None:124+ text = clip(text, ERROR_TEXT_LIMIT)125+ if text:126+ self.error = text127+128+ # -- progress --------------------------------------------------------129+130+131+ def has_progress(self) -> bool:132+ return bool(self.pending_text or self.pending_tool)133+134+ def progress_due(self, now: float, interval: float) -> float:135+ """Return seconds to wait before the pending progress may be published."""136+ if self.last_progress is None:137+ return 0.0138+ return max(0.0, self.last_progress + interval - now)139+140+ def take_progress(self, now: float) -> str:141+ """Return the coalesced progress line and reset the pending state."""142+ parts = [part for part in (self.pending_text, self.pending_tool) if part]143+ self.pending_text = self.pending_tool = ""144+ self.last_progress = now145+ return "\n".join(parts)146+147+ # -- terminal --------------------------------------------------------148+149+ def end(self) -> bool:150+ """Mark the task terminal; True only for the first caller."""151+ if self.terminal:152+ return False153+ self.terminal = True154+ self.pending_text = self.pending_tool = ""155+ return True156+157+ def result(self) -> tuple[str, list[list[str]]]:158+ """Return the 43004 content and extra tags.159+160+ Rule: when the final reply is in the thread (a send Hermes marked final or a161+ finalizing edit, else the last text message), the content is its first line,162+ clipped to 200 characters, and an e tag names that message. Posted files add163+ one e tag each and a "Posted ..." line. When nothing was posted, the content164+ is the text Hermes returned, else "Done".165+ """166+ final_id = self.final_id167+ if final_id is None:168+ texts = [key for key, cls in self.classes.items() if cls == TEXT and self.messages.get(key, "").strip()]169+ final_id = texts[-1] if texts else None170+ tags: list[list[str]] = []171+ lines: list[str] = []172+ if final_id is not None and self.messages.get(final_id, "").strip():173+ lines.append(clip(first_line(self.messages[final_id]), SUMMARY_LIMIT))174+ tags.append(["e", final_id])175+ if self.files:176+ lines.append("Posted " + ", ".join(name for _, name in self.files))177+ tags.extend(["e", message_id] for message_id, _ in self.files)178+ if lines:179+ return "\n".join(lines), tags180+ text = str(self.final_text or "").strip()181+ return (text[:RESULT_TEXT_LIMIT] if text else "Done"), []182+183+ def failure(self) -> str:184+ return self.error or "The task failed"185+186+187+def answer_tags(task: TaskState) -> list[list[str]]:188+ """Tags every answer to a request carries besides the room's h tag."""189+ return [["e", task.request_id], ["p", task.requester]]190+191+192+__all__ = ["TaskState", "TERMINAL_KINDS", "KIND_JOB_REQUEST", "KIND_JOB_ACCEPTED", "KIND_JOB_PROGRESS",193+ "KIND_JOB_RESULT", "KIND_JOB_CANCEL", "KIND_JOB_ERROR", "TEXT", "CHROME", "FILE",194+ "answer_tags", "clip", "first_line", "last_line", "request_text", "request_root"]@@ -482,7 +482,7 @@ def test_helper_death_reconnects_from_persisted_cursor(adapter, helper, tmp_path
482482 assert await instance.connect()483483 first = helper.instances[-1]484484 assert instance.is_connected and instance._lock.held485- assert first.filters == [{"kinds": [9, 11, 12, 1111], "since": now - 1000, "#h": ["room"]}]485+ assert first.filters == [{"kinds": [9, 11, 12, 1111, 43001, 43005], "since": now - 1000, "#h": ["room"]}]486486 await first.subscriptions["tinyagent"]({"kind": 9, "id": "d" * 64, "pubkey": OWNER, "created_at": now,487487 "content": "hello", "tags": [["h", "room"], ["p", BOT]]})488488 assert len(received) == 1 and instance._tracker.cursor == now@@ -491,7 +491,7 @@ def test_helper_death_reconnects_from_persisted_cursor(adapter, helper, tmp_path
491491 await asyncio.wait_for(instance._reconnect_task, 2)492492 second = helper.instances[-1]493493 assert second is not first and first.closed and instance.rpc is second494- assert second.filters == [{"kinds": [9, 11, 12, 1111], "since": now - 300, "#h": ["room"]}]494+ assert second.filters == [{"kinds": [9, 11, 12, 1111, 43001, 43005], "since": now - 300, "#h": ["room"]}]495495 assert instance.is_connected and instance._lock.held and marks == ["connected", "connected"]496496 # The same tracker persists across the reconnect: the delivered event stays deduplicated.497497 assert not instance._tracker.should_accept({"id": "d" * 64, "created_at": now, "tags": []})@@ -0,0 +1,365 @@
1+"""Long-task cards through the real Hermes processing pipeline."""2+3+import asyncio4+import hashlib5+import sys6+import time7+from pathlib import Path8+9+import pytest10+11+sys.path.insert(0, str(Path(__file__).parents[1]))12+from adapter import TinyAdapter13+from gateway.config import PlatformConfig14+from gateway.platform_registry import PlatformEntry, platform_registry15+from gateway.platforms.event import ProcessingOutcome16+from ingress import EventTracker17+18+OWNER = "b" * 6419+BOT = "a" * 6420+OTHER = "c" * 6421+STRANGER = "f" * 6422+REQUEST = "d" * 6423+24+25+def tag(event, name):26+ return next((row[1] for row in event.get("tags", []) if len(row) > 1 and row[0] == name), None)27+28+29+class FakeRPC:30+ def __init__(self):31+ self.events = []32+ self.rows = []33+ self.terminal = []34+ self.fail_kinds = set()35+36+ async def query(self, filter):37+ if "ids" in filter:38+ return [row for row in self.rows if row["id"] in filter["ids"]]39+ if "#e" in filter:40+ return [row for row in self.terminal41+ if row["kind"] in filter.get("kinds", []) and tag(row, "e") in filter["#e"]]42+ return []43+44+ async def publish(self, event):45+ if event["kind"] in self.fail_kinds:46+ raise RuntimeError("relay refused the event")47+ result = {"id": f"{len(self.events) + 1:064x}", "created_at": int(time.time()), "pubkey": BOT, **event}48+ self.events.append(result)49+ return result50+51+ async def upload(self, room, filename, mime, data):52+ return {"url": f"https://tiny.test/media/{filename}", "sha256": hashlib.sha256(data).hexdigest()}53+54+ def kinds(self, kind):55+ return [event for event in self.events if event["kind"] == kind]56+57+58+59+def adapter(monkeypatch, tmp_path):60+ monkeypatch.setenv("HERMES_HOME", str(tmp_path))61+ platform_registry.register(PlatformEntry(name="tiny", label="Tiny", adapter_factory=TinyAdapter,62+ check_fn=lambda: True))63+ instance = TinyAdapter(PlatformConfig(enabled=True, extra={"rooms": "room", "allowed_users": f"{OWNER},{OTHER}"}))64+ instance.rpc = FakeRPC()65+ instance.pubkey = BOT66+ yield instance67+68+69+def request(request_id=REQUEST, *, author=OWNER, assignee=BOT, room="room", subject=None, content="Run the build",70+ root=None):71+ tags = [["h", room], ["p", assignee]]72+ if subject:73+ tags.append(["subject", subject])74+ if root:75+ tags.append(["e", root, "", "root"])76+ return {"kind": 43001, "id": request_id, "pubkey": author, "created_at": int(time.time()),77+ "content": content, "tags": tags}78+79+80+def cancel(request_id=REQUEST, *, author=OWNER, event_id="9" * 64):81+ return {"kind": 43005, "id": event_id, "pubkey": author, "created_at": int(time.time()), "content": "",82+ "tags": [["e", request_id], ["h", "room"]]}83+84+85+def bang_cancel(*, author=OTHER, kind=9, event_id="8" * 64, extra=None):86+ return {"kind": kind, "id": event_id, "pubkey": author, "created_at": int(time.time()), "content": " !cancel \n",87+ "tags": [["h", "room"], ["p", BOT], *(extra or [])]}88+89+90+def answer_tags(event):91+ return [row for row in event["tags"] if row[0] in ("e", "p", "h")]92+93+94+async def settle(rounds=5):95+ for _ in range(rounds):96+ await asyncio.sleep(0)97+98+99+def capture(adapter):100+ received = []101+ async def handle(message):102+ received.append(message)103+ adapter.handle_message = handle104+ return received105+106+107+def test_request_becomes_a_thread_turn_anchored_at_the_request(adapter):108+ received = capture(adapter)109+ asyncio.run(adapter._on_event(request(subject="Nightly build", content="Run it and report.")))110+ message = received[0]111+ assert message.text == "Nightly build\n\nRun it and report."112+ assert message.message_id == REQUEST and message.source.thread_id == REQUEST113+ assert message.source.chat_id == "room" and message.source.user_id == OWNER114+ task = adapter._tasks[REQUEST]115+ assert task.requester == OWNER and task.thread_root == REQUEST and not task.accepted116+ assert adapter._session_assignees[task.session_key] == OWNER117+ assert adapter.rpc.events == []118+ asyncio.run(adapter._on_event(request("e" * 64, root="7" * 64, content="Continue here")))119+ assert received[1].text == "Continue here" and received[1].source.thread_id == "7" * 64120+ assert adapter._tasks["e" * 64].thread_root == "7" * 64121+122+123+124+def test_requests_need_an_allowed_author_our_key_and_a_watched_room(adapter, mutation):125+ received = capture(adapter)126+ changes = {"author": {"author": STRANGER}, "assignee": {"assignee": OTHER}, "room": {"room": "elsewhere"}}127+ asyncio.run(adapter._on_event(request(**changes[mutation])))128+ assert received == [] and adapter._tasks == {} and adapter.rpc.events == []129+130+131+132+def test_finished_requests_are_not_run_again(adapter, tmp_path, kind):133+ received = capture(adapter)134+ adapter._tracker = EventTracker("http://relay", BOT, ["room"], state_dir=tmp_path, now=int(time.time()) - 10)135+ adapter.rpc.terminal = [{"kind": kind, "tags": [["e", REQUEST], ["h", "room"]]}]136+ event = request()137+ asyncio.run(adapter._on_event(event))138+ assert received == [] and adapter._tasks == {} and adapter.rpc.events == []139+ assert not adapter._tracker.should_accept(event)140+141+142+def test_accepted_is_published_when_processing_starts(adapter):143+ received = capture(adapter)144+ async def run():145+ await adapter._on_event(request())146+ assert adapter.rpc.events == []147+ await adapter.on_processing_start(received[0])148+ await adapter.on_processing_start(received[0])149+ accepted = adapter.rpc.kinds(43002)150+ assert len(accepted) == 1 and accepted[0]["content"] == ""151+ assert answer_tags(accepted[0]) == [["h", "room"], ["e", REQUEST], ["p", OWNER]]152+ assert adapter._tasks[REQUEST].accepted153+ asyncio.run(run())154+155+156+def test_progress_is_coalesced_and_throttled(adapter, monkeypatch):157+ received = capture(adapter)158+ clock = [1000.0]159+ monkeypatch.setattr(adapter, "_clock", lambda: clock[0])160+ thread = {"thread_id": REQUEST}161+ async def run():162+ await adapter._on_event(request())163+ await adapter.send("room", "Before accept", metadata=thread)164+ await settle()165+ assert adapter.rpc.kinds(43003) == []166+ await adapter.on_processing_start(received[0])167+ chrome = await adapter.send("room", "\U0001f527 terminal: \"ls\"", metadata=thread)168+ await settle()169+ progress = adapter.rpc.kinds(43003)170+ assert [event["content"] for event in progress] == ["\U0001f527 terminal: \"ls\""]171+ assert answer_tags(progress[0]) == [["h", "room"], ["e", REQUEST], ["p", OWNER]]172+ await adapter.edit_message("room", chrome.message_id, "\U0001f527 terminal: \"ls\"\n\U0001f50d web_search: \"docs\"")173+ draft = await adapter.send("room", "Reading the docs ▉", metadata={**thread, "expect_edits": True})174+ await adapter.edit_message("room", draft.message_id, "Reading the docs\nComparing versions ▉")175+ await settle()176+ assert len(adapter.rpc.kinds(43003)) == 1177+ task = adapter._tasks[REQUEST]178+ assert task.timer is not None and task.has_progress179+ clock[0] += adapter.PROGRESS_INTERVAL180+ await adapter._flush_progress(REQUEST)181+ progress = adapter.rpc.kinds(43003)182+ assert len(progress) == 2183+ assert progress[-1]["content"] == "Comparing versions\n\U0001f50d web_search: \"docs\""184+ clock[0] += adapter.PROGRESS_INTERVAL185+ await adapter._flush_progress(REQUEST)186+ assert len(adapter.rpc.kinds(43003)) == 2187+ assert [event["kind"] for event in adapter.rpc.events if event["kind"] in (9, 12)] == [12, 12, 12]188+ assert all(["e", REQUEST, "", "root"] in event["tags"] for event in adapter.rpc.events if event["kind"] == 12)189+ asyncio.run(run())190+191+192+def pipeline(adapter, handler):193+ seen = []194+ async def wrapped(message):195+ seen.append(message)196+ return await handler(message)197+ adapter.set_message_handler(wrapped)198+ async def process(message):199+ await adapter._process_message_background(message, adapter._event_session_key(message))200+ adapter.handle_message = process201+ return seen202+203+204+def test_success_publishes_one_result_linking_the_thread_reply(adapter):205+ async def handler(message):206+ return "Build passed on main.\nAll 42 tests green."207+ seen = pipeline(adapter, handler)208+ asyncio.run(adapter._on_event(request()))209+ events = adapter.rpc.events210+ replies = [event for event in events if event["kind"] == 12]211+ assert len(replies) == 1 and ["e", REQUEST, "", "root"] in replies[0]["tags"]212+ results = adapter.rpc.kinds(43004)213+ assert len(results) == 1214+ assert results[0]["content"] == "Build passed on main."215+ assert answer_tags(results[0]) == [["h", "room"], ["e", REQUEST], ["p", OWNER], ["e", replies[0]["id"]]]216+ order = [event["kind"] for event in events if event["kind"] in (43002, 12, 43004)]217+ assert order == [43002, 12, 43004]218+ assert adapter.rpc.kinds(43006) == [] and adapter.rpc.kinds(43003) == [] and REQUEST not in adapter._tasks219+ asyncio.run(adapter.on_processing_complete(seen[0], ProcessingOutcome.SUCCESS))220+ assert len(adapter.rpc.kinds(43004)) == 1221+222+223+def test_file_only_result_names_and_links_the_files(adapter, tmp_path):224+ note = tmp_path / "report.txt"225+ note.write_text("build log")226+ async def handler(message):227+ return f"MEDIA:{note}"228+ pipeline(adapter, handler)229+ asyncio.run(adapter._on_event(request()))230+ files = [event for event in adapter.rpc.events if any(row[0] == "imeta" for row in event["tags"])]231+ assert len(files) == 1 and files[0]["kind"] == 12232+ results = adapter.rpc.kinds(43004)233+ assert len(results) == 1 and results[0]["content"] == "Posted report.txt"234+ assert ["e", files[0]["id"]] in results[0]["tags"]235+236+237+def test_failure_publishes_one_error_with_the_exposed_text(adapter):238+ async def handler(message):239+ raise RuntimeError("model exploded")240+ pipeline(adapter, handler)241+ asyncio.run(adapter._on_event(request()))242+ errors = adapter.rpc.kinds(43006)243+ assert len(errors) == 1 and errors[0]["content"] == "model exploded"244+ assert answer_tags(errors[0]) == [["h", "room"], ["e", REQUEST], ["p", OWNER]]245+ notice = [event for event in adapter.rpc.events if event["kind"] == 12]246+ assert len(notice) == 1 and "model exploded" in notice[0]["content"]247+ assert adapter.rpc.kinds(43004) == [] and REQUEST not in adapter._tasks248+249+250+def test_terminal_publication_happens_once_even_when_the_send_fails(adapter):251+ async def handler(message):252+ return "Done"253+ seen = pipeline(adapter, handler)254+ adapter.rpc.fail_kinds = {43004}255+ asyncio.run(adapter._on_event(request()))256+ assert adapter.rpc.kinds(43004) == [] and adapter.rpc.kinds(43006) == []257+ assert REQUEST not in adapter._tasks258+ adapter.rpc.fail_kinds = set()259+ asyncio.run(adapter.on_processing_complete(seen[0], ProcessingOutcome.SUCCESS))260+ asyncio.run(adapter.on_processing_complete(seen[0], ProcessingOutcome.FAILURE))261+ assert adapter.rpc.kinds(43004) == [] and adapter.rpc.kinds(43006) == []262+263+264+def slow_turn(adapter):265+ """Install a handler that never finishes on its own; the real handle_message spawns it."""266+ seen = []267+ async def handler(message):268+ seen.append(message)269+ await asyncio.sleep(30)270+ return "never"271+ adapter.set_message_handler(handler)272+ return seen273+274+275+def test_cancelled_turn_publishes_cancelled(adapter):276+ seen = slow_turn(adapter)277+ async def run():278+ await adapter._on_event(request())279+ await settle()280+ assert len(seen) == 1 and adapter._tasks[REQUEST].accepted281+ await adapter.cancel_session_processing(adapter._tasks[REQUEST].session_key)282+ await settle()283+ errors = adapter.rpc.kinds(43006)284+ assert len(errors) == 1 and errors[0]["content"] == "Cancelled"285+ assert adapter.rpc.kinds(43004) == [] and REQUEST not in adapter._tasks286+ asyncio.run(run())287+288+289+def test_requester_cancel_ends_the_task_silently(adapter):290+ seen = slow_turn(adapter)291+ async def run():292+ await adapter._on_event(request())293+ await settle()294+ session = adapter._tasks[REQUEST].session_key295+ await adapter._on_event(cancel(author=OTHER))296+ assert REQUEST in adapter._tasks and not adapter._session_tasks[session].done()297+ await adapter._on_event(cancel())298+ await settle()299+ assert REQUEST not in adapter._tasks300+ assert session not in adapter._session_tasks301+ assert adapter.rpc.kinds(43006) == [] and adapter.rpc.kinds(43004) == []302+ assert len(seen) == 1303+ asyncio.run(run())304+305+306+307+def test_bang_cancel_stops_a_task_and_names_the_canceller(adapter, shape):308+ seen = slow_turn(adapter)309+ async def run():310+ await adapter._on_event(request())311+ await settle()312+ session = adapter._tasks[REQUEST].session_key313+ if shape == "thread":314+ event = bang_cancel(kind=12, extra=[["e", REQUEST, "", "root"]])315+ elif shape == "room-reference":316+ event = bang_cancel(extra=[["e", REQUEST]])317+ else:318+ event = bang_cancel()319+ await adapter._on_event(event)320+ await settle()321+ errors = adapter.rpc.kinds(43006)322+ assert len(errors) == 1 and errors[0]["content"] == f"Cancelled by {OTHER}"323+ assert REQUEST not in adapter._tasks and session not in adapter._session_tasks324+ assert len(seen) == 1325+ asyncio.run(run())326+327+328+def test_bang_cancel_stops_an_ordinary_turn_without_forwarding(adapter):329+ seen = slow_turn(adapter)330+ cancelled = []331+ async def cancel_session(session_key, **kwargs):332+ cancelled.append(session_key)333+ adapter.cancel_session_processing = cancel_session334+ async def run():335+ plain = {"kind": 9, "id": "5" * 64, "pubkey": OWNER, "created_at": int(time.time()),336+ "content": "hello", "tags": [["h", "room"], ["p", BOT]]}337+ await adapter._on_event(plain)338+ await settle()339+ assert len(seen) == 1340+ await adapter._on_event(bang_cancel(author=OWNER))341+ assert cancelled == [adapter._event_session_key(seen[0])]342+ assert len(seen) == 1 and {event["kind"] for event in adapter.rpc.events} <= {20002}343+ # Without our p tag the text is an ordinary message and follows the mention rule.344+ await adapter._on_event({**bang_cancel(author=OWNER, event_id="6" * 64), "tags": [["h", "room"]]})345+ assert len(seen) == 1 and cancelled == [adapter._event_session_key(seen[0])]346+ for task in list(adapter._session_tasks.values()):347+ task.cancel()348+ await asyncio.gather(*adapter._session_tasks.values(), return_exceptions=True)349+ asyncio.run(run())350+351+352+def test_create_handoff_thread_opens_a_real_thread(adapter):353+ async def run():354+ thread_id = await adapter.create_handoff_thread("room", "Hermes - review")355+ root = adapter.rpc.events[-1]356+ assert thread_id == root["id"] and root["kind"] == 11 and root["content"] == "Hermes - review"357+ assert root["tags"] == [["h", "room"]]358+ result = await adapter.send("room", "Continuing here", metadata={"thread_id": thread_id})359+ reply = adapter.rpc.events[-1]360+ assert result.success and reply["kind"] == 12 and ["e", thread_id, "", "root"] in reply["tags"]361+ assert await adapter.create_handoff_thread("room", "") is not None362+ assert adapter.rpc.events[-1]["content"] == "Handoff"363+ adapter.rpc.fail_kinds = {11}364+ assert await adapter.create_handoff_thread("room", "Broken") is None365+ asyncio.run(run())@@ -0,0 +1,93 @@
1+"""Hermes-free rules for long-task cards: intake text, progress coalescing and results."""2+3+import sys4+from pathlib import Path5+6+sys.path.insert(0, str(Path(__file__).parents[1]))7+8+from tasks import CHROME, FILE, TEXT, TaskState, answer_tags, clip, last_line, request_root, request_text9+10+OWNER = "b" * 6411+12+13+def task(**overrides):14+ fields = dict(request_id="1" * 64, room="room", requester=OWNER, thread_root="1" * 64, session_key="s")15+ fields.update(overrides)16+ return TaskState(**fields)17+18+19+def test_request_text_prefixes_subject_and_falls_back():20+ assert request_text({"content": "Do the thing", "tags": [["subject", "Nightly build"]]}) == "Nightly build\n\nDo the thing"21+ assert request_text({"content": "", "tags": [["subject", "Nightly build"]]}) == "Nightly build"22+ assert request_text({"content": "Do the thing", "tags": []}) == "Do the thing"23+24+25+def test_request_root_prefers_marked_root():26+ assert request_root({"id": "1" * 64, "tags": [["e", "2" * 64, "", "root"]]}) == "2" * 6427+ assert request_root({"id": "1" * 64, "tags": [["e", "2" * 64]]}) == "1" * 6428+29+30+def test_progress_coalesces_latest_text_and_tool_line():31+ state = task(accepted=True)32+ state.observe("m1", "Looking at the repo ▉", TEXT)33+ state.observe("m2", "\U0001f527 terminal: \"ls\"", CHROME)34+ state.observe("m2", "\U0001f527 terminal: \"ls\"\n\U0001f50d web_search: \"docs\"", CHROME)35+ state.observe("m1", "Looking at the repo\nStill reading the docs ▉", CHROME)36+ assert state.has_progress37+ assert state.take_progress(now=100.0) == "Still reading the docs\n\U0001f50d web_search: \"docs\""38+ assert not state.has_progress and state.last_progress == 100.039+ assert state.progress_due(now=110.0, interval=15.0) == 5.040+ assert state.progress_due(now=115.0, interval=15.0) == 0.041+ assert task().progress_due(now=0.0, interval=15.0) == 0.042+43+44+def test_result_links_final_message_with_summary_line():45+ state = task()46+ state.observe("draft", "Working on it", TEXT)47+ state.observe("final", " First line of the answer.\nMore detail below.", TEXT, final=True)48+ assert not state.has_progress49+ content, tags = state.result()50+ assert content == "First line of the answer."51+ assert tags == [["e", "final"]]52+53+54+def test_result_uses_last_text_message_without_a_final_mark():55+ state = task()56+ state.observe("first", "One", TEXT)57+ state.observe("chrome", "\U0001f527 tool", CHROME)58+ state.observe("second", "Two " * 100, TEXT)59+ content, tags = state.result()60+ assert content.endswith("...") and len(content) == 20061+ assert tags == [["e", "second"]]62+63+64+def test_result_names_posted_files():65+ state = task()66+ state.observe("f1", "", FILE, file_name="report.pdf")67+ state.observe("f2", "caption", FILE, file_name="chart.png")68+ assert state.result() == ("Posted report.pdf, chart.png", [["e", "f1"], ["e", "f2"]])69+ state.observe("final", "Here you go", TEXT, final=True)70+ assert state.result() == ("Here you go\nPosted report.pdf, chart.png", [["e", "final"], ["e", "f1"], ["e", "f2"]])71+72+73+def test_result_without_posts_uses_returned_text_or_done():74+ state = task()75+ assert state.result() == ("Done", [])76+ state.final_text = "Quiet answer"77+ assert state.result() == ("Quiet answer", [])78+79+80+def test_terminal_guard_and_failure_text():81+ state = task()82+ assert state.failure() == "The task failed"83+ state.note_error(" boom\nline two ")84+ assert state.failure() == "boom line two"85+ assert state.end() and not state.end()86+ state.observe("late", "ignored", TEXT)87+ assert state.messages == {} and not state.has_progress88+89+90+def test_helpers():91+ assert answer_tags(task()) == [["e", "1" * 64], ["p", OWNER]]92+ assert clip("a b\n c", 5) == "a b c" and clip("abcdef", 5) == "ab..."93+ assert last_line("one\ntwo ▉\n\n") == "two"