mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
fix(ui): preserve active run ownership across steering (#126230)
This commit is contained in:
committed by
GitHub
parent
c2b61a40be
commit
ca0935ae58
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user