mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -127,6 +127,7 @@ export type TuiStateAccess = {
|
||||
currentSessionId: string | null;
|
||||
activeChatRunId: string | null;
|
||||
pendingOptimisticUserMessage?: boolean;
|
||||
pendingChatRunId?: string | null;
|
||||
queuedMessages?: QueuedMessage[];
|
||||
historyLoaded: boolean;
|
||||
sessionInfo: SessionInfo;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user