Skip to content

Commit 94d7cc4

Browse files
Keep internal agent traffic out of transcript (#940)
* Keep internal agent traffic out of transcript * Key resume wake suppression on persisted origin, not content prefix Persisted turns carry no message flags, so the resume path dropped any user block starting with a wake line - including verbatim operator text. turns-to-blocks now marks wake-shaped user turns origin system at persist time, and rowFromHistoryBlock drops only marked blocks. Bare-prefix operator text paints, but an enveloped verbatim-wake operator turn (deliberate paste of the full wake line plus report JSON) still drops on resume: the mark derives from the same content predicate it gates. Narrow trigger, cosmetic consequence (one scrollback row; model history intact). The live path is untouched.
1 parent 9334eed commit 94d7cc4

10 files changed

Lines changed: 314 additions & 18 deletions

docs/TUI.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -666,7 +666,9 @@ sends at once (the parent it was steering has stopped), and the last lane
666666
terminalizing releases the hold, drains follow-ups, and returns the session
667667
to idle — unless todo/doing tasks remain, in which case a system
668668
continuation starts before the fleet-0 event so the run stays busy and
669-
follow-ups wait one more turn.
669+
follow-ups wait one more turn. The mail and that fleet-dry continuation
670+
are runtime-to-agent traffic — the fleet board owns worker status — so
671+
neither paints a transcript row, and neither rehydrates as one.
670672

671673
Interrupting (Ctrl+C) never discards a queued or steered message. It used to
672674
— the transcript literally said `interrupt — discarded N pending`, and an

src/subagent/fleet-dry-drive.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@ export const FLEET_DRY_REPORT_CHARS = 8_192;
1717
export const FLEET_DRY_CONTINUATION_PREFIX =
1818
"The fleet has gone dry. Remaining open tasks:";
1919

20+
/**
21+
* Whether inbound text is the fleet-dry open-task continuation. Same class
22+
* as mailbox mail: internal runtime→agent traffic whose report-JSON payload
23+
* is model-facing, so the transcript never paints it.
24+
*/
25+
export function isFleetDryContinuationText(text: string): boolean {
26+
return text.startsWith(FLEET_DRY_CONTINUATION_PREFIX);
27+
}
28+
2029
export interface FleetDryMailboxRecord {
2130
readonly status: WaitJSONStatus;
2231
readonly collected?: boolean;

src/subagent/mailbox-mail-drive.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
type FleetDryBlobWriter,
1313
type FleetDryLane,
1414
type FleetDryMailbox,
15+
isFleetDryContinuationText,
1516
} from "./fleet-dry-drive.js";
1617

1718
export const MAILBOX_MAIL_WAKE_PREFIX = "mailbox mail";
@@ -25,6 +26,41 @@ export function mailboxMailWakeLine(): string {
2526
return `${MAILBOX_MAIL_WAKE_PREFIX} — occupancy delivered these worker reports (do not call wait_agents for these agent_ids):`;
2627
}
2728

29+
/**
30+
* Whether inbound text is occupancy's mailbox mail. Internal runtime→agent
31+
* traffic — the fleet board already owns worker status and the payload is
32+
* model-facing report JSON, so the transcript never paints it. The live event
33+
* map recognises it by content; history hydration keys on the persisted
34+
* origin marker instead (see isPersistedOccupancyWakeText).
35+
*/
36+
export function isMailboxMailText(text: string): boolean {
37+
return text.startsWith(mailboxMailWakeLine());
38+
}
39+
40+
/**
41+
* Reactor envelope wrapping persisted inbound text: createInboundTurn stores
42+
* user-role turns as `[From: <sender>]\n\n<content>` (plus an optional
43+
* `[Subject: ...]` line), so a resumed wake never starts with its prompt
44+
* line. The resume path must see through it; the live event map matches raw
45+
* message content and keeps the bare matchers above.
46+
*/
47+
const INBOUND_ENVELOPE_PREFIX = /^(\[[^\]\n]*\]\n)+\n/;
48+
49+
function withoutInboundEnvelope(text: string): string {
50+
return text.replace(INBOUND_ENVELOPE_PREFIX, "");
51+
}
52+
53+
/**
54+
* Whether persisted text is an occupancy wake (mailbox mail or fleet-dry
55+
* continuation), tolerating the reactor envelope above. Resume-path only:
56+
* persisted turns carry no message flags, so turns-to-blocks marks wakes by
57+
* this shape and history-hydrate keys its drop on that marker.
58+
*/
59+
export function isPersistedOccupancyWakeText(text: string): boolean {
60+
const bare = withoutInboundEnvelope(text);
61+
return isMailboxMailText(bare) || isFleetDryContinuationText(bare);
62+
}
63+
2864
function isPromiseLike(value: unknown): value is Promise<unknown> {
2965
return typeof value === "object" && value !== null && "then" in value;
3066
}

