Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ matching `## [X.Y.Z]` section (plus install instructions). Do not maintain
parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
`## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script.

## [Unreleased]

### Fixed

- System-originated inbound (`message.received` without the operator flag —
background shell exits, mailbox mail, fleet-dry and compact continuations)
paints as a visible system row instead of a user prompt.

## [0.3.21] - 2026-09-11

### Added
Expand Down
2 changes: 1 addition & 1 deletion src/tui/observe-live.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,7 @@ describe("observe pure mappers", () => {
type: "message.received",
data: { message: { content: "go" } },
}),
).toEqual([{ role: "user", text: "go" }]);
).toEqual([{ role: "system", text: "go" }]);
expect(mapChildStreamEvent({ type: "inference.start" })).toEqual([]);
expect(
mapChildStreamEvent({
Expand Down
9 changes: 7 additions & 2 deletions src/tui/reasoning-fold.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

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

import { OPERATOR_ORIGINATED_FLAG } from "../agent/message-provenance.js";
import { attachSessionBridge, createRecordingPort } from "./runtime-bridge.js";
import { createHarness, type Harness } from "./harness.js";
import { toggleCollapsedRow } from "./shell/chrome.js";
Expand Down Expand Up @@ -47,7 +48,9 @@ function prompt(text: string): void {
mark = shell.streamLog.length;
bridge.handle({
type: "message.received",
data: { message: { content: text } },
data: {
message: { content: text, flags: [OPERATOR_ORIGINATED_FLAG] },
},
});
}

Expand Down Expand Up @@ -126,7 +129,9 @@ describe("a turn's reasoning", () => {
bridge.handle({ type: "inference.text.delta", data: { token: "a" } });
bridge.handle({
type: "message.received",
data: { message: { content: "two" } },
data: {
message: { content: "two", flags: [OPERATOR_ORIGINATED_FLAG] },
},
});
bridge.handle(think("thinking about two"));

Expand Down
71 changes: 66 additions & 5 deletions src/tui/runtime-bridge.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { describe, expect, spyOn, test } from "bun:test";
import { defined } from "../../tests/helpers/defined.js";
import { OPERATOR_ORIGINATED_FLAG } from "../agent/message-provenance.js";
import { buildShellBackgroundMessage } from "../session/runtime-assembly.js";
import {
FIXTURE_BUSY_SESSION,
attachSessionBridge,
Expand All @@ -17,15 +19,26 @@ import { badgeCount } from "./session-queue";
import { LIVE_ACTIVITY_WORDS } from "./session-chrome";

describe("mapReactorLike", () => {
test("message.received → user", () => {
test("operator-originated message.received → user", () => {
expect(
mapReactorLike({
type: "message.received",
data: { message: { content: "hi" } },
data: {
message: { content: "hi", flags: [OPERATOR_ORIGINATED_FLAG] },
},
}),
).toEqual([{ type: "user", text: "hi" }]);
});

test("system-originated message.received → system", () => {
expect(
mapReactorLike({
type: "message.received",
data: { message: { content: "hi" } },
}),
).toEqual([{ type: "system", text: "hi" }]);
});

test("mapReactorLike tool.done types", () => {
const mapped = mapReactorLike({
type: "tool.done",
Expand All @@ -47,6 +60,44 @@ describe("mapReactorLike", () => {
});

describe("attachSessionBridge", () => {
test("background shell exit paints as a system row", async () => {
await withTestRenderer(
async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: 80, rows: 24 },
wireKeys: false,
run: "idle",
});
const bridge = attachSessionBridge(shell, createRecordingPort());
try {
const message = buildShellBackgroundMessage({
id: "sh_1",
command: "sleep 0.1",
exitCode: 0,
timedOut: false,
output: "done",
});
bridge.handle({
type: "message.received",
data: { message },
});
expect(shell.streamLog.filter((r) => r.role === "user")).toHaveLength(
0,
);
expect(
shell.streamLog.filter(
(r) => r.role === "system" && r.text === message.content,
),
).toHaveLength(1);
} finally {
bridge.dispose();
shell.dispose();
}
},
{ width: 80, height: 24 },
);
});

test("fixture paints user / assistant / tool through shell", async () => {
await withTestRenderer(
async (h) => {
Expand Down Expand Up @@ -1549,7 +1600,7 @@ describe("fleet-dry open-task drive (CL-7540)", () => {
bridge.handle({ type: "inference.done", data: {} });
}

test("dry+open: fleet-0 settle drives once, keeps the run busy, and swallows the prompt", async () => {
test("dry+open: fleet-0 settle drives once, keeps the run busy, and paints the prompt as system", async () => {
await withTestRenderer(
async (h) => {
const shell = createAppShell(h.renderer, {
Expand Down Expand Up @@ -1587,6 +1638,11 @@ describe("fleet-dry open-task drive (CL-7540)", () => {
expect(shell.streamLog.filter((r) => r.role === "user").length).toBe(
userRowsBefore,
);
expect(
shell.streamLog.filter(
(r) => r.role === "system" && r.text === prompt,
),
).toHaveLength(1);
settleToollessTurn(bridge);
expect(drives).toBe(1);
} finally {
Expand Down Expand Up @@ -1966,7 +2022,7 @@ describe("fleet-dry open-task drive (CL-7540)", () => {
);
});

test("occupancy send abort drops the continuation echo so a later matching inbound paints", async () => {
test("occupancy send abort does not swallow a later matching inbound", async () => {
await withTestRenderer(
async (h) => {
const shell = createAppShell(h.renderer, {
Expand Down Expand Up @@ -2001,8 +2057,13 @@ describe("fleet-dry open-task drive (CL-7540)", () => {
data: { message: { content: occupancy } },
});
expect(shell.streamLog.filter((r) => r.role === "user").length).toBe(
userRowsAfterSubmit + 1,
userRowsAfterSubmit,
);
expect(
shell.streamLog.filter(
(r) => r.role === "system" && r.text === occupancy,
),
).toHaveLength(1);
} finally {
bridge.dispose();
shell.dispose();
Expand Down
32 changes: 12 additions & 20 deletions src/tui/runtime-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
import { applyShellInterrupt, surfaceSystemNotice } from "./shell/prompt.js";
import { streamRowAt, streamRowCount } from "./shell/transcript.js";
import { rampAnimating } from "./ramp.js";
import { OPERATOR_ORIGINATED_FLAG } from "../agent/message-provenance.js";
import { onTurnBoundary } from "../agent/reactor-events.js";
import {
resolveRampPhase,
Expand Down Expand Up @@ -250,15 +251,14 @@ export interface SessionBridge {
) => void;
/**
* Mark the run busy for a system-originated continuation (fleet-dry open-task
* drive). Pushes `text` onto pendingEchoes so the inbound `message.received`
* is not painted as a user row. Does not send — the caller uses
* drive). Does not echo locally — inbound `message.received` without the
* operator flag paints as a system row. Does not send — the caller uses
* sendWithAttemptIdentity with a system mailbox message.
*/
beginSystemContinuation: (text: string) => void;
/**
* Occupancy send failed after beginSystemContinuation. Drop the occupancy
* echo so a later matching inbound is not swallowed, drop the continuation
* hold, and idle so follow-ups can drain. Pass rearmDry:false for mailbox
* Occupancy send failed after beginSystemContinuation. Drop the continuation
* hold and idle so follow-ups can drain. Pass rearmDry:false for mailbox
* mail so a later subscribe can retry; fleet-dry defaults to re-arming the
* latch for the next settle shot.
*/
Expand Down Expand Up @@ -1247,7 +1247,7 @@ function applyInbound(

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

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

Expand Down Expand Up @@ -1876,7 +1876,6 @@ export function attachSessionBridge(
if (bag.disposed) return;
const t = text.trim();
if (t.length === 0) return;
bag.pendingEchoes.push(t);
bag.lastSentMessage = t;
bag.awaitingContinuationInference = true;
shell.session = setRunState(shell.session, "busy");
Expand All @@ -1886,18 +1885,6 @@ export function attachSessionBridge(
},
abortSystemContinuation: (opts) => {
if (bag.disposed) return;
if (bag.awaitingContinuationInference) {
const occupancy = bag.lastSentMessage;
if (occupancy.length > 0) {
const last = bag.pendingEchoes.length - 1;
if (last >= 0 && bag.pendingEchoes[last] === occupancy) {
bag.pendingEchoes.pop();
} else {
const index = bag.pendingEchoes.lastIndexOf(occupancy);
if (index !== -1) bag.pendingEchoes.splice(index, 1);
}
}
}
bag.awaitingContinuationInference = false;
if (opts?.rearmDry !== false) {
bag.droveOpenTasksThisDry = false;
Expand Down Expand Up @@ -1945,7 +1932,12 @@ export const FIXTURE_BUSY_SESSION: readonly ReactorLikeEvent[] = [
{ type: "inference.start", data: {} },
{
type: "message.received",
data: { message: { content: "list project root" } },
data: {
message: {
content: "list project root",
flags: [OPERATOR_ORIGINATED_FLAG],
},
},
},
{ type: "inference.text.delta", data: { token: "I'll " } },
{ type: "inference.text.delta", data: { token: "list the directory." } },
Expand Down
36 changes: 34 additions & 2 deletions src/tui/stream-event-map.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { describe, expect, test } from "bun:test";
import { OPERATOR_ORIGINATED_FLAG } from "../agent/message-provenance.js";
import { buildShellBackgroundMessage } from "../session/runtime-assembly.js";
import { suppressProviderFailurePresentation } from "./provider/failure-attempt.js";
import {
createStreamMapContext,
Expand All @@ -8,15 +10,45 @@ import {
} from "./stream-event-map.js";

describe("mapProductionEvent", () => {
test("message.received → user text", () => {
test("operator-originated message.received → user text", () => {
expect(
mapProductionEvent({
type: "message.received",
data: { message: { content: "hello" } },
data: {
message: {
content: "hello",
flags: [OPERATOR_ORIGINATED_FLAG],
},
},
}),
).toEqual([{ type: "user", text: "hello" }]);
});

test("message.received without operator flag → system text", () => {
expect(
mapProductionEvent({
type: "message.received",
data: { message: { content: "hello" } },
}),
).toEqual([{ type: "system", text: "hello" }]);
});

test("background shell exit message.received → system", () => {
const message = buildShellBackgroundMessage({
id: "sh_1",
command: "sleep 0.1",
exitCode: 0,
timedOut: false,
output: "done",
});
expect(
mapProductionEvent({
type: "message.received",
data: { message },
}),
).toEqual([{ type: "system", text: message.content ?? "" }]);
});

test("inference.start → busy run", () => {
expect(mapProductionEvent({ type: "inference.start" })).toEqual([
{ type: "attempt", action: "mark" },
Expand Down
23 changes: 20 additions & 3 deletions src/tui/stream-event-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
splitPendingControlTail,
stripTerminalControlSequences,
} from "../util/control-char-strip.js";
import { isOperatorOriginated } from "../agent/message-provenance.js";
import { isReactorErrorFatal } from "../agent/reactor-events.js";
import { terminalProviderFailureMessage } from "../inference-error-message.js";
import {
Expand Down Expand Up @@ -248,6 +249,14 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
return undefined;
}

function inboundMessageFlags(
message: Record<string, unknown> | undefined,
): readonly string[] | undefined {
const flags = message?.flags;
if (!Array.isArray(flags)) return undefined;
return flags.filter((flag): flag is string => typeof flag === "string");
}

function dataOf(event: ReactorLikeEvent): Record<string, unknown> {
return asRecord(event.data) ?? {};
}
Expand Down Expand Up @@ -355,11 +364,19 @@ function mapEvent(
? `\n[Attached ${attachments.length} image${attachments.length === 1 ? "" : "s"}: ${attachments.map((a) => a.name ?? "image").join(", ")}]`
: "";
const full = `${content}${attachmentText}`;
// The boundary must never straddle a user row: a later retry retracting
// across it would erase the operator's own message.
// The boundary must never straddle an inbound transcript row: a later
// retry retracting across it would erase operator or system text.
const disarmed = disarmAttempt(ctx);
if (full.trim().length === 0) return disarmed;
return [...disarmed, { type: "user", text: full }];
return [
...disarmed,
{
type: isOperatorOriginated(inboundMessageFlags(message))
? "user"
: "system",
text: full,
},
];
}

case "inference.start": {
Expand Down
Loading