Show a peer's message in the receiver's block and offer Stop on a tool-only direct turn

Owner 5=A asked for a task-authored message to be visible where it lands:
the receiving task's timeline. The event reached events.jsonl and the Logs
projection, but the chat reducer rendered it invisible and the supervisor
never pushed it live. The row now carries a bounded preview, is pushed to
the live log stream, and renders as a visible compact row in the receiver's
block.

A direct turn that only called tools offered no Stop until the model wrote
a narration row, because the host-attested marker rode progress frames only.
The turn's own queue proxy now stamps the marker on its tool frames (the
same by-value rule that carries the lane fact), and the client grants Stop
from any stamped log frame; a turn that neither narrates nor calls tools
keeps no block to hang a Stop on. The lane key is declared in ChatOutbound.

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
This commit is contained in:
Ouroboros 2026-09-15 23:30:00 +03:00
parent 2722b7b13b
commit f4ad64b928
11 changed files with 97 additions and 16 deletions

View file

@ -199,6 +199,11 @@ class ChatOutbound(TypedDict):
# endpoint uses, supervisor.workers.direct_chat_turn); never a subagent
# frame, never an ephemeral decision turn. Gates the UI "Cancel run" action.
cancelable: NotRequired[bool]
# The lane fact of a direct conversation turn, stamped by value on the
# turn's own frames (supervisor/log_addressing.py TurnEventQueue) so the
# chat block reads it before any census: present and true only for a
# direct turn's progress/tool frames and on every task_done.
_is_direct_chat: NotRequired[bool]
# Monetary projections are nullable when the physical-attempt ledger cannot
# be read. ``None`` is deliberately distinct from a confirmed $0 result.
# C2 (owner 10=B) named these the HONEST names — accounted upper bounds,
@ -640,10 +645,11 @@ class FsDirsResponse(TypedDict):
class TaskNamedOutbound(TypedDict):
"""Outbound notice that the proactive card namer coined a project name for a fresh
main-chat task (v6.40). The client sets the live card's title to ``suggested_name``;
turn-into-project later reuses the same name. Not chat-scoped — carries only
``task_id`` and is a no-op unless a thread already holds that card."""
"""Outbound notice that a project name was coined for a task's card (the inline
naming of a turn-into-project conversion; direct turns are not named in the
background). The client sets the live card's title to ``suggested_name``. Not
chat-scoped — carries only ``task_id`` and is a no-op unless a thread already
holds that card."""
type: Literal["task_named"]
task_id: str

View file

@ -357,6 +357,9 @@ def deliver_task_message(
"type": "task_message_injected", "task_id": task_id,
"source_task_id": source, "provenance": provenance,
"relayed_from_task_id": relayed,
# A bounded preview for the receiver's visible timeline row
# (owner 5=A); the full text is in the receiver's transcript.
"text_preview": str(entry.get("text") or "")[:200],
})
except Exception:
pass

View file

@ -186,7 +186,8 @@ def _handle_send_message(evt: Dict[str, Any], ctx: Any) -> None:
if is_progress and evt.get("_is_direct_chat") is True:
# Stamped by value on the turn's own queue (TurnEventQueue); the live
# frame is built from progress_meta, so the fact rides along.
progress_meta = {**(progress_meta or {}), "_is_direct_chat": True}
progress_meta = dict(progress_meta or {})
progress_meta["_is_direct_chat"] = True
_running = getattr(ctx, "RUNNING", None)
task_row: Dict[str, Any] = {}
if task_id and isinstance(_running, dict):

View file

@ -202,6 +202,13 @@ class TurnEventQueue:
# the chat block never wears managed chrome (a Task title, a
# conversion control) in the window before the census lists it.
data.setdefault("_is_direct_chat", True)
# The host-attested Stop marker rides the turn's WORK frames as
# it rides its narration rows (events_chat_delivery stamps those
# through the same registry): a turn that only calls tools
# offers Stop on the block its rows already justify, and a turn
# that does neither keeps no block to hang a Stop on.
if data.get("type") in ("tool_call_started", "tool_call_finished"):
data.setdefault("cancelable", True)
return item
def put(self, item: Any, *args: Any, **kwargs: Any) -> Any:

