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
This commit is contained in:
Dallin Romney
2026-05-04 21:36:52 +08:00
committed by GitHub
parent a90be474f4
commit 5f373ae4d3
9 changed files with 104 additions and 2 deletions

View File

@@ -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.

View File

@@ -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 } =

View File

@@ -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)}`);

View File

@@ -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({

View File

@@ -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).

View File

@@ -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(),

View File

@@ -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)}`);

View File

@@ -127,6 +127,7 @@ export type TuiStateAccess = {
currentSessionId: string | null;
activeChatRunId: string | null;
pendingOptimisticUserMessage?: boolean;
pendingChatRunId?: string | null;
queuedMessages?: QueuedMessage[];
historyLoaded: boolean;
sessionInfo: SessionInfo;

View File

@@ -317,6 +317,7 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
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<TuiResult> {
set pendingOptimisticUserMessage(value) {
pendingOptimisticUserMessage = value;
},
get pendingChatRunId() {
return pendingChatRunId;
},
set pendingChatRunId(value) {
pendingChatRunId = value ?? null;
},
get historyLoaded() {
return historyLoaded;
},