fix(ui): preserve active run ownership across steering (#126230)

This commit is contained in:
Peter Steinberger
2026-08-19 01:03:14 -07:00
committed by GitHub
parent c2b61a40be
commit ca0935ae58
5 changed files with 244 additions and 13 deletions
@@ -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<string, unknown>) =>
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",
+12 -9
View File
@@ -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;
}
}
}
}
+15 -1
View File
@@ -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",
+39
View File
@@ -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<typeof handlePageGatewayEvent>[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 = {
+6 -3
View File
@@ -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,
});
}