Skip to content

Commit 00f2895

Browse files
committed
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 c546adc commit 00f2895

5 files changed

Lines changed: 209 additions & 20 deletions

File tree

‎src/subagent/mailbox-mail-drive.ts‎

Lines changed: 28 additions & 3 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";
@@ -28,14 +29,38 @@ export function mailboxMailWakeLine(): string {
2829
/**
2930
* Whether inbound text is occupancy's mailbox mail. Internal runtime→agent
3031
* 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.
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).
3435
*/
3536
export function isMailboxMailText(text: string): boolean {
3637
return text.startsWith(mailboxMailWakeLine());
3738
}
3839

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+
3964
function isPromiseLike(value: unknown): value is Promise<unknown> {
4065
return typeof value === "object" && value !== null && "then" in value;
4166
}

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

Lines changed: 102 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, test } from "bun:test";
2+
import type { ConversationTurn } from "@intx/types/runtime";
23
import { buildFleetDryContinuationPrompt } from "../subagent/fleet-dry-drive.js";
34
import { buildMailboxMailPrompt } from "../subagent/mailbox-mail-drive.js";
45
import {
@@ -10,6 +11,16 @@ import {
1011
rowsFromHistoryBlocks,
1112
type HistoryBlock,
1213
} 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+
}
1324

1425
describe("rowFromHistoryBlock", () => {
1526
test("user / text / reply / thinking", () => {
@@ -167,27 +178,68 @@ describe("rowFromHistoryBlock", () => {
167178
});
168179
});
169180

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([
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([
175187
{ agent_id: "w1", status: "done", report: "audit clean" },
176-
]);
177-
expect(rowFromHistoryBlock({ type: "user", content: mail })).toBeNull();
188+
])}`;
189+
expect(
190+
rowFromHistoryBlock({ type: "user", content: mail, origin: "system" }),
191+
).toBeNull();
178192

179-
const dry = buildFleetDryContinuationPrompt(
193+
const dry = `[From: user@local]\n\n${buildFleetDryContinuationPrompt(
180194
[{ id: "t1", title: "ship it", status: "todo" }],
181195
[],
182-
);
183-
expect(rowFromHistoryBlock({ type: "user", content: dry })).toBeNull();
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+
184223
// Ordinary operator text is untouched.
185224
expect(rowFromHistoryBlock({ type: "user", content: "hi" })).toEqual({
186225
role: "user",
187226
text: "hi",
188227
});
189228
});
190229

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+
191243
test("a tasks block no longer hydrates a row at all", () => {
192244
// Task state is live panel state, not conversation history. Nothing writes
193245
// this block any more, and an old session carrying one must not paint a
@@ -335,3 +387,43 @@ describe("hydrateHistoryRows", () => {
335387
]);
336388
});
337389
});
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: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +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 { isFleetDryContinuationText } from "../subagent/fleet-dry-drive.js";
15-
import { isMailboxMailText } from "../subagent/mailbox-mail-drive.js";
14+
import { isPersistedOccupancyWakeText } from "../subagent/mailbox-mail-drive.js";
1615

1716
/**
1817
* Loose content-block shape from `history.hydrate` / turns-to-blocks.
@@ -37,6 +36,14 @@ export interface HistoryBlock {
3736
readonly node?: unknown;
3837
/** plan block payload. */
3938
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;
4047
}
4148

4249
/** Body for a resumed error the transcript recorded without its message. */
@@ -61,6 +68,7 @@ function asHistoryBlock(raw: unknown): HistoryBlock | null {
6168
callId?: string;
6269
node?: unknown;
6370
steps?: unknown;
71+
origin?: string;
6472
} = { type: o.type };
6573
if (typeof o.content === "string") out.content = o.content;
6674
if (typeof o.name === "string") out.name = o.name;
@@ -70,6 +78,7 @@ function asHistoryBlock(raw: unknown): HistoryBlock | null {
7078
if (typeof o.callId === "string") out.callId = o.callId;
7179
if (o.node !== undefined) out.node = o.node;
7280
if (o.steps !== undefined) out.steps = o.steps;
81+
if (typeof o.origin === "string") out.origin = o.origin;
7382
return out as HistoryBlock;
7483
}
7584

@@ -123,9 +132,11 @@ export function rowFromHistoryBlock(block: HistoryBlock): StreamRow | null {
123132
switch (block.type) {
124133
case "user": {
125134
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)) {
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)) {
129140
return null;
130141
}
131142
return { role: "user", text: content };

