Skip to content

Commit 2f28a4a

Browse files
committed
Keep coalesced shell tails from being cleared by a silent sibling
1 parent df5e33a commit 2f28a4a

4 files changed

Lines changed: 111 additions & 3 deletions

File tree

src/tui/runtime-bridge.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1084,6 +1084,15 @@ function syncShellOutputs(
10841084
const key = preview.join("\n");
10851085
if (bag.shellSnapshots.get(callId) === key) continue;
10861086
bag.shellSnapshots.set(callId, key);
1087+
// Consecutive in-flight shells share a lane. An empty sibling snapshot
1088+
// must not clear a tail another member already painted.
1089+
if (
1090+
preview.length === 0 &&
1091+
row.previewLines !== undefined &&
1092+
row.previewLines.length > 0
1093+
) {
1094+
continue;
1095+
}
10871096
rowUpdates.scheduleRowUpdate(bag, index, { ...row, previewLines: preview });
10881097
}
10891098
}

src/tui/stream.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ export interface StreamRow {
158158
*/
159159
readonly callCount?: number;
160160
/**
161-
* Call ids a lane absorbed (newest appended, last 32 kept). Lets a result
161+
* Call ids a lane absorbed (newest appended). Lets a result
162162
* resolve its lane by id even though the lane's own `callId` moved to the
163163
* newest call.
164164
*/

src/tui/tool-rows.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,55 @@ describe("a live turn", () => {
699699
);
700700
});
701701

702+
test("a silent sibling does not clear a coalesced pending shell tail", async () => {
703+
await withTestRenderer(
704+
async (h) => {
705+
const shell = createAppShell(h.renderer, {
706+
terminal: { columns: 80, rows: 24 },
707+
wireKeys: false,
708+
run: "idle",
709+
});
710+
const bridge = attachSessionBridge(shell, createRecordingPort());
711+
try {
712+
bridge.play([
713+
{
714+
type: "inference.tool_call.end",
715+
data: {
716+
name: "run_shell",
717+
callId: "sh1",
718+
arguments: { command: "echo alpha" },
719+
},
720+
},
721+
{
722+
type: "inference.tool_call.end",
723+
data: {
724+
name: "run_shell",
725+
callId: "sh2",
726+
arguments: { command: "sleep 5; echo done" },
727+
},
728+
},
729+
]);
730+
await h.renderOnce();
731+
expect(shell.streamLog.length).toBe(1);
732+
expect(shell.streamLog[0]?.coalesced).toBe(true);
733+
expect(shell.streamLog[0]?.pending).toBe(true);
734+
735+
bridge.syncShellOutputs((callId) => {
736+
if (callId === "sh1") return liveFeed(() => "alpha-only\n");
737+
if (callId === "sh2") return liveFeed(() => "");
738+
return undefined;
739+
});
740+
await h.renderOnce();
741+
expect(shell.streamLog[0]?.previewLines).toEqual(["alpha-only"]);
742+
} finally {
743+
bridge.dispose();
744+
shell.dispose();
745+
}
746+
},
747+
{ width: 80, height: 24 },
748+
);
749+
});
750+
702751
test("rollbackAttempt drops shellSnapshots for truncated run_shell calls", async () => {
703752
await withTestRenderer(
704753
async (h) => {
@@ -902,4 +951,54 @@ describe("lane paint", () => {
902951
pushToolResult(ok, { name: "run_shell", content: "all good" });
903952
expect(ok[0]?.stat).toBeUndefined();
904953
});
954+
955+
test("a coalesced shell lane keeps each call's answer behind the arrow", () => {
956+
const rows: StreamRow[] = [];
957+
pushToolCall(rows, {
958+
name: "run_shell",
959+
arguments: JSON.stringify({ command: "echo a" }),
960+
callId: "s1",
961+
});
962+
pushToolCall(rows, {
963+
name: "run_shell",
964+
arguments: JSON.stringify({ command: "echo b" }),
965+
callId: "s2",
966+
});
967+
pushToolResult(rows, { name: "run_shell", content: "a", callId: "s1" });
968+
pushToolResult(rows, { name: "run_shell", content: "b", callId: "s2" });
969+
const answers = (rows[0]?.detail ?? []).map((line) =>
970+
line.map((segment) => segment.text).join(""),
971+
);
972+
expect(answers).not.toEqual(["answered", "answered"]);
973+
expect(answers).toContain("a");
974+
expect(rows[0]?.resultText).toBe("b");
975+
expect(rows[0]?.previewLines).toEqual(["b"]);
976+
});
977+
978+
test("a later zero-exit coalesced shell does not keep a leftover exit stat", () => {
979+
const rows: StreamRow[] = [];
980+
pushToolCall(rows, {
981+
name: "run_shell",
982+
arguments: JSON.stringify({ command: "false" }),
983+
callId: "s1",
984+
});
985+
pushToolCall(rows, {
986+
name: "run_shell",
987+
arguments: JSON.stringify({ command: "true" }),
988+
callId: "s2",
989+
});
990+
pushToolResult(rows, {
991+
name: "run_shell",
992+
content: "exit code 1\nboom",
993+
callId: "s1",
994+
});
995+
pushToolResult(rows, {
996+
name: "run_shell",
997+
content: "all good",
998+
callId: "s2",
999+
});
1000+
expect(rows[0]?.pending).toBeUndefined();
1001+
expect(collapsed(defined(rows[0]))).toContain("true");
1002+
expect(rows[0]?.stat).not.toBe("exit 1");
1003+
});
9051004
});

src/tui/tool-rows.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,16 +178,16 @@ export function mergeToolRows(call: StreamRow, result: StreamRow): StreamRow {
178178
// The most recent answer is the lane's copy source (Alt+C) and, for a
179179
// shell lane, the settle preview's source.
180180
resultText: result.text,
181-
...(call.stat !== undefined ? { stat: call.stat } : {}),
182181
...(isShell && shellStat !== undefined ? { stat: shellStat } : {}),
182+
...(!isShell && call.stat !== undefined ? { stat: call.stat } : {}),
183183
...(isShell
184184
? { previewLines: shellPreviewLines(previewSource) ?? [] }
185185
: {}),
186186
outstanding: remaining,
187187
...(remaining > 0 ? { pending: true } : {}),
188188
detail: appendRunLine(
189189
call.detail ?? [],
190-
failed ? "call failed" : (effAddendum ?? "answered"),
190+
failed ? "call failed" : (addendum ?? "answered"),
191191
),
192192
};
193193
}

0 commit comments

Comments
 (0)