Skip to content

Commit c546adc

Browse files
committed
Keep internal agent traffic out of transcript
1 parent a61cd76 commit c546adc

8 files changed

Lines changed: 123 additions & 16 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: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,17 @@ export function mailboxMailWakeLine(): string {
2525
return `${MAILBOX_MAIL_WAKE_PREFIX} — occupancy delivered these worker reports (do not call wait_agents for these agent_ids):`;
2626
}
2727

28+
/**
29+
* Whether inbound text is occupancy's mailbox mail. Internal runtime→agent
30+
* traffic — the fleet board already owns worker status and the payload is
31+
* model-facing report JSON, so the transcript never paints it. Persisted
32+
* turns carry no message flags, so both the live event map and history
33+
* hydration must recognise it by content.
34+
*/
35+
export function isMailboxMailText(text: string): boolean {
36+
return text.startsWith(mailboxMailWakeLine());
37+
}
38+
2839
function isPromiseLike(value: unknown): value is Promise<unknown> {
2940
return typeof value === "object" && value !== null && "then" in value;
3041
}

‎src/tui/history-hydrate.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { describe, expect, test } from "bun:test";
2+
import { buildFleetDryContinuationPrompt } from "../subagent/fleet-dry-drive.js";
3+
import { buildMailboxMailPrompt } from "../subagent/mailbox-mail-drive.js";
24
import {
35
EMPTY_PLAN_DETAIL,
46
EMPTY_VIEW_DETAIL,
@@ -165,6 +167,27 @@ describe("rowFromHistoryBlock", () => {
165167
});
166168
});
167169

170+
test("persisted occupancy wakes do not rehydrate as user rows", () => {
171+
// Both were system-originated inbounds; persisted turns carry no flags, so
172+
// the content prefix is the only thing that keeps them out of a resumed
173+
// transcript.
174+
const mail = buildMailboxMailPrompt([
175+
{ agent_id: "w1", status: "done", report: "audit clean" },
176+
]);
177+
expect(rowFromHistoryBlock({ type: "user", content: mail })).toBeNull();
178+
179+
const dry = buildFleetDryContinuationPrompt(
180+
[{ id: "t1", title: "ship it", status: "todo" }],
181+
[],
182+
);
183+
expect(rowFromHistoryBlock({ type: "user", content: dry })).toBeNull();
184+
// Ordinary operator text is untouched.
185+
expect(rowFromHistoryBlock({ type: "user", content: "hi" })).toEqual({
186+
role: "user",
187+
text: "hi",
188+
});
189+
});
190+
168191
test("a tasks block no longer hydrates a row at all", () => {
169192
// Task state is live panel state, not conversation history. Nothing writes
170193
// this block any more, and an old session carrying one must not paint a

‎src/tui/history-hydrate.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ 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 { isFleetDryContinuationText } from "../subagent/fleet-dry-drive.js";
15+
import { isMailboxMailText } from "../subagent/mailbox-mail-drive.js";
1416

1517
/**
1618
* Loose content-block shape from `history.hydrate` / turns-to-blocks.
@@ -119,8 +121,15 @@ function planText(steps: unknown): string {
119121
*/
120122
export function rowFromHistoryBlock(block: HistoryBlock): StreamRow | null {
121123
switch (block.type) {
122-
case "user":
123-
return { role: "user", text: block.content ?? "" };
124+
case "user": {
125+
const content = block.content ?? "";
126+
// Occupancy wakes persist as user-role turns; they were never operator
127+
// text, so a resumed transcript must not repaint them as such.
128+
if (isMailboxMailText(content) || isFleetDryContinuationText(content)) {
129+
return null;
130+
}
131+
return { role: "user", text: content };
132+
}
124133
case "text":
125134
case "reply":
126135
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" },

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

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
stripTerminalControlSequences,
1111
} from "../util/control-char-strip.js";
1212
import { isOperatorOriginated } from "../agent/message-provenance.js";
13+
import { isFleetDryContinuationText } from "../subagent/fleet-dry-drive.js";
14+
import { isMailboxMailText } from "../subagent/mailbox-mail-drive.js";
1315
import { isReactorErrorFatal } from "../agent/reactor-events.js";
1416
import { terminalProviderFailureMessage } from "../inference-error-message.js";
1517
import {
@@ -368,15 +370,17 @@ function mapEvent(
368370
// retry retracting across it would erase operator or system text.
369371
const disarmed = disarmAttempt(ctx);
370372
if (full.trim().length === 0) return disarmed;
371-
return [
372-
...disarmed,
373-
{
374-
type: isOperatorOriginated(inboundMessageFlags(message))
375-
? "user"
376-
: "system",
377-
text: full,
378-
},
379-
];
373+
const operator = isOperatorOriginated(inboundMessageFlags(message));
374+
// Occupancy wakes (mailbox mail, fleet-dry continuation) are
375+
// runtime→agent traffic: the fleet board already owns worker status,
376+
// and the payload is model-facing report JSON — no transcript row.
377+
if (
378+
!operator &&
379+
(isMailboxMailText(content) || isFleetDryContinuationText(content))
380+
) {
381+
return disarmed;
382+
}
383+
return [...disarmed, { type: operator ? "user" : "system", text: full }];
380384
}
381385

382386
case "inference.start": {

0 commit comments

Comments
 (0)