Skip to content

Commit f9af059

Browse files
committed
Paint system-originated inbound as visible system rows
1 parent bb6a342 commit f9af059

7 files changed

Lines changed: 148 additions & 33 deletions

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ matching `## [X.Y.Z]` section (plus install instructions). Do not maintain
1111
parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1212
`## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script.
1313

14+
## [Unreleased]
15+
16+
### Fixed
17+
18+
- System-originated inbound (`message.received` without the operator flag —
19+
background shell exits, mailbox mail, fleet-dry and compact continuations)
20+
paints as a visible system row instead of a user prompt.
21+
1422
## [0.3.21] - 2026-09-11
1523

1624
### Added

‎src/tui/observe-live.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -454,7 +454,7 @@ describe("observe pure mappers", () => {
454454
type: "message.received",
455455
data: { message: { content: "go" } },
456456
}),
457-
).toEqual([{ role: "user", text: "go" }]);
457+
).toEqual([{ role: "system", text: "go" }]);
458458
expect(mapChildStreamEvent({ type: "inference.start" })).toEqual([]);
459459
expect(
460460
mapChildStreamEvent({

‎src/tui/reasoning-fold.test.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1111

12+
import { OPERATOR_ORIGINATED_FLAG } from "../agent/message-provenance.js";
1213
import { attachSessionBridge, createRecordingPort } from "./runtime-bridge.js";
1314
import { createHarness, type Harness } from "./harness.js";
1415
import { toggleCollapsedRow } from "./shell/chrome.js";
@@ -47,7 +48,9 @@ function prompt(text: string): void {
4748
mark = shell.streamLog.length;
4849
bridge.handle({
4950
type: "message.received",
50-
data: { message: { content: text } },
51+
data: {
52+
message: { content: text, flags: [OPERATOR_ORIGINATED_FLAG] },
53+
},
5154
});
5255
}
5356

@@ -126,7 +129,9 @@ describe("a turn's reasoning", () => {
126129
bridge.handle({ type: "inference.text.delta", data: { token: "a" } });
127130
bridge.handle({
128131
type: "message.received",
129-
data: { message: { content: "two" } },
132+
data: {
133+
message: { content: "two", flags: [OPERATOR_ORIGINATED_FLAG] },
134+
},
130135
});
131136
bridge.handle(think("thinking about two"));
132137

‎src/tui/runtime-bridge.test.ts‎

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { describe, expect, spyOn, test } from "bun:test";
22
import { defined } from "../../tests/helpers/defined.js";
3+
import { OPERATOR_ORIGINATED_FLAG } from "../agent/message-provenance.js";
4+
import { buildShellBackgroundMessage } from "../session/runtime-assembly.js";
35
import {
46
FIXTURE_BUSY_SESSION,
57
attachSessionBridge,
@@ -17,15 +19,26 @@ import { badgeCount } from "./session-queue";
1719
import { LIVE_ACTIVITY_WORDS } from "./session-chrome";
1820

1921
describe("mapReactorLike", () => {
20-
test("message.received → user", () => {
22+
test("operator-originated message.received → user", () => {
2123
expect(
2224
mapReactorLike({
2325
type: "message.received",
24-
data: { message: { content: "hi" } },
26+
data: {
27+
message: { content: "hi", flags: [OPERATOR_ORIGINATED_FLAG] },
28+
},
2529
}),
2630
).toEqual([{ type: "user", text: "hi" }]);
2731
});
2832

33+
test("system-originated message.received → system", () => {
34+
expect(
35+
mapReactorLike({
36+
type: "message.received",
37+
data: { message: { content: "hi" } },
38+
}),
39+
).toEqual([{ type: "system", text: "hi" }]);
40+
});
41+
2942
test("mapReactorLike tool.done types", () => {
3043
const mapped = mapReactorLike({
3144
type: "tool.done",
@@ -47,6 +60,44 @@ describe("mapReactorLike", () => {
4760
});
4861

4962
describe("attachSessionBridge", () => {
63+
test("background shell exit paints as a system row", async () => {
64+
await withTestRenderer(
65+
async (h) => {
66+
const shell = createAppShell(h.renderer, {
67+
terminal: { columns: 80, rows: 24 },
68+
wireKeys: false,
69+
run: "idle",
70+
});
71+
const bridge = attachSessionBridge(shell, createRecordingPort());
72+
try {
73+
const message = buildShellBackgroundMessage({
74+
id: "sh_1",
75+
command: "sleep 0.1",
76+
exitCode: 0,
77+
timedOut: false,
78+
output: "done",
79+
});
80+
bridge.handle({
81+
type: "message.received",
82+
data: { message },
83+
});
84+
expect(shell.streamLog.filter((r) => r.role === "user")).toHaveLength(
85+
0,
86+
);
87+
expect(
88+
shell.streamLog.filter(
89+
(r) => r.role === "system" && r.text === message.content,
90+
),
91+
).toHaveLength(1);
92+
} finally {
93+
bridge.dispose();
94+
shell.dispose();
95+
}
96+
},
97+
{ width: 80, height: 24 },
98+
);
99+
});
100+
50101
test("fixture paints user / assistant / tool through shell", async () => {
51102
await withTestRenderer(
52103
async (h) => {
@@ -1549,7 +1600,7 @@ describe("fleet-dry open-task drive (CL-7540)", () => {
15491600
bridge.handle({ type: "inference.done", data: {} });
15501601
}
15511602

1552-
test("dry+open: fleet-0 settle drives once, keeps the run busy, and swallows the prompt", async () => {
1603+
test("dry+open: fleet-0 settle drives once, keeps the run busy, and paints the prompt as system", async () => {
15531604
await withTestRenderer(
15541605
async (h) => {
15551606
const shell = createAppShell(h.renderer, {
@@ -1587,6 +1638,11 @@ describe("fleet-dry open-task drive (CL-7540)", () => {
15871638
expect(shell.streamLog.filter((r) => r.role === "user").length).toBe(
15881639
userRowsBefore,
15891640
);
1641+
expect(
1642+
shell.streamLog.filter(
1643+
(r) => r.role === "system" && r.text === prompt,
1644+
),
1645+
).toHaveLength(1);
15901646
settleToollessTurn(bridge);
15911647
expect(drives).toBe(1);
15921648
} finally {
@@ -1966,7 +2022,7 @@ describe("fleet-dry open-task drive (CL-7540)", () => {
19662022
);
19672023
});
19682024

1969-
test("occupancy send abort drops the continuation echo so a later matching inbound paints", async () => {
2025+
test("occupancy send abort does not swallow a later matching inbound", async () => {
19702026
await withTestRenderer(
19712027
async (h) => {
19722028
const shell = createAppShell(h.renderer, {
@@ -2001,8 +2057,13 @@ describe("fleet-dry open-task drive (CL-7540)", () => {
20012057
data: { message: { content: occupancy } },
20022058
});
20032059
expect(shell.streamLog.filter((r) => r.role === "user").length).toBe(
2004-
userRowsAfterSubmit + 1,
2060+
userRowsAfterSubmit,
20052061
);
2062+
expect(
2063+
shell.streamLog.filter(
2064+
(r) => r.role === "system" && r.text === occupancy,
2065+
),
2066+
).toHaveLength(1);
20062067
} finally {
20072068
bridge.dispose();
20082069
shell.dispose();

‎src/tui/runtime-bridge.ts‎

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
import { applyShellInterrupt, surfaceSystemNotice } from "./shell/prompt.js";
3434
import { streamRowAt, streamRowCount } from "./shell/transcript.js";
3535
import { rampAnimating } from "./ramp.js";
36+
import { OPERATOR_ORIGINATED_FLAG } from "../agent/message-provenance.js";
3637
import { onTurnBoundary } from "../agent/reactor-events.js";
3738
import {
3839
resolveRampPhase,
@@ -250,15 +251,14 @@ export interface SessionBridge {
250251
) => void;
251252
/**
252253
* Mark the run busy for a system-originated continuation (fleet-dry open-task
253-
* drive). Pushes `text` onto pendingEchoes so the inbound `message.received`
254-
* is not painted as a user row. Does not send — the caller uses
254+
* drive). Does not echo locally — inbound `message.received` without the
255+
* operator flag paints as a system row. Does not send — the caller uses
255256
* sendWithAttemptIdentity with a system mailbox message.
256257
*/
257258
beginSystemContinuation: (text: string) => void;
258259
/**
259-
* Occupancy send failed after beginSystemContinuation. Drop the occupancy
260-
* echo so a later matching inbound is not swallowed, drop the continuation
261-
* hold, and idle so follow-ups can drain. Pass rearmDry:false for mailbox
260+
* Occupancy send failed after beginSystemContinuation. Drop the continuation
261+
* hold and idle so follow-ups can drain. Pass rearmDry:false for mailbox
262262
* mail so a later subscribe can retry; fleet-dry defaults to re-arming the
263263
* latch for the next settle shot.
264264
*/
@@ -1247,7 +1247,7 @@ function applyInbound(
12471247

12481248
// A new turn gets a new reasoning row; only within one turn does thinking
12491249
// fold back into the row it already owns.
1250-
if (event.type === "user") bag.turnThinking = null;
1250+
if (event.type === "user" || event.type === "system") bag.turnThinking = null;
12511251

12521252
if (event.type === "user" && consumeEcho(bag, event.text)) return;
12531253

@@ -1876,7 +1876,6 @@ export function attachSessionBridge(
18761876
if (bag.disposed) return;
18771877
const t = text.trim();
18781878
if (t.length === 0) return;
1879-
bag.pendingEchoes.push(t);
18801879
bag.lastSentMessage = t;
18811880
bag.awaitingContinuationInference = true;
18821881
shell.session = setRunState(shell.session, "busy");
@@ -1886,18 +1885,6 @@ export function attachSessionBridge(
18861885
},
18871886
abortSystemContinuation: (opts) => {
18881887
if (bag.disposed) return;
1889-
if (bag.awaitingContinuationInference) {
1890-
const occupancy = bag.lastSentMessage;
1891-
if (occupancy.length > 0) {
1892-
const last = bag.pendingEchoes.length - 1;
1893-
if (last >= 0 && bag.pendingEchoes[last] === occupancy) {
1894-
bag.pendingEchoes.pop();
1895-
} else {
1896-
const index = bag.pendingEchoes.lastIndexOf(occupancy);
1897-
if (index !== -1) bag.pendingEchoes.splice(index, 1);
1898-
}
1899-
}
1900-
}
19011888
bag.awaitingContinuationInference = false;
19021889
if (opts?.rearmDry !== false) {
19031890
bag.droveOpenTasksThisDry = false;
@@ -1945,7 +1932,12 @@ export const FIXTURE_BUSY_SESSION: readonly ReactorLikeEvent[] = [
19451932
{ type: "inference.start", data: {} },
19461933
{
19471934
type: "message.received",
1948-
data: { message: { content: "list project root" } },
1935+
data: {
1936+
message: {
1937+
content: "list project root",
1938+
flags: [OPERATOR_ORIGINATED_FLAG],
1939+
},
1940+
},
19491941
},
19501942
{ type: "inference.text.delta", data: { token: "I'll " } },
19511943
{ type: "inference.text.delta", data: { token: "list the directory." } },

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

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { describe, expect, test } from "bun:test";
2+
import { OPERATOR_ORIGINATED_FLAG } from "../agent/message-provenance.js";
3+
import { buildShellBackgroundMessage } from "../session/runtime-assembly.js";
24
import { suppressProviderFailurePresentation } from "./provider/failure-attempt.js";
35
import {
46
createStreamMapContext,
@@ -8,15 +10,45 @@ import {
810
} from "./stream-event-map.js";
911

1012
describe("mapProductionEvent", () => {
11-
test("message.received → user text", () => {
13+
test("operator-originated message.received → user text", () => {
1214
expect(
1315
mapProductionEvent({
1416
type: "message.received",
15-
data: { message: { content: "hello" } },
17+
data: {
18+
message: {
19+
content: "hello",
20+
flags: [OPERATOR_ORIGINATED_FLAG],
21+
},
22+
},
1623
}),
1724
).toEqual([{ type: "user", text: "hello" }]);
1825
});
1926

27+
test("message.received without operator flag → system text", () => {
28+
expect(
29+
mapProductionEvent({
30+
type: "message.received",
31+
data: { message: { content: "hello" } },
32+
}),
33+
).toEqual([{ type: "system", text: "hello" }]);
34+
});
35+
36+
test("background shell exit message.received → system", () => {
37+
const message = buildShellBackgroundMessage({
38+
id: "sh_1",
39+
command: "sleep 0.1",
40+
exitCode: 0,
41+
timedOut: false,
42+
output: "done",
43+
});
44+
expect(
45+
mapProductionEvent({
46+
type: "message.received",
47+
data: { message },
48+
}),
49+
).toEqual([{ type: "system", text: message.content ?? "" }]);
50+
});
51+
2052
test("inference.start → busy run", () => {
2153
expect(mapProductionEvent({ type: "inference.start" })).toEqual([
2254
{ type: "attempt", action: "mark" },

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

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
splitPendingControlTail,
1010
stripTerminalControlSequences,
1111
} from "../util/control-char-strip.js";
12+
import { isOperatorOriginated } from "../agent/message-provenance.js";
1213
import { isReactorErrorFatal } from "../agent/reactor-events.js";
1314
import { terminalProviderFailureMessage } from "../inference-error-message.js";
1415
import {
@@ -248,6 +249,14 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
248249
return undefined;
249250
}
250251

252+
function inboundMessageFlags(
253+
message: Record<string, unknown> | undefined,
254+
): readonly string[] | undefined {
255+
const flags = message?.flags;
256+
if (!Array.isArray(flags)) return undefined;
257+
return flags.filter((flag): flag is string => typeof flag === "string");
258+
}
259+
251260
function dataOf(event: ReactorLikeEvent): Record<string, unknown> {
252261
return asRecord(event.data) ?? {};
253262
}
@@ -355,11 +364,19 @@ function mapEvent(
355364
? `\n[Attached ${attachments.length} image${attachments.length === 1 ? "" : "s"}: ${attachments.map((a) => a.name ?? "image").join(", ")}]`
356365
: "";
357366
const full = `${content}${attachmentText}`;
358-
// The boundary must never straddle a user row: a later retry retracting
359-
// across it would erase the operator's own message.
367+
// The boundary must never straddle an inbound transcript row: a later
368+
// retry retracting across it would erase operator or system text.
360369
const disarmed = disarmAttempt(ctx);
361370
if (full.trim().length === 0) return disarmed;
362-
return [...disarmed, { type: "user", text: full }];
371+
return [
372+
...disarmed,
373+
{
374+
type: isOperatorOriginated(inboundMessageFlags(message))
375+
? "user"
376+
: "system",
377+
text: full,
378+
},
379+
];
363380
}
364381

365382
case "inference.start": {

0 commit comments

Comments
 (0)