Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions src/subagent/fleet-dry-drive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
36 changes: 36 additions & 0 deletions src/subagent/mailbox-mail-drive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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: <sender>]\n\n<content>` (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<unknown> {
return typeof value === "object" && value !== null && "then" in value;
}
Expand Down
115 changes: 115 additions & 0 deletions src/tui/history-hydrate.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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([]);
});
});
24 changes: 22 additions & 2 deletions src/tui/history-hydrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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. */
Expand All @@ -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;
Expand All @@ -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;
}

Expand Down Expand Up @@ -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 ?? "" };
Expand Down
10 changes: 7 additions & 3 deletions src/tui/runtime-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down
47 changes: 46 additions & 1 deletion src/tui/stream-event-map.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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" },
Expand Down
Loading
Loading