diff --git a/CHANGELOG.md b/CHANGELOG.md index d3e9ba6f274..a38ad9af887 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- 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. - 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. - 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. - 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. diff --git a/src/tui/tui-command-handlers.test.ts b/src/tui/tui-command-handlers.test.ts index b73c1393033..57a85a0eb8d 100644 --- a/src/tui/tui-command-handlers.test.ts +++ b/src/tui/tui-command-handlers.test.ts @@ -59,6 +59,7 @@ function createHarness(params?: { currentSessionId: params?.currentSessionId ?? null, activeChatRunId: params?.activeChatRunId ?? null, pendingOptimisticUserMessage: params?.pendingOptimisticUserMessage ?? false, + pendingChatRunId: null as string | null, isConnected: params?.isConnected ?? true, sessionInfo: {}, }; @@ -292,6 +293,29 @@ describe("tui command handlers", () => { expect(state.pendingOptimisticUserMessage).toBe(true); }); + it("tracks the in-flight runId so escape can abort during the wait", async () => { + const sendChat = vi.fn().mockResolvedValue({ runId: "ignored" }); + const { handleCommand, state } = createHarness({ sendChat }); + + await handleCommand("hello"); + + const sentRunId = (sendChat.mock.calls[0]?.[0] as { runId: string }).runId; + expect(typeof sentRunId).toBe("string"); + expect(sentRunId.length).toBeGreaterThan(0); + expect(state.activeChatRunId).toBeNull(); + expect(state.pendingChatRunId).toBe(sentRunId); + }); + + it("clears the pending runId if sendChat fails", async () => { + const sendChat = vi.fn().mockRejectedValue(new Error("boom")); + const { handleCommand, state } = createHarness({ sendChat }); + + await handleCommand("hello"); + + expect(state.pendingChatRunId).toBeNull(); + expect(state.pendingOptimisticUserMessage).toBe(false); + }); + it("sends /btw without hijacking the active main run", async () => { const setActivityStatus = vi.fn(); const { handleCommand, sendChat, addUser, noteLocalRunId, noteLocalBtwRunId, state } = diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index 21e3d2a61a4..378cbc05b50 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -642,6 +642,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { runId, }); if (!isBtw) { + state.pendingChatRunId = runId; setActivityStatus("waiting"); tui.requestRender(); } @@ -654,6 +655,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { } if (!isBtw) { state.pendingOptimisticUserMessage = false; + state.pendingChatRunId = null; state.activeChatRunId = null; } chatLog.addSystem(`${isBtw ? "btw failed" : "send failed"}: ${String(err)}`); diff --git a/src/tui/tui-event-handlers.test.ts b/src/tui/tui-event-handlers.test.ts index 9f94d0725fb..620c3779041 100644 --- a/src/tui/tui-event-handlers.test.ts +++ b/src/tui/tui-event-handlers.test.ts @@ -529,6 +529,26 @@ describe("tui-event-handlers: handleAgentEvent", () => { expect(loadHistory).not.toHaveBeenCalled(); }); + it("clears pendingChatRunId when an event for that runId arrives", () => { + const { state, handleChatEvent } = createHandlersHarness({ + state: { + activeChatRunId: null, + pendingOptimisticUserMessage: true, + pendingChatRunId: "run-pending", + }, + }); + + handleChatEvent({ + runId: "run-pending", + sessionKey: state.currentSessionKey, + state: "delta", + message: { content: "hi" }, + }); + + expect(state.pendingChatRunId).toBeNull(); + expect(state.activeChatRunId).toBe("run-pending"); + }); + function createConcurrentRunHarness(localContent = "partial") { const { state, chatLog, setActivityStatus, loadHistory, handleChatEvent } = createHandlersHarness({ diff --git a/src/tui/tui-event-handlers.ts b/src/tui/tui-event-handlers.ts index c57dd2139ad..852a6b24887 100644 --- a/src/tui/tui-event-handlers.ts +++ b/src/tui/tui-event-handlers.ts @@ -173,6 +173,7 @@ export function createEventHandlers(context: EventHandlerContext) { streamAssembler = new TuiStreamAssembler(); pendingHistoryRefresh = false; state.pendingOptimisticUserMessage = false; + state.pendingChatRunId = null; reconnectPendingRunId = null; clearLocalRunIds?.(); clearLocalBtwRunIds?.(); @@ -368,6 +369,9 @@ export function createEventHandlers(context: EventHandlerContext) { state.pendingOptimisticUserMessage = false; } } + if (state.pendingChatRunId === evt.runId) { + state.pendingChatRunId = null; + } if (evt.state === "delta") { // Arm watchdog and mark streaming on every delta, even when the visible // text hasn't changed yet (e.g. first commentary-only or tool-call delta). diff --git a/src/tui/tui-session-actions.test.ts b/src/tui/tui-session-actions.test.ts index 70db6de65a3..e8ede6deee6 100644 --- a/src/tui/tui-session-actions.test.ts +++ b/src/tui/tui-session-actions.test.ts @@ -338,6 +338,46 @@ describe("tui session actions", () => { expect(state.activeChatRunId).toBeNull(); }); + it("aborts the in-flight runId when only pendingChatRunId is set", async () => { + const abortChat = vi.fn().mockResolvedValue({ ok: true, aborted: true }); + const addSystem = vi.fn(); + const setActivityStatus = vi.fn(); + const state = createBaseState({ + activeChatRunId: null, + pendingChatRunId: "run-pending", + }); + + const { abortActive } = createSessionActions({ + client: { listSessions: vi.fn(), abortChat } as unknown as TuiBackend, + chatLog: { + addSystem, + clearAll: vi.fn(), + } as unknown as import("./components/chat-log.js").ChatLog, + btw: createBtwPresenter(), + tui: { requestRender: vi.fn() } as unknown as import("@mariozechner/pi-tui").TUI, + opts: {}, + state, + agentNames: new Map(), + initialSessionInput: "", + initialSessionAgentId: null, + resolveSessionKey: vi.fn((raw?: string) => raw ?? "agent:main:main"), + updateHeader: vi.fn(), + updateFooter: vi.fn(), + updateAutocompleteProvider: vi.fn(), + setActivityStatus, + }); + + await abortActive(); + + expect(abortChat).toHaveBeenCalledWith({ + sessionKey: "agent:main:main", + runId: "run-pending", + }); + expect(addSystem).not.toHaveBeenCalledWith("no active run"); + expect(state.pendingChatRunId).toBeNull(); + expect(setActivityStatus).toHaveBeenCalledWith("aborted"); + }); + it("remembers the selected session after history loads", async () => { const listSessions = vi.fn().mockResolvedValue({ ts: Date.now(), diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts index 5967a2876b5..a7e3d12e416 100644 --- a/src/tui/tui-session-actions.ts +++ b/src/tui/tui-session-actions.ts @@ -377,6 +377,7 @@ export function createSessionActions(context: SessionActionContext) { updateAgentFromSessionKey(nextKey); state.currentSessionKey = nextKey; state.activeChatRunId = null; + state.pendingChatRunId = null; setActivityStatus("idle"); state.currentSessionId = null; // Session keys can move backwards in updatedAt ordering; drop previous session freshness @@ -391,7 +392,8 @@ export function createSessionActions(context: SessionActionContext) { }; const abortActive = async () => { - if (!state.activeChatRunId) { + const runId = state.activeChatRunId ?? state.pendingChatRunId ?? null; + if (!runId) { chatLog.addSystem("no active run"); tui.requestRender(); return; @@ -399,8 +401,9 @@ export function createSessionActions(context: SessionActionContext) { try { await client.abortChat({ sessionKey: state.currentSessionKey, - runId: state.activeChatRunId, + runId, }); + state.pendingChatRunId = null; setActivityStatus("aborted"); } catch (err) { chatLog.addSystem(`abort failed: ${String(err)}`); diff --git a/src/tui/tui-types.ts b/src/tui/tui-types.ts index 1ef7efb73ab..c1df313634c 100644 --- a/src/tui/tui-types.ts +++ b/src/tui/tui-types.ts @@ -127,6 +127,7 @@ export type TuiStateAccess = { currentSessionId: string | null; activeChatRunId: string | null; pendingOptimisticUserMessage?: boolean; + pendingChatRunId?: string | null; queuedMessages?: QueuedMessage[]; historyLoaded: boolean; sessionInfo: SessionInfo; diff --git a/src/tui/tui.ts b/src/tui/tui.ts index b9836c0aea2..0c6fd0df84d 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -317,6 +317,7 @@ export async function runTui(opts: RunTuiOptions): Promise { let currentSessionId: string | null = null; let activeChatRunId: string | null = null; let pendingOptimisticUserMessage = false; + let pendingChatRunId: string | null = null; let historyLoaded = false; let isConnected = false; let wasDisconnected = false; @@ -395,6 +396,12 @@ export async function runTui(opts: RunTuiOptions): Promise { set pendingOptimisticUserMessage(value) { pendingOptimisticUserMessage = value; }, + get pendingChatRunId() { + return pendingChatRunId; + }, + set pendingChatRunId(value) { + pendingChatRunId = value ?? null; + }, get historyLoaded() { return historyLoaded; },