Skip to content

Commit 5f373ae

Browse files
authored
fix(tui): abort run during pre-event waiting gap (#77199)
* fix(tui): abort run during pre-event waiting gap Track the runId returned from chat.send so pressing Esc while `activeChatRunId` is still null aborts the in-flight run instead of repeatedly printing "no active run". Identified in #1296. * fix(tui): drop redundant comment on pendingChatRunId set
1 parent a90be47 commit 5f373ae

9 files changed

Lines changed: 104 additions & 2 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ Docs: https://docs.openclaw.ai
6464

6565
### Fixes
6666

67+
- TUI/escape abort: track the in-flight runId after `chat.send` resolves so pressing Esc during the gap before the first gateway event aborts the run instead of repeatedly printing `no active run`. Fixes #1296. Thanks @Lukavyi and @romneyda.
6768
- Gateway/status: label Linux managed gateway services as `systemd user`, making status output explicit about the user-service scope instead of implying a system-level unit. Thanks @vincentkoc.
6869
- Plugins/install: remove the previous managed plugin directory when a reinstall switches sources, so stale ClawHub and npm copies no longer keep duplicate plugin ids in discovery after the new install wins. Thanks @vincentkoc.
6970
- Plugins/install: let official plugin reinstall recovery repair source-only installed runtime shadows, so `openclaw plugins install npm:@openclaw/discord --force` can replace the bad package instead of stopping at stale config validation. Thanks @vincentkoc.

src/tui/tui-command-handlers.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ function createHarness(params?: {
5959
currentSessionId: params?.currentSessionId ?? null,
6060
activeChatRunId: params?.activeChatRunId ?? null,
6161
pendingOptimisticUserMessage: params?.pendingOptimisticUserMessage ?? false,
62+
pendingChatRunId: null as string | null,
6263
isConnected: params?.isConnected ?? true,
6364
sessionInfo: {},
6465
};
@@ -292,6 +293,29 @@ describe("tui command handlers", () => {
292293
expect(state.pendingOptimisticUserMessage).toBe(true);
293294
});
294295

296+
it("tracks the in-flight runId so escape can abort during the wait", async () => {
297+
const sendChat = vi.fn().mockResolvedValue({ runId: "ignored" });
298+
const { handleCommand, state } = createHarness({ sendChat });
299+
300+
await handleCommand("hello");
301+
302+
const sentRunId = (sendChat.mock.calls[0]?.[0] as { runId: string }).runId;
303+
expect(typeof sentRunId).toBe("string");
304+
expect(sentRunId.length).toBeGreaterThan(0);
305+
expect(state.activeChatRunId).toBeNull();
306+
expect(state.pendingChatRunId).toBe(sentRunId);
307+
});
308+
309+
it("clears the pending runId if sendChat fails", async () => {
310+
const sendChat = vi.fn().mockRejectedValue(new Error("boom"));
311+
const { handleCommand, state } = createHarness({ sendChat });
312+
313+
await handleCommand("hello");
314+
315+
expect(state.pendingChatRunId).toBeNull();
316+
expect(state.pendingOptimisticUserMessage).toBe(false);
317+
});
318+
295319
it("sends /btw without hijacking the active main run", async () => {
296320
const setActivityStatus = vi.fn();
297321
const { handleCommand, sendChat, addUser, noteLocalRunId, noteLocalBtwRunId, state } =

src/tui/tui-command-handlers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,7 @@ export function createCommandHandlers(context: CommandHandlerContext) {
642642
runId,
643643
});
644644
if (!isBtw) {
645+
state.pendingChatRunId = runId;
645646
setActivityStatus("waiting");
646647
tui.requestRender();
647648
}
@@ -654,6 +655,7 @@ export function createCommandHandlers(context: CommandHandlerContext) {
654655
}
655656
if (!isBtw) {
656657
state.pendingOptimisticUserMessage = false;
658+
state.pendingChatRunId = null;
657659
state.activeChatRunId = null;
658660
}
659661
chatLog.addSystem(`${isBtw ? "btw failed" : "send failed"}: ${String(err)}`);

src/tui/tui-event-handlers.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,26 @@ describe("tui-event-handlers: handleAgentEvent", () => {
529529
expect(loadHistory).not.toHaveBeenCalled();
530530
});
531531

532+
it("clears pendingChatRunId when an event for that runId arrives", () => {
533+
const { state, handleChatEvent } = createHandlersHarness({
534+
state: {
535+
activeChatRunId: null,
536+
pendingOptimisticUserMessage: true,
537+
pendingChatRunId: "run-pending",
538+
},
539+
});
540+
541+
handleChatEvent({
542+
runId: "run-pending",
543+
sessionKey: state.currentSessionKey,
544+
state: "delta",
545+
message: { content: "hi" },
546+
});
547+
548+
expect(state.pendingChatRunId).toBeNull();
549+
expect(state.activeChatRunId).toBe("run-pending");
550+
});
551+
532552
function createConcurrentRunHarness(localContent = "partial") {
533553
const { state, chatLog, setActivityStatus, loadHistory, handleChatEvent } =
534554
createHandlersHarness({

src/tui/tui-event-handlers.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ export function createEventHandlers(context: EventHandlerContext) {
173173
streamAssembler = new TuiStreamAssembler();
174174
pendingHistoryRefresh = false;
175175
state.pendingOptimisticUserMessage = false;
176+
state.pendingChatRunId = null;
176177
reconnectPendingRunId = null;
177178
clearLocalRunIds?.();
178179
clearLocalBtwRunIds?.();
@@ -368,6 +369,9 @@ export function createEventHandlers(context: EventHandlerContext) {
368369
state.pendingOptimisticUserMessage = false;
369370
}
370371
}
372+
if (state.pendingChatRunId === evt.runId) {
373+
state.pendingChatRunId = null;
374+
}
371375
if (evt.state === "delta") {
372376
// Arm watchdog and mark streaming on every delta, even when the visible
373377
// text hasn't changed yet (e.g. first commentary-only or tool-call delta).

src/tui/tui-session-actions.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,46 @@ describe("tui session actions", () => {
338338
expect(state.activeChatRunId).toBeNull();
339339
});
340340

341+
it("aborts the in-flight runId when only pendingChatRunId is set", async () => {
342+
const abortChat = vi.fn().mockResolvedValue({ ok: true, aborted: true });
343+
const addSystem = vi.fn();
344+
const setActivityStatus = vi.fn();
345+
const state = createBaseState({
346+
activeChatRunId: null,
347+
pendingChatRunId: "run-pending",
348+
});
349+
350+
const { abortActive } = createSessionActions({
351+
client: { listSessions: vi.fn(), abortChat } as unknown as TuiBackend,
352+
chatLog: {
353+
addSystem,
354+
clearAll: vi.fn(),
355+
} as unknown as import("./components/chat-log.js").ChatLog,
356+
btw: createBtwPresenter(),
357+
tui: { requestRender: vi.fn() } as unknown as import("@mariozechner/pi-tui").TUI,
358+
opts: {},
359+
state,
360+
agentNames: new Map(),
361+
initialSessionInput: "",
362+
initialSessionAgentId: null,
363+
resolveSessionKey: vi.fn((raw?: string) => raw ?? "agent:main:main"),
364+
updateHeader: vi.fn(),
365+
updateFooter: vi.fn(),
366+
updateAutocompleteProvider: vi.fn(),
367+
setActivityStatus,
368+
});
369+
370+
await abortActive();
371+
372+
expect(abortChat).toHaveBeenCalledWith({
373+
sessionKey: "agent:main:main",
374+
runId: "run-pending",
375+
});
376+
expect(addSystem).not.toHaveBeenCalledWith("no active run");
377+
expect(state.pendingChatRunId).toBeNull();
378+
expect(setActivityStatus).toHaveBeenCalledWith("aborted");
379+
});
380+
341381
it("remembers the selected session after history loads", async () => {
342382
const listSessions = vi.fn().mockResolvedValue({
343383
ts: Date.now(),

src/tui/tui-session-actions.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,7 @@ export function createSessionActions(context: SessionActionContext) {
377377
updateAgentFromSessionKey(nextKey);
378378
state.currentSessionKey = nextKey;
379379
state.activeChatRunId = null;
380+
state.pendingChatRunId = null;
380381
setActivityStatus("idle");
381382
state.currentSessionId = null;
382383
// Session keys can move backwards in updatedAt ordering; drop previous session freshness
@@ -391,16 +392,18 @@ export function createSessionActions(context: SessionActionContext) {
391392
};
392393

393394
const abortActive = async () => {
394-
if (!state.activeChatRunId) {
395+
const runId = state.activeChatRunId ?? state.pendingChatRunId ?? null;
396+
if (!runId) {
395397
chatLog.addSystem("no active run");
396398
tui.requestRender();
397399
return;
398400
}
399401
try {
400402
await client.abortChat({
401403
sessionKey: state.currentSessionKey,
402-
runId: state.activeChatRunId,
404+
runId,
403405
});
406+
state.pendingChatRunId = null;
404407
setActivityStatus("aborted");
405408
} catch (err) {
406409
chatLog.addSystem(`abort failed: ${String(err)}`);

src/tui/tui-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ export type TuiStateAccess = {
127127
currentSessionId: string | null;
128128
activeChatRunId: string | null;
129129
pendingOptimisticUserMessage?: boolean;
130+
pendingChatRunId?: string | null;
130131
queuedMessages?: QueuedMessage[];
131132
historyLoaded: boolean;
132133
sessionInfo: SessionInfo;

src/tui/tui.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,7 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
317317
let currentSessionId: string | null = null;
318318
let activeChatRunId: string | null = null;
319319
let pendingOptimisticUserMessage = false;
320+
let pendingChatRunId: string | null = null;
320321
let historyLoaded = false;
321322
let isConnected = false;
322323
let wasDisconnected = false;
@@ -395,6 +396,12 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
395396
set pendingOptimisticUserMessage(value) {
396397
pendingOptimisticUserMessage = value;
397398
},
399+
get pendingChatRunId() {
400+
return pendingChatRunId;
401+
},
402+
set pendingChatRunId(value) {
403+
pendingChatRunId = value ?? null;
404+
},
398405
get historyLoaded() {
399406
return historyLoaded;
400407
},

0 commit comments

Comments
 (0)