diff --git a/CHANGELOG.md b/CHANGELOG.md index 060344eee..839d281b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/tui/observe-live.test.ts b/src/tui/observe-live.test.ts index c40024374..93bcb0243 100644 --- a/src/tui/observe-live.test.ts +++ b/src/tui/observe-live.test.ts @@ -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({ diff --git a/src/tui/reasoning-fold.test.ts b/src/tui/reasoning-fold.test.ts index 7e3ee3f34..324ae1c0d 100644 --- a/src/tui/reasoning-fold.test.ts +++ b/src/tui/reasoning-fold.test.ts @@ -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"; @@ -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] }, + }, }); } @@ -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")); diff --git a/src/tui/runtime-bridge.test.ts b/src/tui/runtime-bridge.test.ts index d63fdbccc..a94ad02af 100644 --- a/src/tui/runtime-bridge.test.ts +++ b/src/tui/runtime-bridge.test.ts @@ -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, @@ -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", @@ -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) => { @@ -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, { @@ -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 { @@ -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, { @@ -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(); diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index 0e3af6fb0..b5848906a 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -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, @@ -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. */ @@ -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; @@ -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"); @@ -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; @@ -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." } }, diff --git a/src/tui/stream-event-map.test.ts b/src/tui/stream-event-map.test.ts index 4444eb878..c244644d9 100644 --- a/src/tui/stream-event-map.test.ts +++ b/src/tui/stream-event-map.test.ts @@ -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, @@ -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" }, diff --git a/src/tui/stream-event-map.ts b/src/tui/stream-event-map.ts index 301e4c532..40a2fb02e 100644 --- a/src/tui/stream-event-map.ts +++ b/src/tui/stream-event-map.ts @@ -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 { @@ -248,6 +249,14 @@ function asRecord(value: unknown): Record | undefined { return undefined; } +function inboundMessageFlags( + message: Record | 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 { return asRecord(event.data) ?? {}; } @@ -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": {