‎src/tui/turns-to-blocks.test.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, test, expect } from "bun:test";
22
import type { ConversationTurn } from "@intx/types/runtime";
3+
import { buildMailboxMailPrompt } from "../subagent/mailbox-mail-drive.js";
34
import { turnsToContentBlocks } from "./turns-to-blocks.js";
45
import { hydrateTasksFromTurns } from "../agent/director.js";
56

@@ -78,6 +79,42 @@ describe("turnsToContentBlocks no longer derives tasks", () => {
7879
});
7980
});
8081

82+
describe("turnsToContentBlocks marks occupancy wakes with system origin", () => {
83+
function userTurn(text: string): ConversationTurn {
84+
return {
85+
role: "user",
86+
content: [{ type: "text", text }],
87+
timestamp: 0,
88+
} as unknown as ConversationTurn;
89+
}
90+
91+
test("enveloped wake turn is marked; operator text stays unmarked", () => {
92+
// Persisted turns carry the reactor envelope (createInboundTurn), so the
93+
// wake match must see through "[From: ...]\n\n" to the prompt beneath.
94+
const wake = buildMailboxMailPrompt([
95+
{ agent_id: "w1", status: "done", report: "audit clean" },
96+
]);
97+
const blocks = turnsToContentBlocks([
98+
userTurn(`[From: user@local]\n\n${wake}`),
99+
userTurn("[From: user@local]\n\nship it"),
100+
]);
101+
expect(blocks).toMatchObject([
102+
{ type: "user", origin: "system" },
103+
{ type: "user" },
104+
]);
105+
expect("origin" in (blocks[1] as object)).toBe(false);
106+
});
107+
108+
test("operator text starting with a wake line is not marked", () => {
109+
// Only the full wake shape earns the system marker — a bare prefix
110+
// typed by the operator stays unmarked, and unmarked blocks always
111+
// paint on resume (see history-hydrate).
112+
const blocks = turnsToContentBlocks([userTurn("mailbox mail")]);
113+
expect(blocks).toMatchObject([{ type: "user" }]);
114+
expect("origin" in (blocks[0] as object)).toBe(false);
115+
});
116+
});
117+
81118
describe("hydrateTasksFromTurns", () => {
82119
test("derives the task list from manage_tasks tool calls in a transcript", () => {
83120
const turns = [manageTasksTurn("m1", "doing"), toolResultTurn("m1", false)];

‎src/tui/turns-to-blocks.ts‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
ConversationTurn,
44
} from "@intx/types/runtime";
55

6+
import { isPersistedOccupancyWakeText } from "../subagent/mailbox-mail-drive.js";
67
import { validateView, type ViewNode } from "./view/index.js";
78

89
interface PlanBlockStep {
@@ -12,7 +13,19 @@ interface PlanBlockStep {
1213
}
1314

1415
export type ContentBlockData =
15-
| { type: "user"; content: string }
16+
| {
17+
type: "user";
18+
content: string;
19+
/**
20+
* Persisted origin for resume suppression: turns carry no message
21+
* flags, so wake-shaped user turns are marked "system" here and
22+
* history-hydrate drops only marked blocks. The mark derives from
23+
* content, not provenance — a verbatim wake-shaped operator turn is
24+
* marked (and dropped) exactly like a real wake. Only a bare wake
25+
* prefix, or non-wake text, stays unmarked and paints.
26+
*/
27+
origin?: "operator" | "system";
28+
}
1629
| { type: "thinking"; content: string }
1730
| { type: "text"; content: string }
1831
| {
@@ -205,7 +218,18 @@ function turnToContentBlocks(turn: ConversationTurn): ContentBlockData[] {
205218
const out: ContentBlockData[] = [];
206219
if (turn.role === "user") {
207220
const text = textFromBlocks(turn.content);
208-
if (text.length > 0) out.push({ type: "user", content: text });
221+
if (text.length > 0) {
222+
// Occupancy wakes persist as user-role turns with no flags. Mark the
223+
// wake shape here so history-hydrate can drop it by origin. The match
224+
// is content, not provenance: an operator turn carrying a byte-verbatim
225+
// wake (deliberate paste of the full wake line plus report JSON) is
226+
// marked — and dropped — too. A bare wake prefix stays unmarked.
227+
if (isPersistedOccupancyWakeText(text)) {
228+
out.push({ type: "user", content: text, origin: "system" });
229+
} else {
230+
out.push({ type: "user", content: text });
231+
}
232+
}
209233
return out;
210234
}
211235
if (turn.role !== "assistant") return out;

0 commit comments

Comments
 (0)