feat: background-process completions paint a compact title, not the raw notification wall
Subagent completions already got this: the model receives the full
`[ASYNC DELEGATION …]` text while the CLI/TUI/Desktop paint a one-line
"Subagent Task Completed: <goal>" event. Background-process completions
(`terminal(background=True, notify=True)`) still echoed the entire
`[IMPORTANT: Background process proc_… completed normally (exit code 0).
Command: … Output: …]` block as if the user had typed it.
Generalise the delegation mechanism: `TimelineNotification` (formerly
`SubagentNotification`) carries `display_kind` + `display_text`;
`ProcessNotificationBatch` renders a `process_complete` one with a
`process_completion_display_text` title ("Background Process Finished:
<cmd>", "Background Process Failed (exit 1): <cmd>", "N Background
Processes Finished"). The TUI gateway stamps the same kind/metadata on
the synthesized turn and emits the title on `status.update`; Ink and
Desktop project `process_complete` rows as timeline events (Desktop keeps
the raw output behind the existing expandable async-result row). Model
content is byte-identical to before.
This commit is contained in:
@@ -147,6 +147,12 @@ function timelineTaskCount(metadata: SessionMessage['display_metadata']): number
|
||||
return typeof count === 'number' ? count : undefined
|
||||
}
|
||||
|
||||
function timelineDisplayText(metadata: SessionMessage['display_metadata']): string | undefined {
|
||||
const text = parseDisplayMetadata(metadata)?.display_text
|
||||
|
||||
return typeof text === 'string' && text.trim() ? text : undefined
|
||||
}
|
||||
|
||||
function messageReactions(metadata: SessionMessage['display_metadata']): MessageReaction[] {
|
||||
const reactions = parseDisplayMetadata(metadata)?.reactions
|
||||
|
||||
@@ -164,7 +170,13 @@ function messageReactions(metadata: SessionMessage['display_metadata']): Message
|
||||
function asyncResultBody(content: string): string | undefined {
|
||||
let bodies = [content]
|
||||
|
||||
if (content.startsWith('[ASYNC DELEGATION')) {
|
||||
if (content.startsWith('[IMPORTANT: ')) {
|
||||
// Background-process completion: one `[IMPORTANT: …]` block per process, a batch header first.
|
||||
bodies = content
|
||||
.split(/\n\n(?=\[IMPORTANT: )/)
|
||||
.map(block => block.replace(/^\[IMPORTANT:\s*/, '').replace(/\]$/, ''))
|
||||
.filter(block => !/^\d+ background processes completed\./.test(block))
|
||||
} else if (content.startsWith('[ASYNC DELEGATION')) {
|
||||
if (content.startsWith('[ASYNC DELEGATION BATCH COMPLETE')) {
|
||||
// Task goals can span lines; stopping at a newline leaks the next goal and transcript footer.
|
||||
bodies = content.split(/^--- [✓✗⚠] TASK \d+\/\d+(?:: [\s\S]*?)? {2}\(status=[^\n]*\) ---\r?\n/gm).slice(1)
|
||||
@@ -203,9 +215,16 @@ function timelineDisplayContent(message: SessionMessage, content: string): strin
|
||||
if (message.display_kind === 'async_delegation_complete') {
|
||||
const count = timelineTaskCount(message.display_metadata)
|
||||
|
||||
return count === undefined
|
||||
? 'background agent work finished'
|
||||
: `${count} background agent${count === 1 ? '' : 's'} finished`
|
||||
return (
|
||||
timelineDisplayText(message.display_metadata) ??
|
||||
(count === undefined
|
||||
? 'background agent work finished'
|
||||
: `${count} background agent${count === 1 ? '' : 's'} finished`)
|
||||
)
|
||||
}
|
||||
|
||||
if (message.display_kind === 'process_complete') {
|
||||
return timelineDisplayText(message.display_metadata) ?? 'background process finished'
|
||||
}
|
||||
|
||||
return content
|
||||
@@ -298,6 +317,7 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
|
||||
const displayRole =
|
||||
message.display_kind === 'model_switch' ||
|
||||
message.display_kind === 'async_delegation_complete' ||
|
||||
message.display_kind === 'process_complete' ||
|
||||
message.display_kind === 'auto_continue' ||
|
||||
message.display_kind === 'personality_switch'
|
||||
? 'system'
|
||||
@@ -409,7 +429,7 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
|
||||
id: `${message.timestamp || Date.now()}-${index}-${displayRole}`,
|
||||
role: displayRole,
|
||||
parts,
|
||||
...(message.display_kind === 'async_delegation_complete'
|
||||
...(message.display_kind === 'async_delegation_complete' || message.display_kind === 'process_complete'
|
||||
? { asyncResult: asyncResultBody(displayContentForMessage(message.role, message.content || content)) }
|
||||
: {}),
|
||||
timestamp: earliestTimestamp(message.timestamp, ...parts.map(part => part.timestamp)),
|
||||
|
||||
@@ -549,7 +549,9 @@ export type TimelineDisplayMetadata =
|
||||
completed_count?: number
|
||||
failed_count?: number
|
||||
duration_seconds?: number
|
||||
display_text?: string
|
||||
}
|
||||
| { display_text: string }
|
||||
| { reactions: MessageReaction[] }
|
||||
|
||||
/** One emoji reaction on a message. One per author, iOS-Tapback style. */
|
||||
@@ -580,7 +582,14 @@ export interface SessionMessage {
|
||||
reasoning_content?: null | string
|
||||
reasoning_details?: unknown
|
||||
display_kind?:
|
||||
'async_delegation_complete' | 'auto_continue' | 'hidden' | 'model_switch' | 'personality_switch' | 'steer' | string
|
||||
| 'async_delegation_complete'
|
||||
| 'auto_continue'
|
||||
| 'hidden'
|
||||
| 'model_switch'
|
||||
| 'personality_switch'
|
||||
| 'process_complete'
|
||||
| 'steer'
|
||||
| string
|
||||
/**
|
||||
* A backend older than this app can still serve this as unparsed JSON text,
|
||||
* so readers must narrow before indexing into it.
|
||||
|
||||
@@ -3454,11 +3454,11 @@ class HermesCLI(CLIProcessNotificationsMixin, CLIAgentSetupMixin, CLICommandsMix
|
||||
|
||||
def _tui_process_one_input(self, user_input):
|
||||
"""Route one submitted input: file drop, /resume pick, ! shell, slash command, or a chat turn."""
|
||||
from tools.process_registry_notifications import SubagentNotification
|
||||
notification_preview = user_input if isinstance(user_input, SubagentNotification) else None
|
||||
from tools.process_registry_notifications import TimelineNotification
|
||||
user_input, is_voice_input, is_seeded_query = self._tui_unwrap_input(user_input)
|
||||
if not user_input:
|
||||
return
|
||||
notification_preview = user_input if isinstance(user_input, TimelineNotification) else None
|
||||
self._status_bar_suppressed_after_resize = False # input ends post-resize suppression
|
||||
|
||||
submit_images = []
|
||||
|
||||
@@ -97,6 +97,7 @@ def _tool_calls_summary(tool_calls) -> str:
|
||||
_RESUME_EVENT_TEXT = {
|
||||
"model_switch": "model changed",
|
||||
"async_delegation_complete": "background delegation completed",
|
||||
"process_complete": "background process finished",
|
||||
"auto_continue": "resumed interrupted turn"}
|
||||
|
||||
def _collect_resume_entries(display_history, disp: dict, clean_assistant):
|
||||
@@ -125,7 +126,7 @@ def _collect_resume_entries(display_history, disp: dict, clean_assistant):
|
||||
continue
|
||||
if display_kind in _RESUME_EVENT_TEXT:
|
||||
metadata = msg.get("display_metadata") or {}
|
||||
label = metadata.get("display_text") if display_kind == "async_delegation_complete" else None
|
||||
label = metadata.get("display_text") if display_kind in ("async_delegation_complete", "process_complete") else None
|
||||
entries.append(("event", _sanitize_display_text(label or _RESUME_EVENT_TEXT[display_kind])))
|
||||
continue
|
||||
if role == "user":
|
||||
|
||||
@@ -35,7 +35,7 @@ class CLIChatTurnMixin:
|
||||
the concise voice-response prefix, #65827)
|
||||
"""
|
||||
from cli import ChatConsole, _ChatTurn, _DIM, _RST, _accent_hex, _cprint, set_secret_capture_callback
|
||||
from tools.process_registry_notifications import SubagentNotification
|
||||
from tools.process_registry_notifications import TimelineNotification
|
||||
# Single-query and direct chat callers do not go through run().
|
||||
set_secret_capture_callback(self._secret_capture_callback)
|
||||
# Reset per turn; only a real interrupt flips it, so early returns leave it False.
|
||||
@@ -57,7 +57,7 @@ class CLIChatTurnMixin:
|
||||
return None
|
||||
message = self._chat_route_images(message, images)
|
||||
|
||||
if isinstance(message, str) and not isinstance(message, SubagentNotification):
|
||||
if isinstance(message, str) and not isinstance(message, TimelineNotification):
|
||||
message, blocked = self._chat_expand_context_references(message)
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
@@ -66,7 +66,7 @@ class CLIChatTurnMixin:
|
||||
message = _sanitize_surrogates(message)
|
||||
|
||||
self._chat_stage_user_message(agent, message)
|
||||
if isinstance(message, SubagentNotification):
|
||||
if isinstance(message, TimelineNotification):
|
||||
message = str(message) # UI metadata is on the staged row, never in model content.
|
||||
|
||||
ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]")
|
||||
@@ -207,9 +207,9 @@ class CLIChatTurnMixin:
|
||||
agent._persist_user_message_override = None
|
||||
agent._persist_user_message_timestamp = None
|
||||
staged_user_message = stamp_message_timestamp({"role": "user", "content": message})
|
||||
from tools.process_registry_notifications import SubagentNotification
|
||||
if isinstance(message, SubagentNotification):
|
||||
staged_user_message.update(content=str(message), display_kind="async_delegation_complete",
|
||||
from tools.process_registry_notifications import TimelineNotification
|
||||
if isinstance(message, TimelineNotification):
|
||||
staged_user_message.update(content=str(message), display_kind=message.display_kind,
|
||||
display_metadata={"display_text": message.display_text})
|
||||
agent._pending_cli_user_message = staged_user_message
|
||||
self.conversation_history.append(staged_user_message)
|
||||
|
||||
@@ -23,7 +23,7 @@ class CLIProcessNotificationsMixin:
|
||||
from tools.process_registry import process_registry
|
||||
from tools.async_delegation import claim_event_delivery, complete_event_delivery
|
||||
from tools.process_registry_notifications import (
|
||||
ProcessNotificationBatch, SubagentNotification, group_process_notifications)
|
||||
ProcessNotificationBatch, TimelineNotification, group_process_notifications)
|
||||
|
||||
claimed = []
|
||||
for event, text in process_registry.drain_notifications(
|
||||
@@ -39,16 +39,19 @@ class CLIProcessNotificationsMixin:
|
||||
if event.get("type", "completion") == "completion":
|
||||
pending = ProcessNotificationBatch(notifications)
|
||||
else:
|
||||
pending = SubagentNotification(text, event) if event.get("type") == "async_delegation" else text
|
||||
pending = TimelineNotification.for_delegation(text, event) if event.get("type") == "async_delegation" else text
|
||||
self._pending_input.put(pending)
|
||||
|
||||
def _tui_unwrap_input(self, user_input):
|
||||
"""Unwrap ``_VoiceInputMessage`` / ``_SeededQueryMessage`` -> ``(text_or_tuple, is_voice_input, is_seeded_query)``."""
|
||||
from cli import _VoiceInputMessage, _SeededQueryMessage
|
||||
from tools.process_registry import process_registry
|
||||
from tools.process_registry_notifications import ProcessNotificationBatch
|
||||
from tools.process_registry_notifications import (
|
||||
PROCESS_COMPLETE_DISPLAY_KIND, ProcessNotificationBatch, TimelineNotification)
|
||||
if isinstance(user_input, ProcessNotificationBatch):
|
||||
user_input = user_input.render(process_registry)
|
||||
rendered = user_input.render(process_registry)
|
||||
user_input = rendered and TimelineNotification(
|
||||
rendered, user_input.display_text(process_registry), PROCESS_COMPLETE_DISPLAY_KIND)
|
||||
# Voice-transcribed messages arrive wrapped in a sentinel so only genuine STT output gets the voice
|
||||
# prefix (#65827).
|
||||
is_voice_input = isinstance(user_input, _VoiceInputMessage)
|
||||
|
||||
@@ -210,8 +210,8 @@ class CLIStreamMixin:
|
||||
def _print_user_message_preview(self, user_input: str) -> None:
|
||||
"""Render a user message using the normal chat scrollback style."""
|
||||
from cli import ChatConsole, _accent_hex
|
||||
from tools.process_registry_notifications import SubagentNotification
|
||||
if isinstance(user_input, SubagentNotification):
|
||||
from tools.process_registry_notifications import TimelineNotification
|
||||
if isinstance(user_input, TimelineNotification):
|
||||
ChatConsole().print(f"[dim]◈ {_escape(user_input.display_text)}[/dim]")
|
||||
return
|
||||
ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]")
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Background-process completions paint a compact title while the model keeps the raw wall."""
|
||||
import queue
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
from cli import HermesCLI
|
||||
from tools.process_registry_notifications import (
|
||||
PROCESS_COMPLETE_DISPLAY_KIND, format_process_notification, process_completion_display_text)
|
||||
from tui_gateway import server
|
||||
|
||||
|
||||
def _registry(events):
|
||||
return SimpleNamespace(
|
||||
drain_notifications=lambda **kw: [(e, format_process_notification(e)) for e in events],
|
||||
completion_queue=queue.Queue(), is_completion_consumed=lambda sid: False)
|
||||
|
||||
|
||||
def _event(sid, exit_code, command="cd /tmp && bash long-build.sh"):
|
||||
return {"type": "completion", "session_id": sid, "session_key": "display-session", "command": command,
|
||||
"exit_code": exit_code, "completion_reason": "exited", "output": "web tsc=0\nSECRET_OUTPUT_LINE"}
|
||||
|
||||
|
||||
def test_process_completion_display_keeps_payload_separate_across_surfaces(monkeypatch, capsys, tmp_path):
|
||||
events = [_event("proc_1", 0)]
|
||||
payload = format_process_notification(events[0])
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli.session_id = "display-session"
|
||||
cli._pending_input = queue.Queue()
|
||||
registry = _registry(events)
|
||||
monkeypatch.setattr("tools.process_registry.process_registry", registry)
|
||||
monkeypatch.setattr("tools.async_delegation.claim_event_delivery", lambda *a: "claimed")
|
||||
monkeypatch.setattr("tools.async_delegation.complete_event_delivery", lambda *a: None)
|
||||
cli._drain_process_notifications("cli-idle")
|
||||
for attr, value in {"_pending_resume_sessions": [], "_typed_voice_stop": lambda t: False,
|
||||
"handle_bang_shell": lambda t: False, "_turn_summary_begin": lambda: None,
|
||||
"_tui_after_turn": lambda: None, "_app": SimpleNamespace(invalidate=lambda: None),
|
||||
"chat": Mock()}.items():
|
||||
setattr(cli, attr, value)
|
||||
cli._tui_process_one_input(cli._pending_input.get_nowait())
|
||||
visible = capsys.readouterr().out
|
||||
expected = "Background Process Finished: cd /tmp && bash long-build.sh"
|
||||
assert expected in visible
|
||||
assert "[IMPORTANT" not in visible and "SECRET_OUTPUT_LINE" not in visible
|
||||
queued = cli.chat.call_args.args[0]
|
||||
assert queued == payload # the model still receives the full notification
|
||||
|
||||
cli.conversation_history = []
|
||||
cli.agent = SimpleNamespace(run_conversation=Mock(return_value={}))
|
||||
cli._chat_stage_user_message(cli.agent, queued)
|
||||
staged = cli.conversation_history[-1]
|
||||
assert staged["content"] == payload and type(staged["content"]) is str
|
||||
assert staged["display_kind"] == PROCESS_COMPLETE_DISPLAY_KIND
|
||||
assert staged["display_metadata"]["display_text"] == expected
|
||||
|
||||
from hermes_cli.cli_agent_setup_mixin import _collect_resume_entries
|
||||
entries, _, _ = _collect_resume_entries(cli.conversation_history, {}, lambda text: text)
|
||||
assert entries == [("event", expected)]
|
||||
|
||||
# TUI gateway: the status line and the persisted turn carry the same compact title.
|
||||
emitted, submitted = [], []
|
||||
monkeypatch.setattr(server, "_emit", lambda *args: emitted.append(args))
|
||||
monkeypatch.setattr(server, "_notif_submit", lambda *args, **kw: submitted.append((args, kw)))
|
||||
monkeypatch.setattr(server, "_notif_claim_turn", lambda session: True)
|
||||
session = {"session_key": "display-session", "history_lock": threading.RLock()}
|
||||
server._notif_handle_ready("ui-session", session, events, set(), registry, format_process_notification, None,
|
||||
owned=True)
|
||||
assert emitted[0][2] == {"kind": "process", "text": expected}
|
||||
(_rid, _sid, _session, text, _what), kwargs = submitted[0]
|
||||
assert text == payload
|
||||
assert kwargs["display_kind"] == PROCESS_COMPLETE_DISPLAY_KIND
|
||||
assert kwargs["display_metadata"] == {"display_text": expected}
|
||||
|
||||
|
||||
def test_process_completion_titles_reflect_outcome_and_batch():
|
||||
assert process_completion_display_text([_event("p", 1)]) == (
|
||||
"Background Process Failed (exit 1): cd /tmp && bash long-build.sh")
|
||||
killed = {**_event("p", -15), "completion_reason": "killed"}
|
||||
assert process_completion_display_text([killed]).startswith("Background Process Terminated: ")
|
||||
assert process_completion_display_text([_event("a", 0), _event("b", 2)]) == "2 Background Processes Finished"
|
||||
long_cmd = "x" * 200
|
||||
title = process_completion_display_text([_event("p", 0, command=long_cmd)])
|
||||
assert title.endswith("...") and len(title) < 120
|
||||
@@ -7233,7 +7233,7 @@ def test_notification_poller_live_loop_requeues_foreign_completion_for_owner(
|
||||
monkeypatch.setattr(server, "_get_db", lambda: None)
|
||||
monkeypatch.setattr(server, "_emit", lambda *args, **_kwargs: emitted.append(args))
|
||||
|
||||
def _deliver(_rid, sid, session, text):
|
||||
def _deliver(_rid, sid, session, text, **_kw):
|
||||
delivered["a" if sid == "sid-a-live-handoff" else "b"].append(text)
|
||||
session["running"] = False
|
||||
|
||||
@@ -7342,7 +7342,7 @@ def test_notification_poller_live_loop_drops_addressed_orphan(
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_run_prompt_submit",
|
||||
lambda _rid, _sid, _session, text: delivered.append(text),
|
||||
lambda _rid, _sid, _session, text, **_kw: delivered.append(text),
|
||||
)
|
||||
server._sessions["sid-live-orphan"] = session
|
||||
process_registry._completion_consumed.discard(event["session_id"])
|
||||
@@ -7383,7 +7383,7 @@ def test_notification_poller_drops_orphaned_events(monkeypatch, routing):
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_run_prompt_submit",
|
||||
lambda _rid, _sid, _session, text: delivered.append(text),
|
||||
lambda _rid, _sid, _session, text, **_kw: delivered.append(text),
|
||||
)
|
||||
monkeypatch.setattr(server, "_get_db", lambda: None)
|
||||
|
||||
@@ -7449,7 +7449,7 @@ def test_notification_poller_delivers_owned_events(
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_run_prompt_submit",
|
||||
lambda _rid, _sid, _session, text: delivered.append(text),
|
||||
lambda _rid, _sid, _session, text, **_kw: delivered.append(text),
|
||||
)
|
||||
monkeypatch.setattr(server, "_get_db", lambda: _CompressionDB())
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from dataclasses import dataclass
|
||||
from contextlib import suppress
|
||||
|
||||
_DONE = ("completed", "success")
|
||||
_REASON_STATUS = {"lost": "marked lost because the process backend disappeared", "failed_start": "failed to start"}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -16,9 +17,12 @@ class ProcessNotificationBatch:
|
||||
|
||||
notifications: tuple[tuple[dict, str], ...]
|
||||
|
||||
def _live(self, registry) -> list:
|
||||
return [(event, text) for event, text in self.notifications
|
||||
if not registry.is_completion_consumed(event.get("session_id", ""))]
|
||||
|
||||
def render(self, registry) -> str | None:
|
||||
messages = [text for event, text in self.notifications
|
||||
if not registry.is_completion_consumed(event.get("session_id", ""))]
|
||||
messages = [text for _event, text in self._live(registry)]
|
||||
if not messages:
|
||||
return None
|
||||
if len(messages) == 1:
|
||||
@@ -28,6 +32,9 @@ class ProcessNotificationBatch:
|
||||
"preserve failures and actionable results.]")
|
||||
return "\n\n".join((header, *messages))
|
||||
|
||||
def display_text(self, registry) -> str:
|
||||
return process_completion_display_text([event for event, _text in self._live(registry)])
|
||||
|
||||
|
||||
def group_process_notifications(notifications):
|
||||
"""Group consecutive completions only; watches and delegations are barriers."""
|
||||
@@ -298,16 +305,48 @@ def async_delegation_display_text(evt: dict) -> str:
|
||||
return f"Subagent Tasks {outcome}: {title} ({len(results)} tasks)"
|
||||
|
||||
|
||||
class SubagentNotification(str):
|
||||
"""Keep queued model text string-compatible, with a separate human preview."""
|
||||
PROCESS_COMPLETE_DISPLAY_KIND = "process_complete"
|
||||
|
||||
|
||||
def _short_command(command) -> str:
|
||||
cmd = " ".join(str(command or "").split())
|
||||
return cmd[:77] + "..." if len(cmd) > 80 else cmd
|
||||
|
||||
|
||||
def process_completion_display_text(events: list) -> str:
|
||||
"""Compact UI title for one or more process completions; the model text keeps the full output."""
|
||||
if len(events) != 1:
|
||||
return f"{len(events)} Background Processes Finished"
|
||||
evt = events[0]
|
||||
reason, exit_code = evt.get("completion_reason") or "exited", evt.get("exit_code", "?")
|
||||
if reason == "killed":
|
||||
outcome = "Terminated"
|
||||
elif reason in _REASON_STATUS:
|
||||
outcome = "Lost" if reason == "lost" else "Failed to Start"
|
||||
else:
|
||||
outcome = "Finished" if exit_code == 0 else "Failed"
|
||||
cmd = _short_command(evt.get("command"))
|
||||
detail = f" (exit {exit_code})" if reason not in ("killed", *_REASON_STATUS) and exit_code != 0 else ""
|
||||
return f"Background Process {outcome}{detail}: {cmd}" if cmd else f"Background Process {outcome}{detail}"
|
||||
|
||||
|
||||
class TimelineNotification(str):
|
||||
"""Queued model text that stays string-compatible, plus the display kind and compact human
|
||||
title the surface paints instead of the raw notification wall."""
|
||||
|
||||
display_text: str
|
||||
display_kind: str
|
||||
|
||||
def __new__(cls, text: str, event: dict):
|
||||
def __new__(cls, text: str, display_text: str, display_kind: str):
|
||||
instance = super().__new__(cls, text)
|
||||
instance.display_text = async_delegation_display_text(event)
|
||||
instance.display_text = display_text
|
||||
instance.display_kind = display_kind
|
||||
return instance
|
||||
|
||||
@classmethod
|
||||
def for_delegation(cls, text: str, event: dict) -> "TimelineNotification":
|
||||
return cls(text, async_delegation_display_text(event), "async_delegation_complete")
|
||||
|
||||
|
||||
def _delegation_attribution_line(evt: dict) -> "str | None":
|
||||
"""One-line provenance for a subagent-owned process event, else None. Such a process
|
||||
@@ -329,9 +368,6 @@ def _delegation_attribution_line(evt: dict) -> "str | None":
|
||||
+ (f' Task: "{goal}"' if goal else ""))
|
||||
|
||||
|
||||
_REASON_STATUS = {"lost": "marked lost because the process backend disappeared", "failed_start": "failed to start"}
|
||||
|
||||
|
||||
def _completion_status(evt: dict) -> str:
|
||||
reason = evt.get("completion_reason") or "exited"
|
||||
if reason == "killed":
|
||||
|
||||
@@ -431,8 +431,9 @@ def _notif_handle_event(sid, session, evt, emitted, registry, fmt, deferred, com
|
||||
# while distinct watch_match events from one process must stay visible.
|
||||
dedup_key = _notification_event_dedup_key(evt)
|
||||
if dedup_key not in emitted:
|
||||
from tools.process_registry_notifications import async_delegation_display_text
|
||||
display_text = async_delegation_display_text(evt) if is_delegation else text
|
||||
from tools.process_registry_notifications import async_delegation_display_text, process_completion_display_text
|
||||
display_text = (async_delegation_display_text(evt) if is_delegation
|
||||
else process_completion_display_text([evt]) if evt_type == "completion" else text)
|
||||
_emit("status.update", sid, {"kind": "process", "text": display_text})
|
||||
emitted.add(dedup_key)
|
||||
if evt_type == "completion" and completions is not None:
|
||||
@@ -449,7 +450,7 @@ def _notif_handle_event(sid, session, evt, emitted, registry, fmt, deferred, com
|
||||
|
||||
|
||||
def _notif_dispatch_completions(sid, session, notifications, registry, deferred):
|
||||
from tools.process_registry_notifications import ProcessNotificationBatch
|
||||
from tools.process_registry_notifications import PROCESS_COMPLETE_DISPLAY_KIND, ProcessNotificationBatch
|
||||
from tools.async_delegation import claim_event_delivery, complete_event_delivery, release_event_delivery
|
||||
|
||||
if not notifications:
|
||||
@@ -462,13 +463,15 @@ def _notif_dispatch_completions(sid, session, notifications, registry, deferred)
|
||||
return
|
||||
claimed = [(event, text, claim) for event, text in notifications
|
||||
if (claim := claim_event_delivery(event, "tui-completion-batch")) is not None]
|
||||
text = ProcessNotificationBatch(tuple((event, text) for event, text, _claim in claimed)).render(registry)
|
||||
batch = ProcessNotificationBatch(tuple((event, text) for event, text, _claim in claimed))
|
||||
text = batch.render(registry)
|
||||
if text is None:
|
||||
_notif_release_turn(session)
|
||||
try:
|
||||
if text is not None:
|
||||
_notif_submit(f"__notif__{int(time.time() * 1000)}", sid, session, text,
|
||||
"completion batch dispatch failed")
|
||||
"completion batch dispatch failed", display_kind=PROCESS_COMPLETE_DISPLAY_KIND,
|
||||
display_metadata={"display_text": batch.display_text(registry)})
|
||||
except Exception:
|
||||
for event, _text, claim in claimed:
|
||||
release_event_delivery(event, claim)
|
||||
|
||||
@@ -71,14 +71,16 @@ export const toTranscriptMessages = (rows: unknown): Msg[] => {
|
||||
continue
|
||||
}
|
||||
|
||||
if (display_kind === 'async_delegation_complete') {
|
||||
if (display_kind === 'async_delegation_complete' || display_kind === 'process_complete') {
|
||||
const meta = (row as TranscriptRow).display_metadata
|
||||
const count = meta && typeof meta.task_count === 'number' ? meta.task_count : undefined
|
||||
|
||||
const label =
|
||||
count === undefined
|
||||
? 'background agent work finished'
|
||||
: `${count} background agent${count === 1 ? '' : 's'} finished`
|
||||
display_kind === 'process_complete'
|
||||
? 'background process finished'
|
||||
: count === undefined
|
||||
? 'background agent work finished'
|
||||
: `${count} background agent${count === 1 ? '' : 's'} finished`
|
||||
|
||||
out.push({
|
||||
kind: 'event',
|
||||
|
||||
Reference in New Issue
Block a user