diff --git a/docs/TUI.md b/docs/TUI.md index ef65e7077..f40e94573 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -666,7 +666,9 @@ sends at once (the parent it was steering has stopped), and the last lane terminalizing releases the hold, drains follow-ups, and returns the session to idle — unless todo/doing tasks remain, in which case a system continuation starts before the fleet-0 event so the run stays busy and -follow-ups wait one more turn. +follow-ups wait one more turn. The mail and that fleet-dry continuation +are runtime-to-agent traffic — the fleet board owns worker status — so +neither paints a transcript row, and neither rehydrates as one. Interrupting (Ctrl+C) never discards a queued or steered message. It used to — the transcript literally said `interrupt — discarded N pending`, and an diff --git a/src/subagent/fleet-dry-drive.ts b/src/subagent/fleet-dry-drive.ts index d7bba4c3b..1df7a07f6 100644 --- a/src/subagent/fleet-dry-drive.ts +++ b/src/subagent/fleet-dry-drive.ts @@ -17,6 +17,15 @@ export const FLEET_DRY_REPORT_CHARS = 8_192; export const FLEET_DRY_CONTINUATION_PREFIX = "The fleet has gone dry. Remaining open tasks:"; +/** + * Whether inbound text is the fleet-dry open-task continuation. Same class + * as mailbox mail: internal runtime→agent traffic whose report-JSON payload + * is model-facing, so the transcript never paints it. + */ +export function isFleetDryContinuationText(text: string): boolean { + return text.startsWith(FLEET_DRY_CONTINUATION_PREFIX); +} + export interface FleetDryMailboxRecord { readonly status: WaitJSONStatus; readonly collected?: boolean; diff --git a/src/subagent/mailbox-mail-drive.ts b/src/subagent/mailbox-mail-drive.ts index 95f0722c6..87cf8d1a7 100644 --- a/src/subagent/mailbox-mail-drive.ts +++ b/src/subagent/mailbox-mail-drive.ts @@ -12,6 +12,7 @@ import { type FleetDryBlobWriter, type FleetDryLane, type FleetDryMailbox, + isFleetDryContinuationText, } from "./fleet-dry-drive.js"; export const MAILBOX_MAIL_WAKE_PREFIX = "mailbox mail"; @@ -25,6 +26,41 @@ export function mailboxMailWakeLine(): string { return `${MAILBOX_MAIL_WAKE_PREFIX} — occupancy delivered these worker reports (do not call wait_agents for these agent_ids):`; } +/** + * Whether inbound text is occupancy's mailbox mail. Internal runtime→agent + * traffic — the fleet board already owns worker status and the payload is + * model-facing report JSON, so the transcript never paints it. The live event + * map recognises it by content; history hydration keys on the persisted + * origin marker instead (see isPersistedOccupancyWakeText). + */ +export function isMailboxMailText(text: string): boolean { + return text.startsWith(mailboxMailWakeLine()); +} + +/** + * Reactor envelope wrapping persisted inbound text: createInboundTurn stores + * user-role turns as `[From: ]\n\n` (plus an optional + * `[Subject: ...]` line), so a resumed wake never starts with its prompt + * line. The resume path must see through it; the live event map matches raw + * message content and keeps the bare matchers above. + */ +const INBOUND_ENVELOPE_PREFIX = /^(\[[^\]\n]*\]\n)+\n/; + +function withoutInboundEnvelope(text: string): string { + return text.replace(INBOUND_ENVELOPE_PREFIX, ""); +} + +/** + * Whether persisted text is an occupancy wake (mailbox mail or fleet-dry + * continuation), tolerating the reactor envelope above. Resume-path only: + * persisted turns carry no message flags, so turns-to-blocks marks wakes by + * this shape and history-hydrate keys its drop on that marker. + */ +export function isPersistedOccupancyWakeText(text: string): boolean { + const bare = withoutInboundEnvelope(text); + return isMailboxMailText(bare) || isFleetDryContinuationText(bare); +} + function isPromiseLike(value: unknown): value is Promise { return typeof value === "object" && value !== null && "then" in value; } diff --git a/src/tui/history-hydrate.test.ts b/src/tui/history-hydrate.test.ts index 2990e34c6..3c4e4d646 100644 --- a/src/tui/history-hydrate.test.ts +++ b/src/tui/history-hydrate.test.ts @@ -1,4 +1,7 @@ import { describe, expect, test } from "bun:test"; +import type { ConversationTurn } from "@intx/types/runtime"; +import { buildFleetDryContinuationPrompt } from "../subagent/fleet-dry-drive.js"; +import { buildMailboxMailPrompt } from "../subagent/mailbox-mail-drive.js"; import { EMPTY_PLAN_DETAIL, EMPTY_VIEW_DETAIL, @@ -8,6 +11,16 @@ import { rowsFromHistoryBlocks, type HistoryBlock, } from "./history-hydrate.js"; +import { turnsToContentBlocks } from "./turns-to-blocks.js"; + +/** A persisted user turn: the reactor envelopes inbound text (createInboundTurn). */ +function envelopedUserTurn(text: string): ConversationTurn { + return { + role: "user", + content: [{ type: "text", text: `[From: user@local]\n\n${text}` }], + timestamp: 0, + } as unknown as ConversationTurn; +} describe("rowFromHistoryBlock", () => { test("user / text / reply / thinking", () => { @@ -165,6 +178,68 @@ describe("rowFromHistoryBlock", () => { }); }); + test("persisted occupancy wakes drop only with their origin marker", () => { + // turns-to-blocks marks wake-matching user turns origin:"system" at + // persist time; the drop below is keyed on that marker, never the prefix + // alone. Persisted turns carry the reactor envelope, so the fixtures use + // the enveloped shape real sessions carry. + const mail = `[From: user@local]\n\n${buildMailboxMailPrompt([ + { agent_id: "w1", status: "done", report: "audit clean" }, + ])}`; + expect( + rowFromHistoryBlock({ type: "user", content: mail, origin: "system" }), + ).toBeNull(); + + const dry = `[From: user@local]\n\n${buildFleetDryContinuationPrompt( + [{ id: "t1", title: "ship it", status: "todo" }], + [], + )}`; + expect( + rowFromHistoryBlock({ type: "user", content: dry, origin: "system" }), + ).toBeNull(); + + // At this layer alone, the same bytes without the system marker paint: + // the drop is keyed on the marker, never the prefix. That is a + // layer-local guarantee, not the pipeline outcome — turns-to-blocks + // marks any verbatim wake shape (operator-typed or not) before it + // reaches here, so an enveloped verbatim-wake operator turn drops end + // to end (locked by the pipeline test below). Only a bare prefix, which + // the marker never matches, is guaranteed to paint on resume. + expect(rowFromHistoryBlock({ type: "user", content: mail })).toEqual({ + role: "user", + text: mail, + }); + + // Explicitly operator-flagged text paints at this layer, even verbatim + // wake text — again layer-local, not the pipeline outcome (see above). + expect( + rowFromHistoryBlock({ + type: "user", + content: mail, + origin: "operator", + }), + ).toEqual({ role: "user", text: mail }); + + // Ordinary operator text is untouched. + expect(rowFromHistoryBlock({ type: "user", content: "hi" })).toEqual({ + role: "user", + text: "hi", + }); + }); + + test("non-wake system inbound still paints on resume", () => { + // A system-originated inbound that is not an occupancy wake — shaped like + // a background-shell completion notice (mailbox "system", no operator + // flag) — paints live and must survive resume too. Only wakes carry the + // origin marker, so this arrives unmarked and must paint. + const notice = + "[From: user@local]\n\nBackground shell abc123 finished: exit code 0.\ncommand: bun test\noutput:\n3 pass"; + expect(rowFromHistoryBlock({ type: "user", content: notice })).toEqual({ + role: "user", + text: notice, + }); + }); + test("a tasks block no longer hydrates a row at all", () => { // Task state is live panel state, not conversation history. Nothing writes // this block any more, and an old session carrying one must not paint a @@ -312,3 +387,43 @@ describe("hydrateHistoryRows", () => { ]); }); }); + +describe("resume pipeline end to end (turns-to-blocks into hydrate)", () => { + test("a marked wake drops while operator text paints", () => { + // Mirrors runner wiring: persisted turns become content blocks, which + // cross history.hydrate as untyped JSON — so the origin marker must + // survive asHistoryBlock for the drop to fire, and unmarked text must + // paint even though the wake prefix is in the same payload. + const wake = buildMailboxMailPrompt([ + { agent_id: "w1", status: "done", report: "audit clean" }, + ]); + const operatorText = "[From: user@local]\n\nship it"; + const blocks = turnsToContentBlocks([ + envelopedUserTurn(wake), + envelopedUserTurn("ship it"), + ]); + expect(blocks).toMatchObject([ + { type: "user", origin: "system" }, + { type: "user" }, + ]); + const rows = hydrateHistoryRows(JSON.parse(JSON.stringify(blocks))); + expect(rows).toEqual([{ role: "user", text: operatorText }]); + }); + + test("an enveloped verbatim-wake operator turn drops end to end", () => { + // Residual provenance gap: persisted turns carry no message flags, so + // turns-to-blocks marks wakes by content and an operator turn carrying + // a byte-verbatim wake (full wake line plus report JSON — a deliberate + // paste) is marked origin:"system" and dropped here. Trigger is narrow + // and the consequence cosmetic (one scrollback row; model history + // intact), but the drop is real: this test fails if anyone claims + // verbatim operator text always paints. + const wake = buildMailboxMailPrompt([ + { agent_id: "w1", status: "done", report: "audit clean" }, + ]); + const blocks = turnsToContentBlocks([envelopedUserTurn(wake)]); + expect(blocks).toMatchObject([{ type: "user", origin: "system" }]); + const rows = hydrateHistoryRows(JSON.parse(JSON.stringify(blocks))); + expect(rows).toEqual([]); + }); +}); diff --git a/src/tui/history-hydrate.ts b/src/tui/history-hydrate.ts index 787486fab..ea660e030 100644 --- a/src/tui/history-hydrate.ts +++ b/src/tui/history-hydrate.ts @@ -11,6 +11,7 @@ import { toolResultRow } from "./mcp-view.js"; import type { StreamRow } from "./stream.js"; import { TOOL_DETAIL_WIDTH } from "./tool-args.js"; import { pushToolCall, pushToolResult } from "./tool-rows.js"; +import { isPersistedOccupancyWakeText } from "../subagent/mailbox-mail-drive.js"; /** * Loose content-block shape from `history.hydrate` / turns-to-blocks. @@ -35,6 +36,14 @@ export interface HistoryBlock { readonly node?: unknown; /** plan block payload. */ readonly steps?: unknown; + /** + * Persisted origin for user blocks (turns-to-blocks): "system" marks an + * occupancy wake, the only user-type block the resume path ever drops. + * Anything else paints at this layer — but the mark itself derives from + * content, so a verbatim wake-shaped operator turn arrives marked and + * drops here (deliberate-paste-only trigger; one scrollback row). + */ + readonly origin?: string; } /** Body for a resumed error the transcript recorded without its message. */ @@ -59,6 +68,7 @@ function asHistoryBlock(raw: unknown): HistoryBlock | null { callId?: string; node?: unknown; steps?: unknown; + origin?: string; } = { type: o.type }; if (typeof o.content === "string") out.content = o.content; if (typeof o.name === "string") out.name = o.name; @@ -68,6 +78,7 @@ function asHistoryBlock(raw: unknown): HistoryBlock | null { if (typeof o.callId === "string") out.callId = o.callId; if (o.node !== undefined) out.node = o.node; if (o.steps !== undefined) out.steps = o.steps; + if (typeof o.origin === "string") out.origin = o.origin; return out as HistoryBlock; } @@ -119,8 +130,17 @@ function planText(steps: unknown): string { */ export function rowFromHistoryBlock(block: HistoryBlock): StreamRow | null { switch (block.type) { - case "user": - return { role: "user", text: block.content ?? "" }; + case "user": { + const content = block.content ?? ""; + // Origin-keyed suppression: only a block marked as a system wake + // drops. Unmarked or operator-marked wake-shaped text paints at this + // layer — but the pipeline marks by content, so a verbatim + // wake-shaped operator turn never arrives here unmarked. + if (block.origin === "system" && isPersistedOccupancyWakeText(content)) { + return null; + } + return { role: "user", text: content }; + } case "text": case "reply": return { role: "assistant", text: block.content ?? "" }; diff --git a/src/tui/runtime-bridge.test.ts b/src/tui/runtime-bridge.test.ts index f10dba6ca..65f79836b 100644 --- a/src/tui/runtime-bridge.test.ts +++ b/src/tui/runtime-bridge.test.ts @@ -1601,7 +1601,7 @@ describe("fleet-dry open-task drive (CL-7540)", () => { bridge.handle({ type: "inference.done", data: {} }); } - test("dry+open: fleet-0 settle drives once, keeps the run busy, and paints the prompt as system", async () => { + test("dry+open: fleet-0 settle drives once and keeps the run busy without painting the prompt", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -1639,11 +1639,13 @@ describe("fleet-dry open-task drive (CL-7540)", () => { expect(shell.streamLog.filter((r) => r.role === "user").length).toBe( userRowsBefore, ); + // The continuation is runtime→agent traffic — the fleet board owns + // worker status, so its prompt paints no transcript row. expect( shell.streamLog.filter( (r) => r.role === "system" && r.text === prompt, ), - ).toHaveLength(1); + ).toHaveLength(0); settleToollessTurn(bridge); expect(drives).toBe(1); } finally { @@ -2060,11 +2062,13 @@ describe("fleet-dry open-task drive (CL-7540)", () => { expect(shell.streamLog.filter((r) => r.role === "user").length).toBe( userRowsAfterSubmit, ); + // Fleet-dry continuations are internal runtime→agent traffic and + // paint no row; the abort must not have swallowed the inbound. expect( shell.streamLog.filter( (r) => r.role === "system" && r.text === occupancy, ), - ).toHaveLength(1); + ).toHaveLength(0); } finally { bridge.dispose(); shell.dispose(); diff --git a/src/tui/stream-event-map.test.ts b/src/tui/stream-event-map.test.ts index c244644d9..d5750c284 100644 --- a/src/tui/stream-event-map.test.ts +++ b/src/tui/stream-event-map.test.ts @@ -1,6 +1,12 @@ import { describe, expect, test } from "bun:test"; import { OPERATOR_ORIGINATED_FLAG } from "../agent/message-provenance.js"; -import { buildShellBackgroundMessage } from "../session/runtime-assembly.js"; +import { + buildFleetDryContinuationMessage, + buildMailboxMailMessage, + buildShellBackgroundMessage, +} from "../session/runtime-assembly.js"; +import { buildFleetDryContinuationPrompt } from "../subagent/fleet-dry-drive.js"; +import { buildMailboxMailPrompt } from "../subagent/mailbox-mail-drive.js"; import { suppressProviderFailurePresentation } from "./provider/failure-attempt.js"; import { createStreamMapContext, @@ -49,6 +55,45 @@ describe("mapProductionEvent", () => { ).toEqual([{ type: "system", text: message.content ?? "" }]); }); + test("mailbox mail wake paints no transcript row", () => { + const prompt = buildMailboxMailPrompt([ + { agent_id: "w1", status: "done", report: "audit clean" }, + ]); + expect( + mapProductionEvent({ + type: "message.received", + data: { message: buildMailboxMailMessage(prompt) }, + }), + ).toEqual([]); + }); + + test("fleet-dry continuation paints no transcript row", () => { + const prompt = buildFleetDryContinuationPrompt( + [{ id: "t1", title: "ship it", status: "todo" }], + [{ agent_id: "w1", status: "done" }], + ); + expect( + mapProductionEvent({ + type: "message.received", + data: { message: buildFleetDryContinuationMessage(prompt) }, + }), + ).toEqual([]); + }); + + test("operator-originated text matching a wake prefix still paints", () => { + const content = buildMailboxMailPrompt([ + { agent_id: "w1", status: "done" }, + ]); + expect( + mapProductionEvent({ + type: "message.received", + data: { + message: { content, flags: [OPERATOR_ORIGINATED_FLAG] }, + }, + }), + ).toEqual([{ type: "user", text: content }]); + }); + test("inference.start → busy run", () => { expect(mapProductionEvent({ type: "inference.start" })).toEqual([ { type: "attempt", action: "mark" }, diff --git a/src/tui/stream-event-map.ts b/src/tui/stream-event-map.ts index 40a2fb02e..0b11974bc 100644 --- a/src/tui/stream-event-map.ts +++ b/src/tui/stream-event-map.ts @@ -10,6 +10,8 @@ import { stripTerminalControlSequences, } from "../util/control-char-strip.js"; import { isOperatorOriginated } from "../agent/message-provenance.js"; +import { isFleetDryContinuationText } from "../subagent/fleet-dry-drive.js"; +import { isMailboxMailText } from "../subagent/mailbox-mail-drive.js"; import { isReactorErrorFatal } from "../agent/reactor-events.js"; import { terminalProviderFailureMessage } from "../inference-error-message.js"; import { @@ -368,15 +370,17 @@ function mapEvent( // retry retracting across it would erase operator or system text. const disarmed = disarmAttempt(ctx); if (full.trim().length === 0) return disarmed; - return [ - ...disarmed, - { - type: isOperatorOriginated(inboundMessageFlags(message)) - ? "user" - : "system", - text: full, - }, - ]; + const operator = isOperatorOriginated(inboundMessageFlags(message)); + // Occupancy wakes (mailbox mail, fleet-dry continuation) are + // runtime→agent traffic: the fleet board already owns worker status, + // and the payload is model-facing report JSON — no transcript row. + if ( + !operator && + (isMailboxMailText(content) || isFleetDryContinuationText(content)) + ) { + return disarmed; + } + return [...disarmed, { type: operator ? "user" : "system", text: full }]; } case "inference.start": { diff --git a/src/tui/turns-to-blocks.test.ts b/src/tui/turns-to-blocks.test.ts index 92b419cdd..bbf240e5e 100644 --- a/src/tui/turns-to-blocks.test.ts +++ b/src/tui/turns-to-blocks.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect } from "bun:test"; import type { ConversationTurn } from "@intx/types/runtime"; +import { buildMailboxMailPrompt } from "../subagent/mailbox-mail-drive.js"; import { turnsToContentBlocks } from "./turns-to-blocks.js"; import { hydrateTasksFromTurns } from "../agent/director.js"; @@ -78,6 +79,42 @@ describe("turnsToContentBlocks no longer derives tasks", () => { }); }); +describe("turnsToContentBlocks marks occupancy wakes with system origin", () => { + function userTurn(text: string): ConversationTurn { + return { + role: "user", + content: [{ type: "text", text }], + timestamp: 0, + } as unknown as ConversationTurn; + } + + test("enveloped wake turn is marked; operator text stays unmarked", () => { + // Persisted turns carry the reactor envelope (createInboundTurn), so the + // wake match must see through "[From: ...]\n\n" to the prompt beneath. + const wake = buildMailboxMailPrompt([ + { agent_id: "w1", status: "done", report: "audit clean" }, + ]); + const blocks = turnsToContentBlocks([ + userTurn(`[From: user@local]\n\n${wake}`), + userTurn("[From: user@local]\n\nship it"), + ]); + expect(blocks).toMatchObject([ + { type: "user", origin: "system" }, + { type: "user" }, + ]); + expect("origin" in (blocks[1] as object)).toBe(false); + }); + + test("operator text starting with a wake line is not marked", () => { + // Only the full wake shape earns the system marker — a bare prefix + // typed by the operator stays unmarked, and unmarked blocks always + // paint on resume (see history-hydrate). + const blocks = turnsToContentBlocks([userTurn("mailbox mail")]); + expect(blocks).toMatchObject([{ type: "user" }]); + expect("origin" in (blocks[0] as object)).toBe(false); + }); +}); + describe("hydrateTasksFromTurns", () => { test("derives the task list from manage_tasks tool calls in a transcript", () => { const turns = [manageTasksTurn("m1", "doing"), toolResultTurn("m1", false)]; diff --git a/src/tui/turns-to-blocks.ts b/src/tui/turns-to-blocks.ts index 1ff0e9789..65e10409c 100644 --- a/src/tui/turns-to-blocks.ts +++ b/src/tui/turns-to-blocks.ts @@ -3,6 +3,7 @@ import type { ConversationTurn, } from "@intx/types/runtime"; +import { isPersistedOccupancyWakeText } from "../subagent/mailbox-mail-drive.js"; import { validateView, type ViewNode } from "./view/index.js"; interface PlanBlockStep { @@ -12,7 +13,19 @@ interface PlanBlockStep { } export type ContentBlockData = - | { type: "user"; content: string } + | { + type: "user"; + content: string; + /** + * Persisted origin for resume suppression: turns carry no message + * flags, so wake-shaped user turns are marked "system" here and + * history-hydrate drops only marked blocks. The mark derives from + * content, not provenance — a verbatim wake-shaped operator turn is + * marked (and dropped) exactly like a real wake. Only a bare wake + * prefix, or non-wake text, stays unmarked and paints. + */ + origin?: "operator" | "system"; + } | { type: "thinking"; content: string } | { type: "text"; content: string } | { @@ -205,7 +218,18 @@ function turnToContentBlocks(turn: ConversationTurn): ContentBlockData[] { const out: ContentBlockData[] = []; if (turn.role === "user") { const text = textFromBlocks(turn.content); - if (text.length > 0) out.push({ type: "user", content: text }); + if (text.length > 0) { + // Occupancy wakes persist as user-role turns with no flags. Mark the + // wake shape here so history-hydrate can drop it by origin. The match + // is content, not provenance: an operator turn carrying a byte-verbatim + // wake (deliberate paste of the full wake line plus report JSON) is + // marked — and dropped — too. A bare wake prefix stays unmarked. + if (isPersistedOccupancyWakeText(text)) { + out.push({ type: "user", content: text, origin: "system" }); + } else { + out.push({ type: "user", content: text }); + } + } return out; } if (turn.role !== "assistant") return out;