Skip to content

Commit 60dddd6

Browse files
egavrindevagent
andcommitted
fix(cli): avoid duplicate tui tool rows
- Keep single-shot turns live through completion instead of freezing stale running rows - Clear completed tool labels and avoid grouping sequential tool calls retroactively Co-Authored-By: devagent <devagent@egavrin>
1 parent 8db4981 commit 60dddd6

3 files changed

Lines changed: 139 additions & 14 deletions

File tree

packages/cli/src/tui/SingleShotApp.tsx

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -33,27 +33,25 @@ export function SingleShotApp({ bus, query, onQuery, model, onFinalOutput }: Sin
3333
const { exit } = useApp();
3434
const [running, setRunning] = useState(true);
3535
const hasStartedRef = useRef(false);
36+
const mountedRef = useRef(true);
3637

3738
const {
3839
transcriptNodes, status, subagents, spinnerMessage,
3940
startTurn, appendTurnPart, completeTurn, flushThinking, flushGroup, nextId,
4041
refs,
4142
} = useAgentLog({ bus, model });
4243

44+
useEffect(() => () => { mountedRef.current = false; }, []);
45+
4346
useEffect(() => {
44-
if (hasStartedRef.current) {
45-
return;
46-
}
47+
if (hasStartedRef.current) return;
4748
hasStartedRef.current = true;
48-
49-
let cancelled = false;
50-
5149
const runQuery = async (): Promise<void> => {
5250
refs.turnStart.current = Date.now(); refs.turnToolCount.current = 0; refs.costAccum.current = 0;
5351
startTurn(nextId("turn"), query, refs.turnStart.current);
5452
try {
5553
const result = await onQuery(query);
56-
if (cancelled) return;
54+
if (!mountedRef.current) return;
5755
flushThinking();
5856
flushGroup();
5957

@@ -63,16 +61,15 @@ export function SingleShotApp({ bus, query, onQuery, model, onFinalOutput }: Sin
6361
onFinalOutput(finalText);
6462
}
6563
const notice = noticeForSingleShotStatus(result.status);
66-
if (notice) {
67-
appendTurnPart(nextId("status"), makeInfoPart("status", [notice]));
68-
}
64+
if (notice) appendTurnPart(nextId("status"), makeInfoPart("status", [notice]));
6965

7066
completeTurn(
7167
nextId("summary"),
7268
makeTurnSummaryPart({ iterations: result.iterations, toolCalls: refs.turnToolCount.current, cost: refs.costAccum.current, elapsedMs: Date.now() - refs.turnStart.current }),
7369
{ status: turnStatusForQueryStatus(result.status), finishedAt: Date.now() },
7470
);
7571
} catch (err) {
72+
if (!mountedRef.current) return;
7673
appendTurnPart(nextId("e"), makeErrorPart({ message: err instanceof Error ? err.message : String(err), code: "QUERY_ERROR" }));
7774
completeTurn(
7875
nextId("summary"),
@@ -81,16 +78,15 @@ export function SingleShotApp({ bus, query, onQuery, model, onFinalOutput }: Sin
8178
);
8279
}
8380
setRunning(false);
84-
scheduleExit(() => cancelled, exit);
81+
scheduleExit(() => !mountedRef.current, exit);
8582
};
8683

8784
void runQuery();
88-
return () => { cancelled = true; };
8985
}, [appendTurnPart, completeTurn, exit, flushGroup, flushThinking, model, nextId, onFinalOutput, onQuery, query, refs, startTurn]);
9086

9187
return (
9288
<>
93-
<TranscriptView showWelcome={false} transcriptNodes={transcriptNodes} model={model} />
89+
<TranscriptView showWelcome={false} transcriptNodes={transcriptNodes} model={model} keepLatestNodeLive />
9490
{hasActiveSubagents(subagents) && <SubagentPanel agents={subagents} />}
9591
<Spinner active={running} message={spinnerMessage} suffix={status.cost > 0 ? `$${status.cost.toFixed(4)}` : ""} />
9692
<StatusBar {...status} />

packages/cli/src/tui/useAgentLog.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ function registerToolAfterEvent(
267267
): void {
268268
unsubs.push(runtime.bus.on("tool:after", (event) => {
269269
if (shouldSkipToolAfter(event)) return;
270+
runtime.setSpinnerMessage(undefined);
270271
if (runtime.collapseFailures && !event.result.success && event.name !== "execute_tool_script") {
271272
failureBuffer.count++;
272273
return;
@@ -295,7 +296,10 @@ function applyPendingToolResult(
295296
pendingGroup.totalMs += event.durationMs;
296297
pendingGroup.completed++;
297298
if (!event.result.success) pendingGroup.lastSuccess = false;
298-
if (pendingGroup.count === 1) addToolAfterParts(event, runtime);
299+
if (pendingGroup.count === 1) {
300+
addToolAfterParts(event, runtime);
301+
runtime.pendingGroupRef.current = null;
302+
}
299303
if (pendingGroup.count > 1 && pendingGroup.completed >= pendingGroup.count) {
300304
runtime.flushGroup();
301305
}
@@ -367,6 +371,7 @@ function registerApprovalAndSessionEvents(unsubs: Array<() => void>, runtime: Ag
367371
runtime.appendTurnPart(runtime.nextId("approval-response"), presentApprovalResponseEvent(event));
368372
}));
369373
unsubs.push(runtime.bus.on("session:end", () => {
374+
runtime.setSpinnerMessage(undefined);
370375
runtime.setSubagents(new Map());
371376
}));
372377
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { EventBus } from "@devagent/runtime";
2+
import React, { useEffect, useMemo } from "react";
3+
import { afterEach, describe, expect, it } from "vitest";
4+
5+
import { TranscriptView } from "./App.js";
6+
import {
7+
cleanupRenderedInstances,
8+
renderForTest,
9+
settle,
10+
stripAnsi,
11+
waitForRenders,
12+
} from "./App.test-utils.js";
13+
import { SingleShotApp } from "./SingleShotApp.js";
14+
import { useAgentLog } from "./useAgentLog.js";
15+
import { makeTurnSummaryPart } from "../transcript-presenter.js";
16+
17+
afterEach(cleanupRenderedInstances);
18+
19+
function emitTool(bus: EventBus, name: string, callId: string, durationMs: number): void {
20+
bus.emit("tool:before", {
21+
name,
22+
params: { pattern: "." },
23+
callId,
24+
});
25+
emitToolAfter(bus, name, callId, durationMs);
26+
}
27+
28+
function emitToolAfter(bus: EventBus, name: string, callId: string, durationMs: number): void {
29+
bus.emit("tool:after", {
30+
name,
31+
callId,
32+
durationMs,
33+
result: {
34+
success: true,
35+
output: "1 file(s) found",
36+
error: null,
37+
artifacts: [],
38+
},
39+
});
40+
}
41+
42+
function VisualHarness(
43+
{ mode, onSpinnerMessage }: {
44+
readonly mode: "grouped" | "sequential";
45+
readonly onSpinnerMessage?: (message: string | undefined) => void;
46+
},
47+
): React.ReactElement {
48+
const bus = useMemo(() => new EventBus(), []);
49+
const { transcriptNodes, spinnerMessage, startTurn, completeTurn, nextId } = useAgentLog({ bus, model: "test-model" });
50+
51+
useEffect(() => {
52+
onSpinnerMessage?.(spinnerMessage);
53+
}, [onSpinnerMessage, spinnerMessage]);
54+
55+
useEffect(() => {
56+
startTurn(nextId("turn"), mode === "grouped" ? "Check LSP" : "Find twice", Date.now());
57+
if (mode === "grouped") {
58+
bus.emit("tool:before", { name: "lsp", params: { operation: "diagnostics", path: "src/tmp.ts" }, callId: "call-1" });
59+
bus.emit("tool:before", { name: "lsp", params: { operation: "symbols", path: "src/tmp.ts" }, callId: "call-2" });
60+
emitToolAfter(bus, "lsp", "call-1", 1);
61+
emitToolAfter(bus, "lsp", "call-2", 9);
62+
} else {
63+
emitTool(bus, "find_files", "call-1", 50);
64+
emitTool(bus, "find_files", "call-2", 52);
65+
}
66+
completeTurn(nextId("summary"), makeTurnSummaryPart({ iterations: 1, toolCalls: 2, cost: 0, elapsedMs: 120 }));
67+
}, [bus, completeTurn, mode, nextId, startTurn]);
68+
69+
return React.createElement(TranscriptView, { showWelcome: false, transcriptNodes, model: "test-model" });
70+
}
71+
72+
describe("TUI visual regressions", () => {
73+
it("flushes grouped tool rows and clears the active tool label", async () => {
74+
const spinnerMessages: Array<string | undefined> = [];
75+
const view = renderForTest(React.createElement(VisualHarness, {
76+
mode: "grouped",
77+
onSpinnerMessage: (message) => spinnerMessages.push(message),
78+
}));
79+
80+
await settle();
81+
82+
const plain = stripAnsi(view.stdout.readAll());
83+
expect(plain).toContain("✓ lsp ×2");
84+
expect(spinnerMessages.at(-1)).toBeUndefined();
85+
});
86+
87+
it("does not add grouped summaries for sequential same-name tools", async () => {
88+
const view = renderForTest(React.createElement(VisualHarness, { mode: "sequential" }));
89+
90+
await settle();
91+
92+
const plain = stripAnsi(view.stdout.readAll());
93+
expect(plain).toContain("✓ find_files (50ms)");
94+
expect(plain).toContain("✓ find_files (52ms)");
95+
expect(plain).not.toContain("✓ find_files ×2");
96+
});
97+
98+
it("finishes single-shot turns after tool-event rerenders", async () => {
99+
const bus = new EventBus();
100+
let finalOutput: string | null = null;
101+
const view = renderForTest(React.createElement(SingleShotApp, {
102+
bus,
103+
query: "single shot tools",
104+
model: "test-model",
105+
onFinalOutput: (text) => { finalOutput = text; },
106+
onQuery: () => new Promise((resolve) => {
107+
emitTool(bus, "lsp", "call-lsp-1", 5);
108+
setTimeout(() => resolve({
109+
iterations: 1,
110+
toolCalls: 1,
111+
lastText: "LSP finished.",
112+
status: "success",
113+
}), 0);
114+
}),
115+
}), { columns: 180 });
116+
117+
await waitForRenders();
118+
119+
const output = stripAnsi(view.stdout.readAll());
120+
expect(output).toContain("╭─ completed single shot tools");
121+
expect(output).toContain("LSP finished.");
122+
expect(finalOutput).toBe("LSP finished.");
123+
});
124+
});

0 commit comments

Comments
 (0)