Skip to content

Commit 96e6ee6

Browse files
Merge pull request #917 from corbitsdev/cl-7740-fix-background-shell-completions-rendering-as-user-commands
Paint system-originated inbound as visible system rows
2 parents ae96d35 + f9af059 commit 96e6ee6

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,6 +1,8 @@
11
import { describe, expect, spyOn, test } from "bun:test";
22
import { mailboxMailWakeLine } from "../subagent/mailbox-mail-drive.js";
33
import { defined } from "../../tests/helpers/defined.js";
4+
import { OPERATOR_ORIGINATED_FLAG } from "../agent/message-provenance.js";
5+
import { buildShellBackgroundMessage } from "../session/runtime-assembly.js";
46
import {
57
FIXTURE_BUSY_SESSION,
68
attachSessionBridge,
@@ -18,15 +20,26 @@ import { badgeCount } from "./session-queue";
1820
import { LIVE_ACTIVITY_WORDS } from "./session-chrome";
1921

2022
describe("mapReactorLike", () => {
21-
test("message.received → user", () => {
23+
test("operator-originated message.received → user", () => {
2224
expect(
2325
mapReactorLike({
2426
type: "message.received",
25-
data: { message: { content: "hi" } },
27+
data: {
28+
message: { content: "hi", flags: [OPERATOR_ORIGINATED_FLAG] },
29+
},
2630
}),
2731
).toEqual([{ type: "user", text: "hi" }]);
2832
});
2933

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

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

1553-
test("dry+open: fleet-0 settle drives once, keeps the run busy, and swallows the prompt", async () => {
1604+
test("dry+open: fleet-0 settle drives once, keeps the run busy, and paints the prompt as system", async () => {
15541605
await withTestRenderer(
15551606
async (h) => {
15561607
const shell = createAppShell(h.renderer, {
@@ -1588,6 +1639,11 @@ describe("fleet-dry open-task drive (CL-7540)", () => {
15881639
expect(shell.streamLog.filter((r) => r.role === "user").length).toBe(
15891640
userRowsBefore,
15901641
);
1642+
expect(
1643+
shell.streamLog.filter(
1644+
(r) => r.role === "system" && r.text === prompt,
1645+
),
1646+
).toHaveLength(1);
15911647
settleToollessTurn(bridge);
15921648
expect(drives).toBe(1);
15931649
} finally {
@@ -1967,7 +2023,7 @@ describe("fleet-dry open-task drive (CL-7540)", () => {
19672023
);
19682024
});
19692025

1970-
test("occupancy send abort drops the continuation echo so a later matching inbound paints", async () => {
2026+
test("occupancy send abort does not swallow a later matching inbound", async () => {
19712027
await withTestRenderer(
19722028
async (h) => {
19732029
const shell = createAppShell(h.renderer, {
@@ -2002,8 +2058,13 @@ describe("fleet-dry open-task drive (CL-7540)", () => {
20022058
data: { message: { content: occupancy } },
20032059
});
20042060
expect(shell.streamLog.filter((r) => r.role === "user").length).toBe(
2005-
userRowsAfterSubmit + 1,
2061+
userRowsAfterSubmit,
20062062
);
2063+
expect(
2064+
shell.streamLog.filter(
2065+
(r) => r.role === "system" && r.text === occupancy,
2066+
),
2067+
).toHaveLength(1);
20072068
} finally {
20082069
bridge.dispose();
20092070
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)