src/tui/history-hydrate.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import { describe, expect, test } from "bun:test";
2+
import type { ConversationTurn } from "@intx/types/runtime";
3+
import { buildFleetDryContinuationPrompt } from "../subagent/fleet-dry-drive.js";
4+
import { buildMailboxMailPrompt } from "../subagent/mailbox-mail-drive.js";
25
import {
36
EMPTY_PLAN_DETAIL,
47
EMPTY_VIEW_DETAIL,
@@ -8,6 +11,16 @@ import {
811
rowsFromHistoryBlocks,
912
type HistoryBlock,
1013
} from "./history-hydrate.js";
14+
import { turnsToContentBlocks } from "./turns-to-blocks.js";
15+
16+
/** A persisted user turn: the reactor envelopes inbound text (createInboundTurn). */
17+
function envelopedUserTurn(text: string): ConversationTurn {
18+
return {
19+
role: "user",
20+
content: [{ type: "text", text: `[From: user@local]\n\n${text}` }],
21+
timestamp: 0,
22+
} as unknown as ConversationTurn;
23+
}
1124

1225
describe("rowFromHistoryBlock", () => {
1326
test("user / text / reply / thinking", () => {
@@ -165,6 +178,68 @@ describe("rowFromHistoryBlock", () => {
165178
});
166179
});
167180

181+
test("persisted occupancy wakes drop only with their origin marker", () => {
182+
// turns-to-blocks marks wake-matching user turns origin:"system" at
183+
// persist time; the drop below is keyed on that marker, never the prefix
184+
// alone. Persisted turns carry the reactor envelope, so the fixtures use
185+
// the enveloped shape real sessions carry.
186+
const mail = `[From: user@local]\n\n${buildMailboxMailPrompt([
187+
{ agent_id: "w1", status: "done", report: "audit clean" },
188+
])}`;
189+
expect(
190+
rowFromHistoryBlock({ type: "user", content: mail, origin: "system" }),
191+
).toBeNull();
192+
193+
const dry = `[From: user@local]\n\n${buildFleetDryContinuationPrompt(
194+
[{ id: "t1", title: "ship it", status: "todo" }],
195+
[],
196+
)}`;
197+
expect(
198+
rowFromHistoryBlock({ type: "user", content: dry, origin: "system" }),
199+
).toBeNull();
200+
201+
// At this layer alone, the same bytes without the system marker paint:
202+
// the drop is keyed on the marker, never the prefix. That is a
203+
// layer-local guarantee, not the pipeline outcome — turns-to-blocks
204+
// marks any verbatim wake shape (operator-typed or not) before it
205+
// reaches here, so an enveloped verbatim-wake operator turn drops end
206+
// to end (locked by the pipeline test below). Only a bare prefix, which
207+
// the marker never matches, is guaranteed to paint on resume.
208+
expect(rowFromHistoryBlock({ type: "user", content: mail })).toEqual({
209+
role: "user",
210+
text: mail,
211+
});
212+
213+
// Explicitly operator-flagged text paints at this layer, even verbatim
214+
// wake text — again layer-local, not the pipeline outcome (see above).
215+
expect(
216+
rowFromHistoryBlock({
217+
type: "user",
218+
content: mail,
219+
origin: "operator",
220+
}),
221+
).toEqual({ role: "user", text: mail });
222+
223+
// Ordinary operator text is untouched.
224+
expect(rowFromHistoryBlock({ type: "user", content: "hi" })).toEqual({
225+
role: "user",
226+
text: "hi",
227+
});
228+
});
229+
230+
test("non-wake system inbound still paints on resume", () => {
231+
// A system-originated inbound that is not an occupancy wake — shaped like
232+
// a background-shell completion notice (mailbox "system", no operator
233+
// flag) — paints live and must survive resume too. Only wakes carry the
234+
// origin marker, so this arrives unmarked and must paint.
235+
const notice =
236+
"[From: user@local]\n\nBackground shell abc123 finished: exit code 0.\ncommand: bun test\noutput:\n3 pass";
237+
expect(rowFromHistoryBlock({ type: "user", content: notice })).toEqual({
238+
role: "user",
239+
text: notice,
240+
});
241+
});
242+
168243
test("a tasks block no longer hydrates a row at all", () => {
169244
// Task state is live panel state, not conversation history. Nothing writes
170245
// this block any more, and an old session carrying one must not paint a
@@ -312,3 +387,43 @@ describe("hydrateHistoryRows", () => {
312387
]);
313388
});
314389
});
390+
391+
describe("resume pipeline end to end (turns-to-blocks into hydrate)", () => {
392+
test("a marked wake drops while operator text paints", () => {
393+
// Mirrors runner wiring: persisted turns become content blocks, which
394+
// cross history.hydrate as untyped JSON — so the origin marker must
395+
// survive asHistoryBlock for the drop to fire, and unmarked text must
396+
// paint even though the wake prefix is in the same payload.
397+
const wake = buildMailboxMailPrompt([
398+
{ agent_id: "w1", status: "done", report: "audit clean" },
399+
]);
400+
const operatorText = "[From: user@local]\n\nship it";
401+
const blocks = turnsToContentBlocks([
402+
envelopedUserTurn(wake),
403+
envelopedUserTurn("ship it"),
404+
]);
405+
expect(blocks).toMatchObject([
406+
{ type: "user", origin: "system" },
407+
{ type: "user" },
408+
]);
409+
const rows = hydrateHistoryRows(JSON.parse(JSON.stringify(blocks)));
410+
expect(rows).toEqual([{ role: "user", text: operatorText }]);
411+
});
412+
413+
test("an enveloped verbatim-wake operator turn drops end to end", () => {
414+
// Residual provenance gap: persisted turns carry no message flags, so
415+
// turns-to-blocks marks wakes by content and an operator turn carrying
416+
// a byte-verbatim wake (full wake line plus report JSON — a deliberate
417+
// paste) is marked origin:"system" and dropped here. Trigger is narrow
418+
// and the consequence cosmetic (one scrollback row; model history
419+
// intact), but the drop is real: this test fails if anyone claims
420+
// verbatim operator text always paints.
421+
const wake = buildMailboxMailPrompt([
422+
{ agent_id: "w1", status: "done", report: "audit clean" },
423+
]);
424+
const blocks = turnsToContentBlocks([envelopedUserTurn(wake)]);
425+
expect(blocks).toMatchObject([{ type: "user", origin: "system" }]);
426+
const rows = hydrateHistoryRows(JSON.parse(JSON.stringify(blocks)));
427+
expect(rows).toEqual([]);
428+
});
429+
});

src/tui/history-hydrate.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { toolResultRow } from "./mcp-view.js";
1111
import type { StreamRow } from "./stream.js";
1212
import { TOOL_DETAIL_WIDTH } from "./tool-args.js";
1313
import { pushToolCall, pushToolResult } from "./tool-rows.js";
14+
import { isPersistedOccupancyWakeText } from "../subagent/mailbox-mail-drive.js";
1415

1516
/**
1617
* Loose content-block shape from `history.hydrate` / turns-to-blocks.
@@ -35,6 +36,14 @@ export interface HistoryBlock {
3536
readonly node?: unknown;
3637
/** plan block payload. */
3738
readonly steps?: unknown;
39+
/**
40+
* Persisted origin for user blocks (turns-to-blocks): "system" marks an
41+
* occupancy wake, the only user-type block the resume path ever drops.
42+
* Anything else paints at this layer — but the mark itself derives from
43+
* content, so a verbatim wake-shaped operator turn arrives marked and
44+
* drops here (deliberate-paste-only trigger; one scrollback row).
45+
*/
46+
readonly origin?: string;
3847
}
3948