View file

@ -41,18 +41,28 @@ def _handle_typed_telemetry(evt: Dict[str, Any], ctx: Any) -> None:
def _handle_task_message_injected(evt: Dict[str, Any], ctx: Any) -> None:
"""Log A2A task-message injections so health checks can detect duplicate
processing (sibling of owner_message_injected for the task channel)."""
processing (sibling of owner_message_injected for the task channel), and
push the same row live so the receiving task's chat block shows the
message as a timeline row (owner 5=A: visible where it lands)."""
payload = {
"ts": evt.get("ts", utc_now_iso()),
"type": "task_message_injected",
"task_id": evt.get("task_id", ""),
"source_task_id": evt.get("source_task_id", ""),
"provenance": evt.get("provenance", ""),
"relayed_from_task_id": evt.get("relayed_from_task_id", ""),
"text_preview": str(evt.get("text_preview") or "")[:200],
}
try:
ctx.append_jsonl(ctx.DRIVE_ROOT / "logs" / "events.jsonl", {
"ts": evt.get("ts", utc_now_iso()),
"type": "task_message_injected",
"task_id": evt.get("task_id", ""),
"source_task_id": evt.get("source_task_id", ""),
"provenance": evt.get("provenance", ""),
"relayed_from_task_id": evt.get("relayed_from_task_id", ""),
})
ctx.append_jsonl(ctx.DRIVE_ROOT / "logs" / "events.jsonl", payload)
except Exception:
log.warning("Failed to log task_message_injected event", exc_info=True)
try:
from supervisor.log_addressing import address_ctx_event
ctx.bridge.push_log(address_ctx_event(ctx, dict(payload)))
except Exception:
log.debug("Failed to forward task_message_injected to live logs", exc_info=True)
# Merged into supervisor.events.EVENT_HANDLERS (the `**_CEH` pattern). The

View file

@ -467,7 +467,10 @@ def test_turn_event_queue_stamps_by_value_at_the_producer():
# The lane fact rides the same events by the same rule (a stamped
# task_done keeps its own value; other tasks' events are untouched).
assert captured[-1]["data"]["_is_direct_chat"] is True
assert "cancelable" not in captured[-1]["data"] # not a work frame
assert proxy.stamp({"type": "task_done", "task_id": "turn1", "_is_direct_chat": False})["_is_direct_chat"] is False
tool = proxy.stamp({"type": "log_event", "data": {"type": "tool_call_started", "task_id": "turn1", "tool": "read_file"}})
assert tool["data"]["cancelable"] is True and tool["data"]["_is_direct_chat"] is True
# Another task's event and an already-addressed event are left alone.
other = {"type": "log_event", "data": {"type": "x", "task_id": "other"}}

View file

@ -329,6 +329,10 @@
* @property {string=} task_group_id
* @property {string=} task_event
* @property {string=} status
* @property {boolean=} _is_direct_chat
* The lane fact of a direct conversation turn, stamped by the host on the
* turn's own progress/tool frames and on every task_done; the chat block
* reads it before any census lists the turn.
* @property {boolean=} cancelable
* v6.82 (P5): host-attested — this frame's task is a supervisor-queue task that
* POST /api/tasks/{id}/cancel can force-cancel: a lineage-resolved pooled root or

View file

@ -2120,8 +2120,11 @@ export function createChatInstance({
if (childInfo) return Boolean(changed || queued);
const subagentChanged = updateSubagentCardFromEvent(evt, rawTs);
// The host stamps the lane on the turn's own frames (task_done always,
// a direct turn's tool frames too), so chrome never waits for a census.
// a direct turn's tool frames too), so chrome never waits for a census;
// the host-attested Stop marker rides a direct turn's tool frames the
// way it rides its narration rows, so a tool-only turn offers Stop.
if (typeof evt._is_direct_chat === 'boolean') noteDirectTurn(liveCardRecords.get(taskId), evt._is_direct_chat);
if (evt.cancelable === true) markTaskCancelable(taskId);
if (eventType === 'task_done' && summary.terminal) {
recordTerminalActivity(taskId);
syncChatStatus();

View file

@ -791,6 +791,7 @@ export function summarizeLogEvent(evt) {
// independent task / system / escalation).
const source = evt.source_task_id ? String(evt.source_task_id) : 'another task';
return view('info', `Message from task ${source}`, {
body: shortText(evt.text_preview, 200),
meta: taskMeta(
evt.provenance ? `provenance=${evt.provenance}` : '',
evt.relayed_from_task_id ? `relayed=${evt.relayed_from_task_id}` : '',
@ -1246,6 +1247,21 @@ export function summarizeChatLiveEvent(evt) {
return chatView({ phase: 'thinking', headline: 'Thinking', dedupeKey: key(evt.round || '', evt.attempt || '') });
}
if (t === 'task_message_injected') {
// A message from another task landed in this task's transcript: a
// visible row in the receiver's block (owner 5=A), named by value.
const source = evt.source_task_id ? String(evt.source_task_id) : 'another task';
const preview = String(evt.text_preview || '');
return chatView({
phase: 'info',
headline: `Message from task ${source}`,
body: shortText(preview, 200),
fullBody: preview,
visible: true,
dedupeKey: key(source, evt.ts || ''),
});
}
if (t === 'tool_call_started' || (t === 'tool_call_finished' && !evt.is_error)) {
// A successful call is a compact one-line row: `tool · target`, then
// `✓ duration` when it finishes — content the block can stand on,

View file

@ -122,6 +122,22 @@ test('a tool frame stamped with the lane fact mints a direct block before any ce
} finally { f.close(); }
});
test('a tool-only direct turn offers Stop from its stamped tool frame, without a narration row', () => {
const f = fixture();
try {
f.log({ type: 'llm_round_started', model: 'm', round: 1, _is_direct_chat: true });
assert.equal(f.card(), null, 'a round frame is not content and carries no marker');
f.log({ type: 'tool_call_started', tool: 'read_file', tool_call_id: 'c1', args: { path: 'README.md' }, _is_direct_chat: true, cancelable: true });
assert.ok(f.card(), 'the tool row mints the block');
assert.ok(f.card().querySelector('[data-cancel-run]'), 'the host marker on the work frame offers Stop');
assert.equal(f.card().dataset.direct, '1');
f.census(direct());
assert.ok(f.card().querySelector('[data-cancel-run]'));
f.log({ ...final, type: 'task_done', status: 'completed', _is_direct_chat: true });
assert.equal(f.card()?.querySelector('[data-cancel-run]') ?? null, null, 'no Stop on a finished turn');
} finally { f.close(); }
});
test('a direct turn with two successful tools shows two compact rows live and the summary row after a reload', async () => {
const f = fixture();
try {

View file

@ -5,7 +5,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { categorizeLogEvent, getLogTaskGroupId, isGroupedTaskEvent, summarizeLogEvent } from '../modules/log_events.js';
import { categorizeLogEvent, getLogTaskGroupId, isGroupedTaskEvent, summarizeChatLiveEvent, summarizeLogEvent } from '../modules/log_events.js';
test('a task message landing in a task names its sender in that task timeline', () => {
const evt = { type: 'task_message_injected', task_id: 'receiver-1', source_task_id: 'sender-9', provenance: 'independent_task' };
@ -34,3 +34,15 @@ test('a refused task-authored message carries the host reason and warns', () =>
assert.equal(view.body, 'target_finished');
assert.equal(categorizeLogEvent(evt, view), 'tasks');
});
test('a task message landing in a task is a visible row in the receiver\'s chat block, with its preview', () => {
const evt = { type: 'task_message_injected', task_id: 'receiver-1', source_task_id: 'sender-9',
provenance: 'independent_task', text_preview: 'the PR is ready; please review it', ts: '2026-09-15T12:00:00Z' };
const view = summarizeChatLiveEvent(evt);
assert.equal(view.visible, true);
assert.equal(view.headline, 'Message from task sender-9');
assert.equal(view.body, 'the PR is ready; please review it');
assert.ok(summarizeLogEvent(evt).body.includes('the PR is ready'));
});