From ca0935ae58fd916beb9a6b7b6b65cd96a40e5fd0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 19 Aug 2026 01:03:14 -0700 Subject: [PATCH] fix(ui): preserve active run ownership across steering (#126230) --- ...hat-flow.active-run-follow-ups.e2e.test.ts | 172 ++++++++++++++++++ ui/src/pages/chat/chat-send-delivery.ts | 21 ++- ui/src/pages/chat/chat-send.test.ts | 16 +- ui/src/pages/chat/chat-state.test.ts | 39 ++++ ui/src/pages/chat/session-message-apply.ts | 9 +- 5 files changed, 244 insertions(+), 13 deletions(-) diff --git a/ui/src/e2e/chat-flow.active-run-follow-ups.e2e.test.ts b/ui/src/e2e/chat-flow.active-run-follow-ups.e2e.test.ts index b1f059cfed85..57f468f7d4d7 100644 --- a/ui/src/e2e/chat-flow.active-run-follow-ups.e2e.test.ts +++ b/ui/src/e2e/chat-flow.active-run-follow-ups.e2e.test.ts @@ -5,6 +5,7 @@ import { expectRequestCountStable, installMockGateway, requireRecord, + requireString, waitForRequests, } from "./chat-flow.test-support.ts"; @@ -137,6 +138,177 @@ suite.define(() => { } }); + it("keeps the active run across a live steer operation", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const runId = "run-a"; + const gateway = await installMockGateway(page, { + historyMessages: [ + { + role: "user", + content: "run the long command", + __openclaw: { id: "user-a", idempotencyKey: `${runId}:user`, seq: 1 }, + }, + { + role: "assistant", + content: [{ type: "toolCall", id: "callExec", name: "exec", arguments: {} }], + __openclaw: { id: "exec-call", seq: 2 }, + }, + { + role: "toolResult", + toolCallId: "callExec", + toolName: "exec", + content: [{ type: "text", text: "process still running" }], + __openclaw: { id: "exec-result", seq: 3 }, + }, + ], + inFlightRun: { runId, text: "" }, + sessionInfo: { activeRunIds: [runId], hasActiveRun: true, key: "main" }, + }); + + try { + await page.goto(`${suite.server.baseUrl}settings/appearance`); + const configPatchesBefore = (await gateway.getRequests("config.patch")).length; + await page.locator("[data-settings-follow-up-mode]").selectOption("queue"); + await waitForRequests(gateway, "config.patch", configPatchesBefore + 1); + const shortcut = page.locator("[data-settings-send-shortcut]"); + await shortcut.selectOption("enter"); + expect(await shortcut.inputValue()).toBe("enter"); + await page.goto(`${suite.server.baseUrl}chat`); + + const composer = page.locator(".agent-chat__composer-combobox textarea"); + await page.locator(".chat-tool-msg-summary", { hasText: "Exec" }).waitFor(); + await page.getByRole("button", { name: "Stop generating" }).waitFor(); + let toolSequence = 0; + const emitTool = (data: Record) => + gateway.emitGatewayEvent("agent", { + data, + runId, + seq: ++toolSequence, + sessionKey: "main", + stream: "tool", + ts: Date.now(), + }); + + const steerText = "steer while the process runs"; + const sendsBeforeSteer = (await gateway.getRequests("chat.send")).length; + await gateway.deferNext("chat.send"); + await composer.fill(steerText); + await composer.press("Control+Enter"); + const steerSend = await gateway.waitForRequest("chat.send", { after: sendsBeforeSteer }); + const steerParams = requireRecord(steerSend.params); + expect(steerParams).toMatchObject({ + deliver: false, + message: steerText, + queueMode: "steer", + sessionKey: "main", + }); + expect(steerParams).not.toHaveProperty("expectedRunId"); + expect(steerParams).not.toHaveProperty("expectedLeafEntryId"); + const steerRunId = requireString( + steerParams.idempotencyKey, + "steer chat send idempotency key", + ); + await gateway.resolveDeferred("chat.send", { runId: steerRunId, status: "started" }); + const steerUser = { + __openclaw: { + id: "ui4-steer-user", + idempotencyKey: `${steerRunId}:user`, + seq: 4, + steerTargetRunId: runId, + }, + content: [{ text: steerText, type: "text" }], + role: "user", + timestamp: Date.now(), + }; + await gateway.deferNext("chat.history"); + await gateway.emitGatewayEvent("session.message", { + activeRunIds: [runId], + clientRunId: steerRunId, + hasActiveRun: true, + message: steerUser, + messageId: "ui4-steer-user", + messageSeq: 4, + session: { + activeRunIds: [runId], + hasActiveRun: true, + key: "main", + kind: "direct", + status: "running", + updatedAt: Date.now(), + }, + sessionKey: "main", + }); + await page.locator(".chat-group.user", { hasText: steerText }).waitFor(); + await gateway.emitGatewayEvent("chat", { + runId: steerRunId, + sessionKey: "main", + state: "final", + }); + + await emitTool({ + args: { action: "poll" }, + name: "process", + phase: "start", + toolCallId: "callProcess", + }); + await emitTool({ + name: "process", + phase: "result", + result: "process complete", + toolCallId: "callProcess", + }); + const finalText = "UI4_LONG_BASE UI4_STEER_OK"; + await gateway.emitGatewayEvent("chat", { + deltaText: finalText, + message: { + content: [{ text: finalText, type: "text" }], + role: "assistant", + timestamp: Date.now(), + }, + runId, + sessionKey: "main", + state: "delta", + }); + await gateway.emitGatewayEvent("session.message", { + activeRunIds: [runId], + clientRunId: runId, + hasActiveRun: true, + message: { + role: "assistant", + content: [{ text: finalText, type: "text" }], + __openclaw: { id: "ui4-final", seq: 5 }, + }, + messageId: "ui4-final", + messageSeq: 5, + session: { + activeRunIds: [runId], + hasActiveRun: true, + key: "main", + kind: "direct", + status: "running", + updatedAt: Date.now(), + }, + sessionKey: "main", + }); + await gateway.emitChatFinal({ runId, text: finalText }); + await expect + .poll(() => + page.locator(".chat-thread-inner").getByText(finalText, { exact: true }).count(), + ) + .toBe(1); + await expect + .poll(() => page.locator(".chat-work-group", { hasText: "used process" }).count()) + .toBe(0); + } finally { + await suite.closeBrowserContext(context); + } + }); + it("keeps modified Enter queued in modifier-enter shortcut mode", async () => { const context = await suite.newBrowserContext({ locale: "en-US", diff --git a/ui/src/pages/chat/chat-send-delivery.ts b/ui/src/pages/chat/chat-send-delivery.ts index 5b3563f6ae1f..99a526548c2b 100644 --- a/ui/src/pages/chat/chat-send-delivery.ts +++ b/ui/src/pages/chat/chat-send-delivery.ts @@ -381,15 +381,18 @@ async function sendQueuedChatMessage( }); void loadChatHistory(host); } else if (isNonTerminalAgentRunStatus(ack.status)) { - const adopted = host.chatRunId === ack.runId; - const adoptedStream = adopted && typeof host.chatStream === "string"; - host.chatRunId = ack.runId; - if (!adopted) { - host.chatRunStartup = null; - } - if (!adoptedStream) { - host.chatStream = ""; - host.chatStreamStartedAt = startedAt; + // A steer ACK identifies its client operation, not the active model run. + if (prepared.queueMode !== "steer" || !host.chatRunId) { + const adopted = host.chatRunId === ack.runId; + const adoptedStream = adopted && typeof host.chatStream === "string"; + host.chatRunId = ack.runId; + if (!adopted) { + host.chatRunStartup = null; + } + if (!adoptedStream) { + host.chatStream = ""; + host.chatStreamStartedAt = startedAt; + } } } } diff --git a/ui/src/pages/chat/chat-send.test.ts b/ui/src/pages/chat/chat-send.test.ts index acc6cceebfa2..64edc8d29684 100644 --- a/ui/src/pages/chat/chat-send.test.ts +++ b/ui/src/pages/chat/chat-send.test.ts @@ -3783,7 +3783,8 @@ describe("handleSendChat", () => { }), ), ); - expect(host.chatRunId).toBe("steer-run"); + expect(host.chatRunId).toBe("run-1"); + expect(host.chatStream).toBe("Working..."); expect(host.chatQueue).toEqual([ expect.objectContaining({ queueMode: "steer", @@ -3796,6 +3797,19 @@ describe("handleSendChat", () => { expect(payload).not.toHaveProperty("expectedLeafEntryId"); }); + it("adopts a steer-mode ACK when no run is active", async () => { + const host = makeChatHost({ + requestHandlers: { + "chat.send": { status: "started", runId: "started-run" }, + }, + chatMessage: "start through steer mode", + }); + + await handleSendChat(host, undefined, { followUpMode: "steer" }); + + expect(host.chatRunId).toBe("started-run"); + }); + it("sends a fresh mode-bearing row ahead of older outbox reconciliation", async () => { const older = { id: "older-reconciliation-head", diff --git a/ui/src/pages/chat/chat-state.test.ts b/ui/src/pages/chat/chat-state.test.ts index 5c9fc9761f01..ab6fc02b56f1 100644 --- a/ui/src/pages/chat/chat-state.test.ts +++ b/ui/src/pages/chat/chat-state.test.ts @@ -174,6 +174,7 @@ describe("canonical session message recovery", () => { ]); expect(state.chatRunId).toBe(activeRunId); expect(state.chatQueue).toEqual([]); + state.chatRunId = steerRunId; const steerEvent = { type: "event", @@ -198,6 +199,7 @@ describe("canonical session message recovery", () => { }, } satisfies Parameters[1]; handlePageGatewayEvent(state, steerEvent); + expect(state.chatRunId).toBe(activeRunId); const segmentsAfterRequestBoundary = state.chatStreamSegments; expect(state.chatStreamSegments).toBe(segmentsAfterRequestBoundary); expect( @@ -331,6 +333,43 @@ describe("canonical session message recovery", () => { ); }); + it("does not rebind an unrelated run from a persisted steer", () => { + const { state } = createSessionEventState({ + connected: false, + chatMessages: [], + chatRunId: "run-c", + chatStream: "Run C", + chatStreamSegments: [], + chatToolMessages: [], + }); + + handlePageGatewayEvent(state, { + type: "event", + event: "session.message", + payload: { + sessionKey: state.sessionKey, + clientRunId: "run-b", + hasActiveRun: true, + messageId: "steer-b", + messageSeq: 1, + message: { + role: "user", + content: "Steer A", + __openclaw: { + id: "steer-b", + idempotencyKey: "run-b:user", + seq: 1, + steerTargetRunId: "run-a", + }, + }, + }, + }); + + expect(state.chatRunId).toBe("run-c"); + expect(state.chatStream).toBe("Run C"); + expect(state.chatStreamSegments).toEqual([]); + }); + it("keeps pre-steer output above an earlier ordinary queued user", () => { const activeRunId = "active-run"; const originalPrompt = { diff --git a/ui/src/pages/chat/session-message-apply.ts b/ui/src/pages/chat/session-message-apply.ts index 8aea7fa3c153..b5e24c52f03a 100644 --- a/ui/src/pages/chat/session-message-apply.ts +++ b/ui/src/pages/chat/session-message-apply.ts @@ -72,16 +72,19 @@ export function applySessionMessagePayload( { type: "messagePersisted", message, envelope: event }, { scope, runActive }, ); + const steerTargetRunId = persistedSteerTargetRunId(message); + const currentRunId = state.chatRunId; if ( incoming.role === "user" && runActive === true && - state.chatRunId && incoming.runId && - persistedSteerTargetRunId(message) === state.chatRunId && + steerTargetRunId && + (!currentRunId || currentRunId === steerTargetRunId || currentRunId === incoming.runId) && projection.messages.length > previousMessageCount ) { + state.chatRunId = steerTargetRunId; rolloverChatStream(state, { - runId: state.chatRunId, + runId: steerTargetRunId, boundaryRunId: incoming.runId, }); }