4049
/** Body for a resumed error the transcript recorded without its message. */
@@ -59,6 +68,7 @@ function asHistoryBlock(raw: unknown): HistoryBlock | null {
5968
callId?: string;
6069
node?: unknown;
6170
steps?: unknown;
71+
origin?: string;
6272
} = { type: o.type };
6373
if (typeof o.content === "string") out.content = o.content;
6474
if (typeof o.name === "string") out.name = o.name;
@@ -68,6 +78,7 @@ function asHistoryBlock(raw: unknown): HistoryBlock | null {
6878
if (typeof o.callId === "string") out.callId = o.callId;
6979
if (o.node !== undefined) out.node = o.node;
7080
if (o.steps !== undefined) out.steps = o.steps;
81+
if (typeof o.origin === "string") out.origin = o.origin;
7182
return out as HistoryBlock;
7283
}
7384

@@ -119,8 +130,17 @@ function planText(steps: unknown): string {
119130
*/
120131
export function rowFromHistoryBlock(block: HistoryBlock): StreamRow | null {
121132
switch (block.type) {
122-
case "user":
123-
return { role: "user", text: block.content ?? "" };
133+
case "user": {
134+
const content = block.content ?? "";
135+
// Origin-keyed suppression: only a block marked as a system wake
136+
// drops. Unmarked or operator-marked wake-shaped text paints at this
137+
// layer — but the pipeline marks by content, so a verbatim
138+
// wake-shaped operator turn never arrives here unmarked.
139+
if (block.origin === "system" && isPersistedOccupancyWakeText(content)) {
140+
return null;
141+
}
142+
return { role: "user", text: content };
143+
}
124144
case "text":
125145
case "reply":
126146
return { role: "assistant", text: block.content ?? "" };

src/tui/runtime-bridge.test.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1601,7 +1601,7 @@ describe("fleet-dry open-task drive (CL-7540)", () => {
16011601
bridge.handle({ type: "inference.done", data: {} });
16021602
}
16031603

1604-
test("dry+open: fleet-0 settle drives once, keeps the run busy, and paints the prompt as system", async () => {
1604+
test("dry+open: fleet-0 settle drives once and keeps the run busy without painting the prompt", async () => {
16051605
await withTestRenderer(
16061606
async (h) => {
16071607
const shell = createAppShell(h.renderer, {
@@ -1639,11 +1639,13 @@ describe("fleet-dry open-task drive (CL-7540)", () => {
16391639
expect(shell.streamLog.filter((r) => r.role === "user").length).toBe(
16401640
userRowsBefore,
16411641
);
1642+
// The continuation is runtime→agent traffic — the fleet board owns
1643+
// worker status, so its prompt paints no transcript row.
16421644
expect(
16431645
shell.streamLog.filter(
16441646
(r) => r.role === "system" && r.text === prompt,
16451647
),
1646-
).toHaveLength(1);
1648+
).toHaveLength(0);
16471649
settleToollessTurn(bridge);
16481650
expect(drives).toBe(1);
16491651
} finally {
@@ -2060,11 +2062,13 @@ describe("fleet-dry open-task drive (CL-7540)", () => {
20602062
expect(shell.streamLog.filter((r) => r.role === "user").length).toBe(
20612063
userRowsAfterSubmit,
20622064
);
2065+
// Fleet-dry continuations are internal runtime→agent traffic and
2066+
// paint no row; the abort must not have swallowed the inbound.
20632067
expect(
20642068
shell.streamLog.filter(
20652069
(r) => r.role === "system" && r.text === occupancy,
20662070
),
2067-
).toHaveLength(1);
2071+
).toHaveLength(0);
20682072
} finally {
20692073
bridge.dispose();
20702074
shell.dispose();

src/tui/stream-event-map.test.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { describe, expect, test } from "bun:test";
22
import { OPERATOR_ORIGINATED_FLAG } from "../agent/message-provenance.js";
3-
import { buildShellBackgroundMessage } from "../session/runtime-assembly.js";
3+
import {
4+
buildFleetDryContinuationMessage,
5+
buildMailboxMailMessage,
6+
buildShellBackgroundMessage,
7+
} from "../session/runtime-assembly.js";
8+
import { buildFleetDryContinuationPrompt } from "../subagent/fleet-dry-drive.js";
9+
import { buildMailboxMailPrompt } from "../subagent/mailbox-mail-drive.js";
410
import { suppressProviderFailurePresentation } from "./provider/failure-attempt.js";
511
import {
612
createStreamMapContext,
@@ -49,6 +55,45 @@ describe("mapProductionEvent", () => {
4955
).toEqual([{ type: "system", text: message.content ?? "" }]);
5056
});
5157

58+
test("mailbox mail wake paints no transcript row", () => {
59+
const prompt = buildMailboxMailPrompt([
60+
{ agent_id: "w1", status: "done", report: "audit clean" },
61+
]);
62+
expect(
63+
mapProductionEvent({
64+
type: "message.received",
65+
data: { message: buildMailboxMailMessage(prompt) },
66+
}),
67+
).toEqual([]);
68+
});
69+
70+
test("fleet-dry continuation paints no transcript row", () => {
71+
const prompt = buildFleetDryContinuationPrompt(
72+
[{ id: "t1", title: "ship it", status: "todo" }],
73+
[{ agent_id: "w1", status: "done" }],
74+
);
75+
expect(
76+
mapProductionEvent({
77+
type: "message.received",
78+
data: { message: buildFleetDryContinuationMessage(prompt) },
79+
}),
80+
).toEqual([]);
81+
});
82+
83+
test("operator-originated text matching a wake prefix still paints", () => {
84+
const content = buildMailboxMailPrompt([
85+
{ agent_id: "w1", status: "done" },
86+
]);
87+
expect(
88+
mapProductionEvent({
89+
type: "message.received",
90+
data: {
91+
message: { content, flags: [OPERATOR_ORIGINATED_FLAG] },
92+
},
93+
}),
94+
).toEqual([{ type: "user", text: content }]);
95+
});
96+
5297
test("inference.start → busy run", () => {
5398
expect(mapProductionEvent({ type: "inference.start" })).toEqual([
5499
{ type: "attempt", action: "mark" },

0 commit comments

Comments
 (0)