Skip to content

Commit 2d4e5c9

Browse files
egavrindevagent
andcommitted
fix(cli): harden tui cancellation
- Preserve TUI cancellation requests during async query preparation - Reset cleared status limits and use single-shot retry guidance Co-Authored-By: devagent <devagent@egavrin>
1 parent ad72a0c commit 2d4e5c9

5 files changed

Lines changed: 247 additions & 10 deletions

File tree

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import {
2+
ApprovalGate,
3+
EventBus,
4+
SessionState,
5+
ToolRegistry,
6+
type DevAgentConfig,
7+
type LLMProvider,
8+
type StreamChunk,
9+
} from "@devagent/runtime";
10+
import { afterEach, describe, expect, it, vi } from "vitest";
11+
12+
import type { RunSingleQueryOptions } from "./main-types.js";
13+
14+
function createTestConfig(): DevAgentConfig {
15+
return {
16+
provider: "openai",
17+
model: "gpt-5",
18+
approval: { mode: "default" },
19+
budget: {
20+
maxIterations: 10,
21+
maxContextTokens: 100_000,
22+
responseHeadroom: 2_000,
23+
costWarningThreshold: 1.0,
24+
enableCostTracking: true,
25+
},
26+
context: { turnIsolation: false },
27+
logging: { enabled: false },
28+
} as unknown as DevAgentConfig;
29+
}
30+
31+
function deferred(): {
32+
readonly promise: Promise<void>;
33+
readonly resolve: () => void;
34+
} {
35+
let resolve!: () => void;
36+
const promise = new Promise<void>((res) => {
37+
resolve = res;
38+
});
39+
return { promise, resolve };
40+
}
41+
42+
function createRunOptions(provider: LLMProvider): RunSingleQueryOptions {
43+
const config = createTestConfig();
44+
const bus = new EventBus();
45+
return {
46+
query: "inspect",
47+
provider,
48+
toolRegistry: new ToolRegistry(),
49+
bus,
50+
gate: new ApprovalGate(config.approval, bus),
51+
config,
52+
repoRoot: "/tmp",
53+
mode: "act",
54+
skills: { list: () => [] } as RunSingleQueryOptions["skills"],
55+
contextManager: { setSummarizeCallback: () => {} } as RunSingleQueryOptions["contextManager"],
56+
doubleCheck: {} as RunSingleQueryOptions["doubleCheck"],
57+
initialMessages: undefined,
58+
verbosity: "quiet",
59+
verbosityConfig: { base: "quiet", categories: new Set() },
60+
sessionState: new SessionState(),
61+
};
62+
}
63+
64+
function createProvider(): { readonly provider: LLMProvider; readonly chat: ReturnType<typeof vi.fn>; readonly abort: ReturnType<typeof vi.fn> } {
65+
const chat = vi.fn(async function* (): AsyncIterable<StreamChunk> {
66+
yield { type: "text", content: "done" };
67+
yield { type: "done", content: "" };
68+
});
69+
const abort = vi.fn();
70+
return {
71+
chat,
72+
abort,
73+
provider: {
74+
id: "test-provider",
75+
chat,
76+
abort,
77+
},
78+
};
79+
}
80+
81+
describe("runTuiQuery cancellation", () => {
82+
afterEach(() => {
83+
vi.doUnmock("./prompt-commands.js");
84+
vi.resetModules();
85+
vi.restoreAllMocks();
86+
});
87+
88+
it("honors cancellation while first-turn query preparation is pending", async () => {
89+
const prep = deferred();
90+
vi.doMock("./prompt-commands.js", async (importOriginal) => {
91+
const actual = await importOriginal<typeof import("./prompt-commands.js")>();
92+
return {
93+
...actual,
94+
preparePromptCommandQuery: vi.fn(async () => {
95+
await prep.promise;
96+
return null;
97+
}),
98+
};
99+
});
100+
const { provider, chat, abort } = createProvider();
101+
const { runTuiQuery, abortTuiQuery, resetTuiLoop } = await import("./main-query.js");
102+
103+
resetTuiLoop();
104+
const run = runTuiQuery(createRunOptions(provider));
105+
await Promise.resolve();
106+
107+
abortTuiQuery();
108+
prep.resolve();
109+
110+
const result = await run;
111+
expect(abort).toHaveBeenCalledTimes(1);
112+
expect(chat).not.toHaveBeenCalled();
113+
expect(result.status).toBe("aborted");
114+
resetTuiLoop();
115+
});
116+
117+
it("preserves cancellation during query preparation for an existing TUI loop", async () => {
118+
const secondPrep = deferred();
119+
let prepareCallCount = 0;
120+
vi.doMock("./prompt-commands.js", async (importOriginal) => {
121+
const actual = await importOriginal<typeof import("./prompt-commands.js")>();
122+
return {
123+
...actual,
124+
preparePromptCommandQuery: vi.fn(async () => {
125+
prepareCallCount += 1;
126+
if (prepareCallCount === 2) await secondPrep.promise;
127+
return null;
128+
}),
129+
};
130+
});
131+
const { provider, chat } = createProvider();
132+
const { runTuiQuery, abortTuiQuery, resetTuiLoop } = await import("./main-query.js");
133+
134+
resetTuiLoop();
135+
await expect(runTuiQuery(createRunOptions(provider))).resolves.toMatchObject({ status: "success" });
136+
expect(chat).toHaveBeenCalledTimes(1);
137+
138+
const secondRun = runTuiQuery(createRunOptions(provider));
139+
await Promise.resolve();
140+
abortTuiQuery();
141+
secondPrep.resolve();
142+
143+
await expect(secondRun).resolves.toMatchObject({ status: "aborted" });
144+
expect(chat).toHaveBeenCalledTimes(1);
145+
resetTuiLoop();
146+
});
147+
});

