Skip to content

Commit aa031e9

Browse files
committed
Split the thinking row when a steer echo is swallowed
Consuming an echoed message before the mapper kept the recovery handoff alive but skipped the turn bookkeeping, so reasoning after a steer folded into the row above the operator's message. Close the open row and reset the turn thinking on that path, and pin that the replayed quota prompt row is dropped by the rollback.
1 parent 22b8855 commit aa031e9

2 files changed

Lines changed: 63 additions & 1 deletion

File tree

src/tui/runtime-bridge.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -908,6 +908,8 @@ describe("same-turn failover after inference.error", () => {
908908
const port = createRecordingPort();
909909
const bridge = attachSessionBridge(shell, port);
910910
try {
911+
bridge.submit("retry this", "immediate");
912+
bridge.handle({ type: "message.received", data: { message: { content: "retry this" } } });
911913
bridge.handle({ type: "inference.start", data: {} });
912914
bridge.handle({
913915
type: "inference.error",
@@ -928,6 +930,10 @@ describe("same-turn failover after inference.error", () => {
928930

929931
expect(errorRows(shell)).toEqual([]);
930932
expect(shell.streamLog.map((r) => r.text).join("\n")).toContain("recovered");
933+
// The replay duplicates the operator's prompt; rollback drops the copy.
934+
expect(shell.streamLog.filter((r) => r.role === "user").map((r) => r.text)).toEqual([
935+
"retry this",
936+
]);
931937
// Same-turn failover, not an operator stop — recovery must not borrow interrupt.
932938
expect(port.calls.some((c) => c.op === "interrupt")).toBe(false);
933939
expect(shell.streamLog.some((r) => r.meta === "stop")).toBe(false);
@@ -940,6 +946,56 @@ describe("same-turn failover after inference.error", () => {
940946
);
941947
});
942948

949+
test("a steer echo at a tool boundary opens a new thinking row", async () => {
950+
await withTestRenderer(
951+
async (h) => {
952+
const shell = createAppShell(h.renderer, {
953+
terminal: { columns: 80, rows: 24 },
954+
wireKeys: false,
955+
run: "idle",
956+
});
957+
const bridge = attachSessionBridge(shell, createRecordingPort());
958+
try {
959+
bridge.submit("first prompt", "immediate");
960+
bridge.handle({
961+
type: "message.received",
962+
data: { message: { content: "first prompt" } },
963+
});
964+
bridge.handle({ type: "inference.start", data: {} });
965+
bridge.handle({ type: "inference.thinking.delta", data: { token: "planning" } });
966+
bridge.handle({
967+
type: "inference.tool_call.end",
968+
data: { name: "run_shell", callId: "c1", arguments: "{}" },
969+
});
970+
bridge.handle({ type: "inference.done", data: {} });
971+
bridge.submit("steer this", "steer");
972+
bridge.handle({
973+
type: "tool.done",
974+
data: { result: { callId: "c1", content: "ok", isError: false } },
975+
});
976+
bridge.handle({ type: "message.received", data: { message: { content: "steer this" } } });
977+
bridge.handle({ type: "inference.start", data: {} });
978+
bridge.handle({ type: "inference.thinking.delta", data: { token: "after steer" } });
979+
bridge.handle({ type: "inference.text.delta", data: { token: "done" } });
980+
981+
const rows = shell.streamLog.map((r) => `${r.meta ?? r.role}:${r.text}`);
982+
expect(rows.indexOf("thinking:planning")).toBeGreaterThan(-1);
983+
expect(rows.indexOf("steering:steer this")).toBeGreaterThan(
984+
rows.indexOf("thinking:planning"),
985+
);
986+
expect(rows.indexOf("thinking:after steer")).toBeGreaterThan(
987+
rows.indexOf("steering:steer this"),
988+
);
989+
expect(shell.streamLog.filter((r) => r.meta === "thinking")).toHaveLength(2);
990+
} finally {
991+
bridge.dispose();
992+
shell.dispose();
993+
}
994+
},
995+
{ width: 80, height: 24 },
996+
);
997+
});
998+
943999
test("interrupt then a new prompt keeps the prompt and the classified error", async () => {
9441000
await withTestRenderer(
9451001
async (h) => {

src/tui/runtime-bridge.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1078,7 +1078,13 @@ export function attachSessionBridge(
10781078
const settled = noteEvent(event);
10791079
// Reactor-shaped types always map first (avoids tool.done name collision).
10801080
if (PRODUCTION_REACTOR_TYPES.has(event.type)) {
1081-
if (consumePendingEchoEvent(bag, event)) return;
1081+
if (consumePendingEchoEvent(bag, event)) {
1082+
// The echo skips the mapper so it cannot expire a recovery handoff,
1083+
// but it still starts a new turn: the next reasoning gets its own row.
1084+
closeOpenRow(shell, bag);
1085+
bag.turnThinking = null;
1086+
return;
1087+
}
10821088
for (const mapped of mapProductionEvent(event as ReactorLikeEvent, bag.mapCtx)) {
10831089
applyInbound(shell, bag, mapped);
10841090
}

0 commit comments

Comments
 (0)