packages/cli/src/main-query.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,6 @@ export function updateTuiSystemPrompt(prompt: string): void {
369369
}
370370

371371
export async function runTuiQuery(options: RunSingleQueryOptions): Promise<TaskLoopRunResult> {
372-
const preparedQuery = await prepareQueryForExecution(options.query, options.repoRoot);
373372
if (!tuiLoop) {
374373
const initialLoopOptions = createInitialTuiLoopOptions(options);
375374
setupSummarizeCallback(options.contextManager, options.provider, options.sessionState);
@@ -391,6 +390,7 @@ export async function runTuiQuery(options: RunSingleQueryOptions): Promise<TaskL
391390
} else {
392391
tuiLoop.resetIterations();
393392
}
393+
const preparedQuery = await prepareQueryForExecution(options.query, options.repoRoot);
394394
return tuiLoop.run(preparedQuery.query, {
395395
prependedMessages: preparedQuery.prependedMessages,
396396
finalTextValidator: preparedQuery.finalTextValidator,

packages/cli/src/tui/App.test.ts

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1136,17 +1136,32 @@ describe("interactive incomplete query status", () => {
11361136

11371137
describe("single-shot incomplete query status", () => {
11381138
it.each([
1139+
{
1140+
name: "budget exhausted run",
1141+
status: "budget_exceeded",
1142+
label: "budget",
1143+
noticeFragments: [
1144+
"Iteration limit exhausted before completion.",
1145+
"Re-run with a higher iteration limit or start interactive mode to",
1146+
"continue.",
1147+
],
1148+
},
11391149
{
11401150
name: "empty model response",
11411151
status: "empty_response",
1142-
notice: "Model returned no final response. Type /continue to retry",
1152+
label: "error",
1153+
noticeFragments: [
1154+
"Model returned no final response.",
1155+
"Re-run the command to retry, or switch provider/model if it repeats.",
1156+
],
11431157
},
11441158
{
11451159
name: "aborted run",
11461160
status: "aborted",
1147-
notice: "Run stopped before completion. Type /continue to retry",
1161+
label: "error",
1162+
noticeFragments: ["Run stopped before completion. Re-run the command to retry."],
11481163
},
1149-
] as const)("surfaces an $name as an incomplete turn", async ({ status, notice }) => {
1164+
] as const)("surfaces an $name as an incomplete turn", async ({ status, label, noticeFragments }) => {
11501165
let finalOutput: string | null = null;
11511166
const view = renderForTest(
11521167
React.createElement(SingleShotApp, {
@@ -1163,13 +1178,17 @@ describe("single-shot incomplete query status", () => {
11631178
status,
11641179
}),
11651180
}),
1181+
{ columns: 180 },
11661182
);
11671183

11681184
await waitForRenders();
11691185

11701186
const output = stripAnsi(view.stdout.readAll());
1171-
expect(output).toContain("╭─ error single shot");
1172-
expect(output).toContain(notice);
1187+
expect(output).toContain(`╭─ ${label} single shot`);
1188+
for (const fragment of noticeFragments) {
1189+
expect(output).toContain(fragment);
1190+
}
1191+
expect(output).not.toContain("/continue");
11731192
expect(output).not.toContain("╭─ completed single shot");
11741193
expect(finalOutput).toBeNull();
11751194
});
@@ -1311,6 +1330,57 @@ describe("interactive prompt commands", () => {
13111330
expect(clearedFrame).not.toContain("$0.123");
13121331
expect(clearedFrame).not.toContain("42k/100k");
13131332
expect(clearedFrame).not.toContain("iter 7/10");
1333+
expect(clearedFrame).not.toContain("100k");
1334+
expect(clearedFrame).not.toContain("iter");
1335+
});
1336+
1337+
it("fully resets status values when clearing with /clear", async () => {
1338+
let clearCalls = 0;
1339+
const bus = new EventBus();
1340+
const view = renderForTest(
1341+
React.createElement(App, {
1342+
bus,
1343+
model: "test-model",
1344+
approvalMode: "autopilot",
1345+
cwd: "/tmp/devagent",
1346+
onClear: () => {
1347+
clearCalls += 1;
1348+
},
1349+
onCycleApprovalMode: () => {},
1350+
onQuery: async () => ({
1351+
iterations: 0,
1352+
toolCalls: 0,
1353+
lastText: null,
1354+
status: "success" as const,
1355+
}),
1356+
}),
1357+
);
1358+
1359+
bus.emit("iteration:start", {
1360+
iteration: 7,
1361+
maxIterations: 10,
1362+
estimatedTokens: 42_000,
1363+
maxContextTokens: 100_000,
1364+
});
1365+
bus.emit("cost:update", {
1366+
inputTokens: 42_000,
1367+
outputTokens: 100,
1368+
totalCost: 0.1234,
1369+
model: "test-model",
1370+
});
1371+
await waitForRenders();
1372+
view.stdout.clear();
1373+
await typeAndSubmit(view.stdin, "/clear");
1374+
await waitForRenders();
1375+
1376+
const output = stripAnsi(view.stdout.readAll());
1377+
const clearedFrame = output.slice(output.lastIndexOf("Context cleared."));
1378+
expect(clearCalls).toBe(1);
1379+
expect(output).toContain("Context cleared.");
1380+
expect(clearedFrame).not.toContain("$0.123");
1381+
expect(clearedFrame).not.toContain("42k");
1382+
expect(clearedFrame).not.toContain("100k");
1383+
expect(clearedFrame).not.toContain("iter");
13141384
});
13151385
});
13161386

packages/cli/src/tui/App.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,14 @@ interface ClearSessionContext {
147147
function clearInteractiveSession(context: ClearSessionContext): void {
148148
context.onClear();
149149
context.clearSubagents();
150-
context.setStatus((status) => ({ ...status, cost: 0, iteration: 0, inputTokens: 0 }));
150+
context.setStatus((status) => ({
151+
...status,
152+
cost: 0,
153+
iteration: 0,
154+
inputTokens: 0,
155+
maxIterations: 0,
156+
maxContextTokens: 0,
157+
}));
151158
context.appendStandalonePart(context.nextId("clear"), makeInfoPart("info", ["Context cleared."]));
152159
}
153160

@@ -225,7 +232,7 @@ export function turnStatusForQueryStatus(status: InteractiveQueryResult["status"
225232
return "error";
226233
}
227234

228-
export function noticeForQueryStatus(status: InteractiveQueryResult["status"]): string | null {
235+
function noticeForQueryStatus(status: InteractiveQueryResult["status"]): string | null {
229236
if (status === "budget_exceeded") return ITERATION_LIMIT_NOTICE;
230237
if (status === "empty_response") return EMPTY_RESPONSE_NOTICE;
231238
if (status === "aborted") return ABORTED_NOTICE;

packages/cli/src/tui/SingleShotApp.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import { useApp } from "ink";
99
import React, { useEffect, useRef, useState } from "react";
1010

11-
import { TranscriptView, noticeForQueryStatus, turnStatusForQueryStatus } from "./App.js";
11+
import { TranscriptView, turnStatusForQueryStatus } from "./App.js";
1212
import type { InteractiveQueryResult } from "./shared.js";
1313
import { Spinner } from "./Spinner.js";
1414
import { StatusBar } from "./StatusBar.js";
@@ -64,7 +64,7 @@ export function SingleShotApp({ bus, query, onQuery, model, onFinalOutput }: Sin
6464
appendTurnPart(nextId("final"), { kind: "final-output", data: { text: finalText } });
6565
onFinalOutput(finalText);
6666
}
67-
const notice = noticeForQueryStatus(result.status);
67+
const notice = noticeForSingleShotStatus(result.status);
6868
if (notice) {
6969
appendTurnPart(nextId("status"), makeInfoPart("status", [notice]));
7070
}
@@ -102,3 +102,16 @@ export function SingleShotApp({ bus, query, onQuery, model, onFinalOutput }: Sin
102102
</>
103103
);
104104
}
105+
106+
function noticeForSingleShotStatus(status: InteractiveQueryResult["status"]): string | null {
107+
if (status === "budget_exceeded") {
108+
return "Iteration limit exhausted before completion. Re-run with a higher iteration limit or start interactive mode to continue.";
109+
}
110+
if (status === "empty_response") {
111+
return "Model returned no final response. Re-run the command to retry, or switch provider/model if it repeats.";
112+
}
113+
if (status === "aborted") {
114+
return "Run stopped before completion. Re-run the command to retry.";
115+
}
116+
return null;
117+
}

0 commit comments

Comments
 (0)