diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index 9855a2a7dfc8..59ca44ba5400 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -4178,7 +4178,6 @@ ui/src/pages/chat/chat-progress.ts 2 ui/src/pages/chat/chat-queue.ts 1 ui/src/pages/chat/chat-realtime.ts 1 ui/src/pages/chat/chat-send-ack.ts 2 -ui/src/pages/chat/chat-send-actions.ts 2 ui/src/pages/chat/chat-send-composer.ts 1 ui/src/pages/chat/chat-send-request.ts 1 ui/src/pages/chat/chat-send-timing.ts 6 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 7c38c6e65b93..b1f059cfed85 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 @@ -1,4 +1,3 @@ -import type { Page } from "playwright"; import { expect, it } from "vitest"; import { chatSessionListResponse, @@ -6,315 +5,12 @@ import { expectRequestCountStable, installMockGateway, requireRecord, - requireString, waitForRequests, } from "./chat-flow.test-support.ts"; const suite = createChatFlowE2eSuite(); -async function expectChatBubbleAbove(page: Page, upperText: string, lowerText: string) { - const thread = page.locator(".chat-thread-inner"); - await expect - .poll(() => - thread.evaluate( - (element, texts) => { - const bubbles = Array.from(element.querySelectorAll(".chat-bubble")); - const matches = texts.map((text) => - bubbles.filter((bubble) => bubble.textContent?.includes(text)), - ); - const counts = matches.map((matchingBubbles) => matchingBubbles.length); - const upperBubble = matches[0]?.[0]; - const lowerBubble = matches[1]?.[0]; - if (counts.some((count) => count !== 1) || !upperBubble || !lowerBubble) { - return { counts, lowerTop: null, ordered: false, upperTop: null }; - } - const upperTop = upperBubble.getBoundingClientRect().top; - const lowerTop = lowerBubble.getBoundingClientRect().top; - return { counts, lowerTop, ordered: upperTop < lowerTop, upperTop }; - }, - [upperText, lowerText], - ), - ) - .toEqual({ - counts: [1, 1], - lowerTop: expect.any(Number), - ordered: true, - upperTop: expect.any(Number), - }); -} - suite.define(() => { - it("keeps cumulative assistant output ordered across a consumed steer", async () => { - const context = await suite.newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const runtimeConfig = { - messages: { queue: { byChannel: { webchat: "steer" }, mode: "followup" } }, - }; - const gateway = await installMockGateway(page, { - methodResponses: { - "config.get": { - config: runtimeConfig, - hash: "queue-steer-config", - issues: [], - raw: JSON.stringify(runtimeConfig), - runtimeConfig, - valid: true, - }, - }, - }); - - try { - await page.goto(`${suite.server.baseUrl}chat`); - - const originalPrompt = "keep this run active"; - await page.locator(".agent-chat__composer-combobox textarea").fill(originalPrompt); - await page.getByRole("button", { name: "Send message" }).click(); - const initialSend = await gateway.waitForRequest("chat.send"); - const activeRunId = requireString( - requireRecord(initialSend.params).idempotencyKey, - "active chat run id", - ); - await gateway.emitGatewayEvent("session.message", { - activeRunIds: [activeRunId], - clientRunId: activeRunId, - hasActiveRun: true, - message: { - __openclaw: { - id: "persisted-original-user", - idempotencyKey: `${activeRunId}:user`, - seq: 1, - }, - content: [{ text: originalPrompt, type: "text" }], - role: "user", - timestamp: Date.now(), - }, - messageId: "persisted-original-user", - messageSeq: 1, - session: { - activeRunIds: [activeRunId], - hasActiveRun: true, - key: "main", - kind: "direct", - status: "running", - updatedAt: Date.now(), - }, - sessionKey: "main", - }); - await page.getByRole("button", { name: "Stop generating" }).waitFor({ timeout: 10_000 }); - - const preSteerReply = "Assistant output before the steer."; - await gateway.emitGatewayEvent("chat", { - deltaText: preSteerReply, - message: { - content: [{ text: preSteerReply, type: "text" }], - role: "assistant", - timestamp: Date.now(), - }, - runId: activeRunId, - sessionKey: "main", - state: "delta", - }); - await page.getByText(preSteerReply, { exact: true }).waitFor({ timeout: 10_000 }); - - const queuedFollowUp = "queued follow-up before steer"; - await gateway.emitGatewayEvent("session.message", { - activeRunIds: [activeRunId], - clientRunId: "queued-run", - hasActiveRun: true, - message: { - __openclaw: { - id: "persisted-queued-user", - idempotencyKey: "queued-run:user", - seq: 3, - }, - content: [{ text: queuedFollowUp, type: "text" }], - role: "user", - timestamp: Date.now(), - }, - messageId: "persisted-queued-user", - messageSeq: 3, - session: { - activeRunIds: [activeRunId], - hasActiveRun: true, - key: "main", - kind: "direct", - status: "running", - updatedAt: Date.now(), - }, - sessionKey: "main", - }); - await page.getByText(queuedFollowUp, { exact: true }).waitFor({ timeout: 10_000 }); - await expectChatBubbleAbove(page, preSteerReply, queuedFollowUp); - - const followUp = "tighten the active plan"; - await page.locator(".agent-chat__composer-combobox textarea").fill(followUp); - await page.getByRole("button", { name: "Steer into the active run" }).click(); - - const sends = await waitForRequests(gateway, "chat.send", 2); - const steerParams = requireRecord(sends[1]?.params); - expect(steerParams).toMatchObject({ - deliver: false, - message: followUp, - sessionKey: "main", - }); - const steerRunId = requireString(steerParams.idempotencyKey, "steer run id"); - const queue = page.locator(".chat-queue"); - await queue.locator(".chat-queue__badge--steered", { hasText: "Steering" }).waitFor({ - timeout: 10_000, - }); - await queue.getByText(followUp).waitFor({ timeout: 10_000 }); - await gateway.emitGatewayEvent("chat", { - runId: steerRunId, - sessionKey: "main", - state: "final", - }); - await queue.getByText(followUp).waitFor({ state: "detached", timeout: 10_000 }); - await expect - .poll(() => page.locator(".chat-thread .chat-group.user", { hasText: followUp }).count()) - .toBe(1); - await expectChatBubbleAbove(page, originalPrompt, preSteerReply); - await expectChatBubbleAbove(page, preSteerReply, queuedFollowUp); - await expectChatBubbleAbove(page, queuedFollowUp, followUp); - - await gateway.emitGatewayEvent("session.message", { - activeRunIds: [activeRunId], - clientRunId: activeRunId, - hasActiveRun: true, - message: { - __openclaw: { - id: "persisted-steer-user", - idempotencyKey: `${steerRunId}:user`, - seq: 4, - steerTargetRunId: activeRunId, - }, - content: [{ text: followUp, type: "text" }], - role: "user", - timestamp: Date.now(), - }, - messageId: "persisted-steer-user", - messageSeq: 4, - session: { - activeRunIds: [activeRunId], - hasActiveRun: true, - key: "main", - kind: "direct", - status: "running", - updatedAt: Date.now(), - }, - sessionKey: "main", - }); - - await expect - .poll(() => page.locator(".chat-thread .chat-group.user", { hasText: followUp }).count()) - .toBe(1); - const postSteerReply = "Assistant output after the steer."; - const cumulativeReply = `${preSteerReply} ${postSteerReply}`; - const terminalPostSteerReply = `${postSteerReply} Final unseen suffix.`; - const terminalReply = `${preSteerReply} ${terminalPostSteerReply}`; - await gateway.emitGatewayEvent("chat", { - deltaText: ` ${postSteerReply}`, - message: { - content: [{ text: cumulativeReply, type: "text" }], - role: "assistant", - timestamp: Date.now(), - }, - runId: activeRunId, - sessionKey: "main", - state: "delta", - }); - await page.locator(".chat-bubble", { hasText: postSteerReply }).waitFor({ timeout: 10_000 }); - await expectChatBubbleAbove(page, originalPrompt, preSteerReply); - await expectChatBubbleAbove(page, preSteerReply, queuedFollowUp); - await expectChatBubbleAbove(page, queuedFollowUp, followUp); - await expectChatBubbleAbove(page, followUp, postSteerReply); - const authoritativeMessages = [ - { - __openclaw: { - id: "persisted-original-user", - idempotencyKey: `${activeRunId}:user`, - seq: 1, - }, - content: [{ text: originalPrompt, type: "text" }], - role: "user", - timestamp: 100, - }, - { - __openclaw: { id: "persisted-pre-steer", idempotencyKey: activeRunId, seq: 2 }, - content: [{ text: preSteerReply, type: "text" }], - role: "assistant", - timestamp: 200, - }, - { - __openclaw: { - id: "persisted-queued-user", - idempotencyKey: "queued-run:user", - seq: 3, - }, - content: [{ text: queuedFollowUp, type: "text" }], - role: "user", - timestamp: 250, - }, - { - __openclaw: { - id: "persisted-steer-user", - idempotencyKey: `${steerRunId}:user`, - seq: 4, - steerTargetRunId: activeRunId, - }, - content: [{ text: followUp, type: "text" }], - role: "user", - timestamp: 50, - }, - { - __openclaw: { id: "persisted-post-steer", idempotencyKey: activeRunId, seq: 5 }, - content: [{ text: terminalPostSteerReply, type: "text" }], - role: "assistant", - timestamp: 300, - }, - ]; - const terminalHistory = { - messages: authoritativeMessages, - sessionId: "control-ui-e2e-session", - sessionInfo: { - activeRunIds: [], - hasActiveRun: false, - key: "main", - kind: "direct", - status: "done", - updatedAt: Date.now(), - }, - thinkingLevel: null, - }; - await gateway.setMethodResponse("chat.history", terminalHistory); - await gateway.setMethodResponse("chat.startup", terminalHistory); - await gateway.emitChatFinal({ runId: activeRunId, text: terminalReply }); - await page - .getByRole("button", { name: "Stop generating" }) - .waitFor({ state: "detached", timeout: 10_000 }); - await page - .locator(".chat-bubble", { hasText: terminalPostSteerReply }) - .waitFor({ timeout: 10_000 }); - await expectChatBubbleAbove(page, preSteerReply, queuedFollowUp); - await expectChatBubbleAbove(page, queuedFollowUp, followUp); - await expectChatBubbleAbove(page, followUp, postSteerReply); - - await page.reload(); - await page - .locator(".chat-bubble", { hasText: terminalPostSteerReply }) - .waitFor({ timeout: 10_000 }); - await expectChatBubbleAbove(page, originalPrompt, preSteerReply); - await expectChatBubbleAbove(page, preSteerReply, queuedFollowUp); - await expectChatBubbleAbove(page, queuedFollowUp, followUp); - await expectChatBubbleAbove(page, followUp, postSteerReply); - } finally { - await suite.closeBrowserContext(context); - } - }); - it("preserves a non-steer server default for active-run follow-ups", async () => { const context = await suite.newBrowserContext({ locale: "en-US", @@ -419,8 +115,7 @@ suite.define(() => { const composer = page.locator(".agent-chat__composer-combobox textarea"); await composer.fill("keep the first shortcut run active"); await page.getByRole("button", { name: "Send message" }).click(); - const firstSend = requireRecord((await gateway.waitForRequest("chat.send")).params); - const firstRunId = requireString(firstSend.idempotencyKey, "first active run id"); + await gateway.waitForRequest("chat.send"); await page.getByRole("button", { name: "Stop generating" }).waitFor({ timeout: 10_000 }); const steerText = "steer this keyboard follow-up now"; @@ -431,13 +126,12 @@ suite.define(() => { const steerParams = requireRecord(firstRunSends[1]?.params); expect(steerParams).toMatchObject({ deliver: false, - expectedRunId: firstRunId, message: steerText, queueMode: "steer", sessionKey: "main", }); - const steeredRow = page.locator(".chat-queue__item--steered", { hasText: steerText }); - await steeredRow.waitFor({ timeout: 10_000 }); + expect(steerParams).not.toHaveProperty("expectedRunId"); + expect(steerParams).not.toHaveProperty("expectedLeafEntryId"); } finally { await suite.closeBrowserContext(context); } @@ -542,112 +236,6 @@ suite.define(() => { } }); - it("dismisses an informational steer notice when the steer request lands", async () => { - const context = await suite.newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page); - - try { - await page.goto(`${suite.server.baseUrl}settings/appearance`); - await page.locator("[data-settings-follow-up-mode]").selectOption("queue"); - await page.goto(`${suite.server.baseUrl}chat?session=main`); - - const originalPrompt = "keep this run active"; - await page.locator(".agent-chat__composer-combobox textarea").fill(originalPrompt); - await page.getByRole("button", { name: "Send message" }).click(); - const activeRequest = await gateway.waitForRequest("chat.send"); - const activeRunId = requireString( - requireRecord(activeRequest.params).idempotencyKey, - "active run idempotency key", - ); - await gateway.emitGatewayEvent("session.message", { - activeRunIds: [activeRunId], - clientRunId: activeRunId, - hasActiveRun: true, - message: { - __openclaw: { - id: "persisted-notice-original-user", - idempotencyKey: `${activeRunId}:user`, - seq: 1, - }, - content: [{ text: originalPrompt, type: "text" }], - role: "user", - timestamp: Date.now(), - }, - messageId: "persisted-notice-original-user", - messageSeq: 1, - session: { - activeRunIds: [activeRunId], - hasActiveRun: true, - key: "main", - kind: "direct", - status: "running", - updatedAt: Date.now(), - }, - sessionKey: "main", - }); - await page.getByRole("button", { name: "Stop generating" }).waitFor({ timeout: 10_000 }); - - const steerText = "route this into the active run"; - await page.locator(".agent-chat__composer-combobox textarea").fill(steerText); - await page.getByRole("button", { name: "Queue message" }).click(); - const queue = page.locator(".chat-queue"); - await queue.getByText(steerText).waitFor({ timeout: 10_000 }); - await queue.getByRole("button", { name: "Steer" }).click(); - - const sends = await waitForRequests(gateway, "chat.send", 2); - const steerParams = requireRecord(sends[1]?.params); - expect(steerParams).toMatchObject({ - expectedRunId: activeRunId, - message: steerText, - queueMode: "steer", - }); - const steerRunId = requireString(steerParams.idempotencyKey, "steer idempotency key"); - const row = queue.locator(".chat-queue__item--steered", { hasText: steerText }); - await row.waitFor({ timeout: 10_000 }); - const pendingPresentation = await row.evaluate((element) => { - const badge = element.querySelector(".chat-queue__badge--steered"); - const icon = element.querySelector(".chat-queue__icon"); - const probe = document.createElement("span"); - probe.style.color = "var(--info)"; - probe.style.background = "var(--info-subtle)"; - document.body.append(probe); - const probeStyle = getComputedStyle(probe); - const infoColor = probeStyle.color; - const infoSubtle = probeStyle.backgroundColor; - probe.remove(); - return { - badgeColor: badge ? getComputedStyle(badge).color : "", - backgroundColor: getComputedStyle(element).backgroundColor, - iconPoints: icon?.querySelector("polyline")?.getAttribute("points") ?? "", - infoColor, - infoSubtle, - }; - }); - await gateway.emitGatewayEvent("chat", { - runId: steerRunId, - sessionKey: "main", - state: "final", - }); - await row.waitFor({ state: "detached", timeout: 10_000 }); - await page.getByText(steerText, { exact: true }).waitFor({ timeout: 10_000 }); - await expectChatBubbleAbove(page, "keep this run active", steerText); - - expect(pendingPresentation).toMatchObject({ - badgeColor: pendingPresentation.infoColor, - backgroundColor: pendingPresentation.infoSubtle, - iconPoints: "15 10 20 15 15 20", - }); - await page.getByRole("button", { name: "Stop generating" }).waitFor({ timeout: 10_000 }); - } finally { - await suite.closeBrowserContext(context); - } - }); - it("steers a restored queued message when only the session row reports the active run", async () => { const context = await suite.newBrowserContext({ locale: "en-US", @@ -707,20 +295,12 @@ suite.define(() => { const steerParams = requireRecord(steerRequest.params); expect(steerParams).toMatchObject({ deliver: false, - expectedLeafEntryId: "leaf-active", - expectedRunId: "active-run", message: queuedPrompt, queueMode: "steer", sessionKey: "main", }); - await queue.locator(".chat-queue__badge--steered", { hasText: "Steering" }).waitFor({ - timeout: 10_000, - }); - await gateway.emitChatFinal({ - runId: requireString(steerParams.idempotencyKey, "restored steer idempotency key"), - text: "Restored steer completed.", - }); - await queue.getByText(queuedPrompt).waitFor({ state: "detached", timeout: 10_000 }); + expect(steerParams).not.toHaveProperty("expectedRunId"); + expect(steerParams).not.toHaveProperty("expectedLeafEntryId"); } finally { await suite.closeBrowserContext(context); } diff --git a/ui/src/e2e/chat-flow.messaging.e2e.test.ts b/ui/src/e2e/chat-flow.messaging.e2e.test.ts index 88dedfad06ec..eb4a35640af0 100644 --- a/ui/src/e2e/chat-flow.messaging.e2e.test.ts +++ b/ui/src/e2e/chat-flow.messaging.e2e.test.ts @@ -888,7 +888,7 @@ suite.define(() => { }); }); - it("steers the exact run with the current leaf reported by the session row", async () => { + it("sends /steer for the selected session without resolving a run or leaf", async () => { await withChatPage(async (page) => { const sessionKey = "main"; const gateway = await installMockGateway(page, { @@ -929,8 +929,9 @@ suite.define(() => { expect(params.sessionKey).toBe(sessionKey); expect(params.message).toBe("use the smaller fix"); expect(params.deliver).toBe(false); - expect(params.expectedRunId).toBe("active-run"); - expect(params.expectedLeafEntryId).toBe("leaf-before-steer"); + expect(params.queueMode).toBe("steer"); + expect(params).not.toHaveProperty("expectedRunId"); + expect(params).not.toHaveProperty("expectedLeafEntryId"); await page.getByText("Steered.", { exact: true }).waitFor({ timeout: 10_000 }); expect(await page.getByText("No active run").count()).toBe(0); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 98038f269c87..c42aac09aa34 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -4896,8 +4896,6 @@ export const en: TranslationMap = { }, sendErrors: { activeLeafChanged: "The session switched branches — review and resend.", - steerRunNoLongerActive: - "This steer still targets the previous run, but that run is no longer active.", }, waitingForApproval: "Waiting for approval…", startupStatus: { @@ -5002,7 +5000,6 @@ export const en: TranslationMap = { timeout: "The active run ended before the steer message was accepted.", failed: "Steer failed before it reached the run; try again.", usage: "Usage: `/steer `", - noActiveRun: "No active run. Use the chat input or `/redirect` instead.", succeeded: "Steered.", requestFailed: "Failed to steer: {error}", }, @@ -5386,7 +5383,6 @@ export const en: TranslationMap = { editing: "Editing a queued message", cancelEdit: "Cancel editing and keep the queued message", states: { - steering: "Steering", applyingSettings: "Applying chat settings", waitingForRun: "Waiting for current run", runningCommand: "Running command", diff --git a/ui/src/lib/chat/chat-queue-order.test.ts b/ui/src/lib/chat/chat-queue-order.test.ts index e46552f1e761..721045aee89a 100644 --- a/ui/src/lib/chat/chat-queue-order.test.ts +++ b/ui/src/lib/chat/chat-queue-order.test.ts @@ -133,7 +133,7 @@ describe("chat queue order", () => { }, { label: "failed send", item: queued("a", 1, { sendState: "failed" }), movable: true }, { label: "in-flight send", item: queued("a", 1, { sendState: "sending" }), movable: false }, - { label: "steer chip", item: queued("a", 1, { kind: "steered" }), movable: false }, + { label: "pending run row", item: queued("a", 1, { pendingRunId: "run-1" }), movable: false }, { label: "joined a run", item: queued("a", 1, { pendingRunId: "run-1" }), movable: false }, { label: "running a local command", diff --git a/ui/src/lib/chat/chat-queue-order.ts b/ui/src/lib/chat/chat-queue-order.ts index 14d60c0927ad..1cb2027d6e48 100644 --- a/ui/src/lib/chat/chat-queue-order.ts +++ b/ui/src/lib/chat/chat-queue-order.ts @@ -11,8 +11,8 @@ export function chatQueueOrderKey(item: ChatQueuePosition): number { } /** - * The one queue comparator. Display projection, drain head selection, steer - * rebuild, and alias merge all sort through it, so what the operator sees is + * The one queue comparator. Display projection, drain head selection, and + * alias merge all sort through it, so what the operator sees is * what the Gateway receives. Equal positions keep their existing relative order * through sort stability, which is how same-millisecond arrivals stayed FIFO. */ @@ -22,13 +22,12 @@ export function compareChatQueueOrder(left: ChatQueuePosition, right: ChatQueueP /** * A row may move while it is still waiting for its turn. Rows already attached - * to a run — sending, steering, running a command, or awaiting settings — keep + * to a run — sending, running a command, or awaiting settings — keep * their place, so a move can never jump ahead of work already handed over. */ export function isMovableChatQueueItem(item: ChatQueueItem): boolean { return ( !item.pendingRunId && - item.kind !== "steered" && (item.sendState === undefined || item.sendState === "waiting-idle" || item.sendState === "waiting-reconnect" || diff --git a/ui/src/lib/chat/chat-types.ts b/ui/src/lib/chat/chat-types.ts index fc029a208165..60a200a49e8b 100644 --- a/ui/src/lib/chat/chat-types.ts +++ b/ui/src/lib/chat/chat-types.ts @@ -3,6 +3,7 @@ */ import type { MediaKind } from "@openclaw/media-core/constants"; +import type { QueueMode } from "../../../../packages/gateway-protocol/src/schema/logs-chat.js"; import type { toolIcons } from "../../components/icons-tools.ts"; import type { SenderIdentity } from "./sender-label.ts"; @@ -63,7 +64,6 @@ export type ChatQueueItem = { createdAt: number; /** Operator-owned queue position; absent means "wherever arrival put it". */ orderKey?: number; - kind?: "queued" | "steered"; attachments?: ChatAttachment[]; refreshSessions?: boolean; /** Transcript id of the replied-to message; Gateway hydrates reply context. */ @@ -74,13 +74,12 @@ export type ChatQueueItem = { sendAttempts?: number; sendError?: string; sendRunId?: string; - /** Immutable active run selected when this row first became a steer. */ - steerTargetRunId?: string; + /** One-send override retained with the durable row for reconnect and retry. */ + queueMode?: QueueMode; sendState?: | "waiting-model" | "waiting-idle" | "executing-command" - | "steering" | "sending" | "waiting-reconnect" | "unconfirmed" diff --git a/ui/src/lib/chat/outbox-store-codec.ts b/ui/src/lib/chat/outbox-store-codec.ts index b75a80921b71..e4ccb94d5b53 100644 --- a/ui/src/lib/chat/outbox-store-codec.ts +++ b/ui/src/lib/chat/outbox-store-codec.ts @@ -1,5 +1,6 @@ import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { readNonBlankString as normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { normalizeQueueMode } from "../../../../src/auto-reply/reply/queue/normalize.js"; import { normalizeAgentId } from "../sessions/session-key.ts"; import type { ChatAttachment, ChatQueueItem } from "./chat-types.ts"; import { normalizeSenderIdentity } from "./sender-label.ts"; @@ -79,8 +80,15 @@ export function normalizeStoredQueueItem(value: unknown): ChatQueueItem | null { if (sender) { item.sender = sender; } - if (entry.kind === "queued" || entry.kind === "steered") { - item.kind = entry.kind; + const legacySteer = + entry.kind === "steered" || + normalizeOptionalString(entry.steerTargetRunId) !== undefined || + entry.sendState === "steering"; + const queueMode = legacySteer + ? "steer" + : normalizeQueueMode(typeof entry.queueMode === "string" ? entry.queueMode : undefined); + if (queueMode) { + item.queueMode = queueMode; } if (attachments.length) { item.attachments = attachments; @@ -93,7 +101,9 @@ export function normalizeStoredQueueItem(value: unknown): ChatQueueItem | null { if (replyToId) { item.replyToId = replyToId; } - if ( + if (entry.sendState === "steering") { + item.sendState = "unconfirmed"; + } else if ( entry.sendState === "failed" || entry.sendState === "unconfirmed" || entry.sendState === "waiting-idle" || @@ -112,10 +122,6 @@ export function normalizeStoredQueueItem(value: unknown): ChatQueueItem | null { if (sendRunId) { item.sendRunId = sendRunId; } - const steerTargetRunId = normalizeOptionalString(entry.steerTargetRunId); - if (steerTargetRunId) { - item.steerTargetRunId = steerTargetRunId; - } if (typeof entry.sendAttempts === "number" && Number.isFinite(entry.sendAttempts)) { item.sendAttempts = entry.sendAttempts; } diff --git a/ui/src/lib/chat/outbox-store.test.ts b/ui/src/lib/chat/outbox-store.test.ts index c109f9f24f01..992c8bc62cd3 100644 --- a/ui/src/lib/chat/outbox-store.test.ts +++ b/ui/src/lib/chat/outbox-store.test.ts @@ -373,7 +373,6 @@ describe("stored outbox summaries", () => { undefined, "waiting-idle", "executing-command", - "steering", "sending", "waiting-reconnect", ] as const; @@ -427,8 +426,8 @@ describe("stored outbox summaries", () => { const threadA = storedChatOutboxScopeKey({ sessionKey: "thread-a" }); const threadB = storedChatOutboxScopeKey({ sessionKey: "thread-b" }); - expect(summary.total).toBe(9); - expect(summary.countsByScope.get(threadA)).toBe(8); + expect(summary.total).toBe(8); + expect(summary.countsByScope.get(threadA)).toBe(7); expect(summary.countsByScope.get(threadB)).toBe(1); expect(summary.attentionCountsByScope.get(threadA)).toBe(2); expect(summary.attentionCountsByScope.get(threadB)).toBe(1); diff --git a/ui/src/pages/chat/chat-command-executor.test.ts b/ui/src/pages/chat/chat-command-executor.test.ts index c316638b1ff2..f922d6d76eae 100644 --- a/ui/src/pages/chat/chat-command-executor.test.ts +++ b/ui/src/pages/chat/chat-command-executor.test.ts @@ -1570,13 +1570,10 @@ describe("executeSlashCommand directives", () => { }); describe("executeSlashCommand /steer (soft inject)", () => { - it("injects into the current session via chat.send with deliver: false", async () => { - const request = vi.fn(async (method: string, _payload?: unknown) => { - if (method === "sessions.list") { - return { sessions: [row("agent:main:main", { status: "running" })] }; - } + it("sends the selected session without resolving a run or leaf", async () => { + const request = vi.fn(async (method: string) => { if (method === "chat.send") { - return { status: "started", runId: "run-1", messageSeq: 2 }; + return { status: "started", runId: "run-1" }; } throw new Error(`unexpected method: ${method}`); }); @@ -1588,81 +1585,19 @@ describe("executeSlashCommand /steer (soft inject)", () => { "try a different approach", ); - expect(result.content).toBe(t("chat.commandResults.steer.succeeded")); - expect(result.pendingCurrentRun).toBe(true); - const chatSend = requireRequestCall(request, "chat.send"); - expect(chatSend.payload.sessionKey).toBe("agent:main:main"); - expect(chatSend.payload.message).toBe("try a different approach"); - expect(chatSend.payload.deliver).toBe(false); - expect(chatSend.payload.queueMode).toBe("steer"); - }); - - it("uses a unique run id when a real session row omits active leaf context", async () => { - const request = vi.fn(async (method: string, _payload?: unknown) => { - if (method === "sessions.list") { - return { - sessions: [ - row("agent:main:main", { - hasActiveRun: true, - activeRunIds: ["active-run"], - activeLeafEntryId: undefined, - }), - ], - }; - } - if (method === "chat.send") { - return { status: "started", runId: "run-active-flag", messageSeq: 2 }; - } - throw new Error(`unexpected method: ${method}`); - }); - - const result = await executeSlashCommand( - createTestGatewayClient(request), - "agent:main:main", - "steer", - "continue with the smaller fix", - ); - expect(result.content).toBe(t("chat.commandResults.steer.succeeded")); expect(result.pendingCurrentRun).toBe(true); const chatSend = requireRequestCall(request, "chat.send"); expect(chatSend.payload).toMatchObject({ sessionKey: "agent:main:main", - message: "continue with the smaller fix", + message: "try a different approach", deliver: false, - expectedRunId: "active-run", + queueMode: "steer", + idempotencyKey: expect.any(String), }); + expect(chatSend.payload).not.toHaveProperty("expectedRunId"); expect(chatSend.payload).not.toHaveProperty("expectedLeafEntryId"); - }); - - it.each([ - ["zero", []], - ["multiple", ["run-a", "run-b"]], - ] as const)("refuses %s authoritative active run ids", async (_label, activeRunIds) => { - const request = vi.fn(async (method: string) => { - if (method === "sessions.list") { - return { - sessions: [ - row("agent:main:main", { - hasActiveRun: true, - activeRunIds: [...activeRunIds], - activeLeafEntryId: undefined, - }), - ], - }; - } - throw new Error(`unexpected method: ${method}`); - }); - - const result = await executeSlashCommand( - createTestGatewayClient(request), - "agent:main:main", - "steer", - "continue safely", - ); - - expect(result.content).toBe(t("chat.commandResults.steer.noActiveRun")); - expectNoRequestCall(request, "chat.send"); + expectNoRequestCall(request, "sessions.list"); }); it("does not mark the current run pending when chat.send returns terminal ok", async () => { @@ -1696,9 +1631,6 @@ describe("executeSlashCommand /steer (soft inject)", () => { "reports terminal %s ACK without marking the current run pending", async (status, expectedKey) => { const request = vi.fn(async (method: string, _payload?: unknown) => { - if (method === "sessions.list") { - return { sessions: [row("agent:main:main", { status: "running" })] }; - } if (method === "chat.send") { return { status, runId: `run-${status}`, summary: "aborted" }; } @@ -1722,9 +1654,6 @@ describe("executeSlashCommand /steer (soft inject)", () => { it("passes selected-agent scope when steering the selected global session", async () => { const request = vi.fn(async (method: string, _payload?: unknown) => { - if (method === "sessions.list") { - return { sessions: [row("global", { status: "running" })] }; - } if (method === "chat.send") { return { status: "started", runId: "run-global", messageSeq: 2 }; } @@ -1740,7 +1669,6 @@ describe("executeSlashCommand /steer (soft inject)", () => { ); expect(result.content).toBe(t("chat.commandResults.steer.succeeded")); - expect(request).toHaveBeenCalledWith("sessions.list", { agentId: "work" }); const chatSend = requireRequestCall(request, "chat.send"); expect(chatSend.payload).toMatchObject({ sessionKey: "global", @@ -1750,177 +1678,6 @@ describe("executeSlashCommand /steer (soft inject)", () => { }); }); - it("passes selected-agent scope when steering a selected-global alias", async () => { - const request = vi.fn(async (method: string, _payload?: unknown) => { - if (method === "sessions.list") { - return { sessions: [row("global", { status: "running" })] }; - } - if (method === "chat.send") { - return { status: "started", runId: "run-global", messageSeq: 2 }; - } - throw new Error(`unexpected method: ${method}`); - }); - - const result = await executeSlashCommand( - createTestGatewayClient(request), - "agent:work:main", - "steer", - "try the alias", - ); - - expect(result.content).toBe(t("chat.commandResults.steer.succeeded")); - expect(request).toHaveBeenCalledWith("sessions.list", { agentId: "work" }); - const chatSend = requireRequestCall(request, "chat.send"); - expect(chatSend.payload).toMatchObject({ - sessionKey: "agent:work:main", - agentId: "work", - message: "try the alias", - deliver: false, - }); - }); - - it("uses cached sessions to avoid an extra sessions.list round trip", async () => { - const request = vi.fn(async (method: string, _payload?: unknown) => { - if (method === "chat.send") { - return { status: "started", runId: "run-2", messageSeq: 1 }; - } - throw new Error(`unexpected method: ${method}`); - }); - - const result = await executeSlashCommand( - createTestGatewayClient(request), - "agent:main:main", - "steer", - "researcher try a different approach", - { - sessionsResult: { - sessions: [ - row("agent:main:main", { status: "running" }), - row("agent:main:subagent:researcher", { - spawnedBy: "agent:main:main", - status: "running", - }), - ], - } as SessionsListResult, - }, - ); - - expect(result.content).toBe(t("chat.commandResults.steer.succeeded")); - expect(request).toHaveBeenCalledTimes(1); - const chatSend = requireRequestCall(request, "chat.send"); - expect(chatSend.payload.sessionKey).toBe("agent:main:main"); - expect(chatSend.payload.message).toBe("researcher try a different approach"); - expect(chatSend.payload.deliver).toBe(false); - }); - - it("does not treat 'all' as a subagent wildcard", async () => { - const request = vi.fn(async (method: string, _payload?: unknown) => { - if (method === "sessions.list") { - return { sessions: [row("agent:main:main", { status: "running" })] }; - } - if (method === "chat.send") { - return { status: "started", runId: "run-3", messageSeq: 1 }; - } - throw new Error(`unexpected method: ${method}`); - }); - - const result = await executeSlashCommand( - createTestGatewayClient(request), - "agent:main:main", - "steer", - "all good now", - ); - - expect(result.content).toBe(t("chat.commandResults.steer.succeeded")); - const chatSend = requireRequestCall(request, "chat.send"); - expect(chatSend.payload.sessionKey).toBe("agent:main:main"); - expect(chatSend.payload.message).toBe("all good now"); - expect(chatSend.payload.deliver).toBe(false); - }); - - it("does not match agent id as target — treats 'main' as message text", async () => { - const request = vi.fn(async (method: string, _payload?: unknown) => { - if (method === "sessions.list") { - return { - sessions: [ - row("agent:main:main", { status: "running" }), - row("agent:main:subagent:researcher", { spawnedBy: "agent:main:main" }), - ], - }; - } - if (method === "chat.send") { - return { status: "started", runId: "run-4", messageSeq: 1 }; - } - throw new Error(`unexpected method: ${method}`); - }); - - const result = await executeSlashCommand( - createTestGatewayClient(request), - "agent:main:main", - "steer", - "main refine the plan", - ); - - expect(result.content).toBe(t("chat.commandResults.steer.succeeded")); - const chatSend = requireRequestCall(request, "chat.send"); - expect(chatSend.payload.sessionKey).toBe("agent:main:main"); - expect(chatSend.payload.message).toBe("main refine the plan"); - expect(chatSend.payload.deliver).toBe(false); - }); - - it("treats subagent-looking prefixes as current-session message text", async () => { - const request = vi.fn(async (method: string, _payload?: unknown) => { - if (method === "sessions.list") { - return { - sessions: [ - row("agent:main:main", { status: "running" }), - row("agent:main:subagent:researcher", { - spawnedBy: "agent:main:main", - endedAt: Date.now() - 60_000, - }), - ], - }; - } - if (method === "chat.send") { - return { status: "started", runId: "run-5", messageSeq: 1 }; - } - throw new Error(`unexpected method: ${method}`); - }); - - const result = await executeSlashCommand( - createTestGatewayClient(request), - "agent:main:main", - "steer", - "researcher try again", - ); - - expect(result.content).toBe(t("chat.commandResults.steer.succeeded")); - const chatSend = requireRequestCall(request, "chat.send"); - expect(chatSend.payload.sessionKey).toBe("agent:main:main"); - expect(chatSend.payload.message).toBe("researcher try again"); - expect(chatSend.payload.deliver).toBe(false); - }); - - it("returns a no-op summary when the current session has no active run", async () => { - const request = vi.fn(async (method: string, _payload?: unknown) => { - if (method === "sessions.list") { - return { sessions: [row("agent:main:main", { status: "done", endedAt: Date.now() })] }; - } - throw new Error(`unexpected method: ${method}`); - }); - - const result = await executeSlashCommand( - createTestGatewayClient(request), - "agent:main:main", - "steer", - "try again", - ); - - expect(result.content).toBe(t("chat.commandResults.steer.noActiveRun")); - expect(request).toHaveBeenCalledWith("sessions.list", {}); - expectNoRequestCall(request, "chat.send"); - }); - it("returns steer usage when no message is provided", async () => { const request = vi.fn(); @@ -1936,10 +1693,7 @@ describe("executeSlashCommand /steer (soft inject)", () => { }); it("returns steer error message on RPC failure", async () => { - const request = vi.fn(async (method: string, _payload?: unknown) => { - if (method === "sessions.list") { - return { sessions: [row("agent:main:main", { status: "running" })] }; - } + const request = vi.fn(async () => { throw new Error("connection lost"); }); diff --git a/ui/src/pages/chat/chat-command-executor.ts b/ui/src/pages/chat/chat-command-executor.ts index 4ec10849b9d4..5d166249fcc6 100644 --- a/ui/src/pages/chat/chat-command-executor.ts +++ b/ui/src/pages/chat/chat-command-executor.ts @@ -38,7 +38,6 @@ import { import { formatUiError, formatUiExternalText } from "../../lib/format-error.ts"; import { formatCompactTokenCount } from "../../lib/format.ts"; import { readSessionMethodAccess } from "../../lib/session-method-access.ts"; -import { isSessionRunActive } from "../../lib/session-run-state.ts"; import type { SessionCapability } from "../../lib/sessions/index.ts"; import { DEFAULT_AGENT_ID, @@ -811,10 +810,10 @@ async function loadModelCatalog( } } -async function resolveSteerTarget( +function resolveCommandMessage( sessionKey: string, args: string, -): Promise<{ key: string; message: string } | { error: string }> { +): { key: string; message: string } | { error: string } { const trimmed = args.trim(); if (!trimmed) { return { error: "empty" }; @@ -825,12 +824,6 @@ async function resolveSteerTarget( }; } -function isActiveSteerSession( - session: GatewaySessionRow | undefined, -): session is GatewaySessionRow & { activeRunIds: [string] } { - return Boolean(session && isSessionRunActive(session) && session.activeRunIds?.length === 1); -} - type SteerChatSendAckStatus = "started" | "in_flight" | "ok" | "timeout" | "error"; function normalizeSteerChatSendAckStatus(payload: unknown): SteerChatSendAckStatus { @@ -871,21 +864,12 @@ async function executeSteer( context: SlashCommandContext, ): Promise { try { - const resolved = await resolveSteerTarget(sessionKey, args); + const resolved = resolveCommandMessage(sessionKey, args); if ("error" in resolved) { return { content: resolved.error === "empty" ? t("chat.commandResults.steer.usage") : resolved.error, }; } - const sessions = - context.sessionsResult ?? - (await listSessions(context, selectedGlobalScope(sessionKey, context))); - const targetSession = resolveCurrentSession(sessions, resolved.key); - if (!isActiveSteerSession(targetSession)) { - return { - content: t("chat.commandResults.steer.noActiveRun"), - }; - } assertCurrentSlashCommand(context); const ackStatus = normalizeSteerChatSendAckStatus( await client.request("chat.send", { @@ -894,10 +878,6 @@ async function executeSteer( message: resolved.message, deliver: false, queueMode: "steer", - expectedRunId: targetSession.activeRunIds[0], - ...(targetSession.activeLeafEntryId !== undefined - ? { expectedLeafEntryId: targetSession.activeLeafEntryId } - : {}), idempotencyKey: generateUUID(), }), ); @@ -926,7 +906,7 @@ async function executeRedirect( context: SlashCommandContext, ): Promise { try { - const resolved = await resolveSteerTarget(sessionKey, args); + const resolved = resolveCommandMessage(sessionKey, args); if ("error" in resolved) { return { content: diff --git a/ui/src/pages/chat/chat-composer-actions.test.ts b/ui/src/pages/chat/chat-composer-actions.test.ts index d26d1213dcf6..8612af1235c8 100644 --- a/ui/src/pages/chat/chat-composer-actions.test.ts +++ b/ui/src/pages/chat/chat-composer-actions.test.ts @@ -165,7 +165,7 @@ describe("renderChatComposer controls", () => { onQueueSteer, queue: [ { id: "queued-1", text: "tighten the plan", createdAt: 1 }, - { id: "steered-1", text: "already sent", createdAt: 2, kind: "steered" }, + { id: "pending-1", text: "already sent", createdAt: 2, pendingRunId: "run-1" }, { id: "local-1", text: "/status", createdAt: 3, localCommandName: "status" }, { id: "waiting-idle-1", @@ -175,7 +175,7 @@ describe("renderChatComposer controls", () => { }, ], }); - const steer = [...container.querySelectorAll(".chat-queue__steer")]; + const steer = [...container.querySelectorAll(".chat-queue__action")]; expect(steer).toHaveLength(2); steer[0]?.click(); steer[1]?.click(); diff --git a/ui/src/pages/chat/chat-composer-queue.test.ts b/ui/src/pages/chat/chat-composer-queue.test.ts index 4a154f2f3dcc..4655f2be38db 100644 --- a/ui/src/pages/chat/chat-composer-queue.test.ts +++ b/ui/src/pages/chat/chat-composer-queue.test.ts @@ -12,10 +12,7 @@ afterEach(async () => { }); describe("chat composer steering queue", () => { - it.each([ - { sendState: "steering" as const, sendRunId: "send-1" }, - { pendingRunId: "run-1", sendRunId: "send-1" }, - ])("renders one Steering badge for an in-flight or acknowledged steer", (steerState) => { + it("renders the durable steer mode without a run-bound state", () => { const container = document.createElement("div"); document.body.append(container); render( @@ -25,8 +22,8 @@ describe("chat composer steering queue", () => { id: "steer-1", text: "change course", createdAt: 1, - kind: "steered", - ...steerState, + queueMode: "steer", + sendState: "waiting-idle", }, ], onQueueRemove: vi.fn(), @@ -35,11 +32,9 @@ describe("chat composer steering queue", () => { ); const badges = container.querySelectorAll(".chat-queue__badge"); - expect(badges).toHaveLength(1); - expect(badges[0]?.textContent?.trim()).toBe(t("chat.queue.states.steering")); - const icon = container.querySelector(".chat-queue__icon"); - expect(icon?.querySelector('polyline[points="15 10 20 15 15 20"]')).not.toBeNull(); - expect(icon?.querySelector("circle")).toBeNull(); + expect(badges).toHaveLength(2); + expect(badges[0]?.textContent?.trim()).toBe(t("chat.queue.steer")); + expect(badges[1]?.textContent?.trim()).toBe(t("chat.queue.states.waitingForRun")); }); it("keeps a failed steer visually classified as an error", () => { @@ -52,7 +47,7 @@ describe("chat composer steering queue", () => { id: "failed-steer", text: "change course", createdAt: 1, - kind: "steered", + queueMode: "steer", sendState: "failed", sendError: "steer rejected", }, @@ -64,11 +59,11 @@ describe("chat composer steering queue", () => { const row = container.querySelector(".chat-queue__item"); expect(row?.classList.contains("chat-queue__item--failed")).toBe(true); - expect(row?.classList.contains("chat-queue__item--steered")).toBe(false); const icon = row?.querySelector(".chat-queue__icon"); expect(icon?.querySelector('path[d^="m21.73 18"]')).not.toBeNull(); - expect(icon?.querySelector('polyline[points="15 10 20 15 15 20"]')).toBeNull(); - expect(container.querySelector(".chat-queue__badge--steered")).toBeNull(); + expect(container.querySelector(".chat-queue__badge")?.textContent?.trim()).toBe( + t("chat.queue.steer"), + ); }); }); @@ -155,7 +150,7 @@ describe("chat composer queue reordering", () => { it("reserves the handle column on every row so the pills never shift", () => { const container = renderQueue({ queue: [ - { id: "steer", text: "steer", createdAt: 1, kind: "steered", pendingRunId: "run-1" }, + { id: "pending", text: "pending", createdAt: 1, pendingRunId: "run-1" }, waiting("b", 2), waiting("c", 3), ], @@ -224,7 +219,7 @@ describe("chat composer queue reordering", () => { expect(rows[1]?.querySelector(".chat-queue__edit-input")).not.toBeNull(); expect(rows[1]?.querySelector(".chat-queue__edit-submit")).not.toBeNull(); expect(rows[1]?.querySelector(".chat-queue__edit-cancel")).not.toBeNull(); - expect(rows.map((row) => row.querySelector(".chat-queue__steer") !== null)).toEqual([ + expect(rows.map((row) => row.querySelector(".chat-queue__action") !== null)).toEqual([ true, false, true, @@ -271,7 +266,7 @@ describe("chat composer queue reordering", () => { it("keeps a row that already joined a run out of the reorder set", () => { const container = renderQueue({ queue: [ - { id: "steer", text: "steer", createdAt: 1, kind: "steered", pendingRunId: "run-1" }, + { id: "pending", text: "pending", createdAt: 1, pendingRunId: "run-1" }, waiting("b", 2), waiting("c", 3), ], diff --git a/ui/src/pages/chat/chat-gateway.test.ts b/ui/src/pages/chat/chat-gateway.test.ts index 7c7892893e9a..4b3b44c9aa59 100644 --- a/ui/src/pages/chat/chat-gateway.test.ts +++ b/ui/src/pages/chat/chat-gateway.test.ts @@ -855,129 +855,6 @@ describe("handleChatGatewayEvent", () => { expect(state.chatStreamSegments).toEqual([]); }); - it("does not replay persisted keyed commentary after retiring a same-run steer", () => { - const originalUser = createTextChatMessage("user", "Ask", undefined, 1); - const persistedCommentary = { - role: "assistant", - content: [{ type: "text", text: "Looking into it." }], - timestamp: 2, - openclawStreamFallback: { - itemId: "preamble-1", - replacementText: "Looking into it.", - source: "segment", - }, - }; - const state = createState({ - sessionKey: "main", - chatRunId: "run-1", - chatMessages: [originalUser, persistedCommentary], - chatQueue: [ - { - id: "steer-1", - text: "Focus on the deployment too", - createdAt: 3, - kind: "steered", - pendingRunId: "run-1", - sendRunId: "steer-send-1", - sessionKey: "main", - }, - ], - chatStream: null, - chatStreamStartedAt: null, - }) as ChatState & { - chatStreamSegments: Array<{ text: string; ts: number; itemId: string }>; - }; - state.chatStreamSegments = [{ text: "Looking into it.", ts: 2, itemId: "preamble-1" }]; - - expect( - handleChatGatewayEvent(state, { - runId: "run-1", - sessionKey: "main", - state: "final", - message: createTextChatMessage("assistant", "Final answer.", undefined, 5), - }), - ).toBe("final"); - - expect(state.chatQueue).toEqual([]); - expect(state.chatMessages).toHaveLength(4); - expectTextChatMessage(state.chatMessages[0], "user", "Ask"); - expectTextChatMessage(state.chatMessages[1], "assistant", "Looking into it."); - expectTextChatMessage(state.chatMessages[2], "user", "Focus on the deployment too"); - expectTextChatMessage(state.chatMessages[3], "assistant", "Final answer."); - }); - - it("retires a reply steer chip after an exact-target terminal rejection", () => { - const state = createState({ - sessionKey: "main", - chatRunId: "reply-steer-request", - chatQueue: [ - { - id: "reply-steer-chip", - text: "Reply with deployment context", - createdAt: 3, - kind: "steered", - pendingRunId: "reply-steer-request", - sendRunId: "reply-steer-request", - sessionKey: "main", - }, - ], - }); - - expect( - handleChatGatewayEvent(state, { - runId: "reply-steer-request", - sessionKey: "main", - state: "error", - errorMessage: "active run changed; review and retry", - }), - ).toBe("error"); - - expect(state.chatQueue).toEqual([]); - expect(state.chatRunId).toBeNull(); - expect(state.chatRunError).toEqual({ - summary: "Error: active run changed; review and retry", - }); - }); - - it("keeps a pending steer chip when an unrelated request run finishes", () => { - const chip = { - id: "pending-steer-chip", - text: "Keep waiting for this steer", - createdAt: 3, - kind: "steered" as const, - pendingRunId: "active-run", - sendRunId: "steer-request-run", - sessionKey: "main", - }; - const state = createState({ - sessionKey: "main", - chatRunId: "active-run", - chatQueue: [ - chip, - { - id: "unrelated-pending-row", - text: "Keep unrelated pending work", - createdAt: 4, - pendingRunId: "unrelated-run", - sessionKey: "main", - }, - ], - }); - - handleChatGatewayEvent(state, { - runId: "unrelated-run", - sessionKey: "main", - state: "final", - }); - - expect(state.chatQueue).toEqual([ - chip, - expect.objectContaining({ id: "unrelated-pending-row" }), - ]); - expect(state.chatRunId).toBe("active-run"); - expect(state.chatMessages).toEqual([]); - }); - it("preserves an already-recorded stream boundary for a persisted steer", () => { const state = createState({ sessionKey: "main", @@ -1001,17 +878,6 @@ describe("handleChatGatewayEvent", () => { 3, ), ], - chatQueue: [ - { - id: "steer-1", - text: "Focus on deployment", - createdAt: 3, - kind: "steered", - pendingRunId: "run-1", - sendRunId: "steer-send-1", - sessionKey: "main", - }, - ], }) as ChatState & { chatStreamSegments: Array<{ text: string; @@ -1036,7 +902,6 @@ describe("handleChatGatewayEvent", () => { message: createTextChatMessage("assistant", "Final answer.", undefined, 5), }); - expect(state.chatQueue).toEqual([]); expect(state.chatMessages).toHaveLength(4); expectTextChatMessage(state.chatMessages[0], "user", "Ask"); expectTextChatMessage(state.chatMessages[1], "assistant", "Looking into it."); diff --git a/ui/src/pages/chat/chat-gateway.ts b/ui/src/pages/chat/chat-gateway.ts index c3a8d5541e41..11bc019ce9d6 100644 --- a/ui/src/pages/chat/chat-gateway.ts +++ b/ui/src/pages/chat/chat-gateway.ts @@ -25,10 +25,6 @@ import { } from "./history-merge.ts"; import { reconcileChatRunLifecycle } from "./run-lifecycle.ts"; import { appendChatMessageToCache } from "./session-message-cache.ts"; -import { - retireSteeredChipsForRequestRun, - retireSteeredChipsForTerminalRun, -} from "./steer-lifecycle.ts"; import { latestStreamBoundaryRunId, reconcileTerminalStreamBoundary, @@ -66,17 +62,6 @@ function isPendingLocalChatRun(state: ChatState, runId: string): boolean { return state.chatQueue.some((item) => item.sendRunId === runId && item.sendState === "sending"); } -function isTerminalChatState(value: unknown): boolean { - return value === "final" || value === "aborted" || value === "error"; -} - -function isEventForDifferentActiveRun( - payload: ChatEventPayload | undefined, - activeRunId: string | null, -): boolean { - return Boolean(activeRunId && payload && payload.runId !== activeRunId); -} - function resolveDeltaChatStreamText( currentStream: string | null, payload: ChatEventPayload, @@ -530,25 +515,5 @@ function handleChatEvent(state: ChatState, payload?: ChatEventPayload) { } export function handleChatGatewayEvent(state: ChatState, payload?: ChatEventPayload) { - const activeRunIdBeforeEvent = state.chatRunId; - const terminalEventMatchesChat = - isTerminalChatState(payload?.state) && - payload !== undefined && - // Unkeyed events must also carry a real run id: with no active run, - // `undefined === undefined` would let sessionless internal-run terminals - // (e.g. companion answers) materialize into the open main thread. - (chatEventSessionMatches(state, payload) || - (typeof payload.runId === "string" && payload.runId === activeRunIdBeforeEvent)); - const terminalOwnsActiveRun = - terminalEventMatchesChat && !isEventForDifferentActiveRun(payload, activeRunIdBeforeEvent); - // An accepted steer terminal is keyed by the steer request while the chip - // also tracks the active target run. Reconcile either identity before the - // generic different-run path ignores the terminal and leaves stale status. - if (terminalOwnsActiveRun) { - retireSteeredChipsForTerminalRun(state, payload?.runId); - } - if (terminalEventMatchesChat) { - retireSteeredChipsForRequestRun(state, payload?.runId); - } return handleChatEvent(state, payload); } diff --git a/ui/src/pages/chat/chat-history.ts b/ui/src/pages/chat/chat-history.ts index aac348a7e174..9d67e94340da 100644 --- a/ui/src/pages/chat/chat-history.ts +++ b/ui/src/pages/chat/chat-history.ts @@ -67,7 +67,6 @@ import { readChatSessionSnapshot, type ChatSessionSnapshot, } from "./session-message-cache.ts"; -import { retirePersistedSteeredChips } from "./steer-lifecycle.ts"; import { latestPersistedSteerBoundary, markChatStreamAfterBoundary, @@ -1559,7 +1558,6 @@ async function loadChatHistoryUncached( state.chatThinkingLevel = response.sessionInfo.thinkingLevel ?? null; state.chatQueueModeOverride = response.sessionInfo.queueMode; state.chatEffectiveQueueMode = response.sessionInfo.effectiveQueueMode; - retirePersistedSteeredChips(state); replaceCachedChatMessages(state, sessionKey, requestAgentId, response.deltaCursor); recordChatHistoryTiming(state, "applied", startedAtMs, { requestSessionKey: sessionKey, @@ -1650,7 +1648,6 @@ async function loadChatHistoryUncached( if (Object.hasOwn(res.sessionInfo ?? {}, "activeLeafEntryId")) { state.chatDisplayedLeafEntryId = nextDisplayedLeafEntryId; } - retirePersistedSteeredChips(state); state.chatHistoryPagination = reconciledHistory?.pagination ?? nextPagination; state.currentSessionId = nextSessionId; replaceCachedChatMessages(state, sessionKey, requestAgentId, res.deltaCursor); diff --git a/ui/src/pages/chat/chat-outbox-drain.ts b/ui/src/pages/chat/chat-outbox-drain.ts index 0df25cfea60a..c745e97c51b0 100644 --- a/ui/src/pages/chat/chat-outbox-drain.ts +++ b/ui/src/pages/chat/chat-outbox-drain.ts @@ -27,6 +27,12 @@ import { updateQueuedMessageForSession, } from "./chat-queue.ts"; import type { ChatHost } from "./chat-send-contract.ts"; +import { + chatMessagesContainQueuedSend, + OFFLINE_QUEUE_STORAGE_ERROR, + preserveQueuedUserTurn, + surfaceChatDeliveryFailure, +} from "./chat-send-support.ts"; import { listStoredChatOutboxes, storedChatOutboxScopeKey, @@ -36,16 +42,12 @@ import { import { formatConnectError } from "./connect-error.ts"; import { isQueuedMessageBeingEdited } from "./queued-message-edit.ts"; import { isChatBusy } from "./run-lifecycle.ts"; -import { - chatMessagesContainQueuedSend, - OFFLINE_QUEUE_STORAGE_ERROR, - preserveQueuedUserTurn, - surfaceChatDeliveryFailure, -} from "./steer-lifecycle.ts"; export type QueuedChatSendResult = "sent" | "pending" | "failed"; export type QueuedChatStorageMode = "durable" | "memory"; export type QueuedChatSendOptions = { + /** Fresh selected-session sends may let the Gateway resolve its effective active-run mode. */ + allowActiveRunSend?: boolean; /** Exact submit-time leaf; restored drains omit it so intervening advances park the draft. */ expectedLeafEntryId?: string | null; pendingSettings?: Promise; @@ -281,6 +283,9 @@ async function reconcileStoredChatOutboxHead( // bubbles even mid-run, and a missing row falls through conservatively. const neverAttempted = (item.sendAttempts ?? 0) === 0 && item.sendRequestStartedAtMs === undefined; + if (neverAttempted && item.queueMode) { + return "send"; + } if (neverAttempted) { const row = !isUiGlobalSessionKey(outbox.sessionKey) || host.sessions.state.agentId === outbox.agentId @@ -372,12 +377,20 @@ async function drainStoredChatOutbox( if (!outbox) { return "empty"; } - const storedItem = outbox.queue.find( - (entry) => - lane.freshAdmissions.has(entry.id) || - entry.sendState !== "failed" || - entry.localCommandName, + // A fresh active-run send is an explicit operator action, not work queued + // behind the run. Let it bypass older FIFO rows; ordinary fresh admissions + // still preserve their existing order. + const freshActiveRunItem = outbox.queue.find( + (entry) => lane.freshAdmissions.has(entry.id) && Boolean(entry.queueMode), ); + const storedItem = + freshActiveRunItem ?? + outbox.queue.find( + (entry) => + lane.freshAdmissions.has(entry.id) || + entry.sendState !== "failed" || + entry.localCommandName, + ); const freshItem = storedItem && lane.freshAdmissions.has(storedItem.id); const item = freshItem ? (readQueuedMessageById(host, storedItem.id) ?? storedItem) diff --git a/ui/src/pages/chat/chat-queue.ts b/ui/src/pages/chat/chat-queue.ts index cd056c4428cc..e0e50a7b11c9 100644 --- a/ui/src/pages/chat/chat-queue.ts +++ b/ui/src/pages/chat/chat-queue.ts @@ -89,32 +89,6 @@ export function syncVisibleChatQueueProjection( chatOutboxOwner(host).syncHost(host, options); } -export function setTransientQueuedMessageProjection( - host: ChatQueueScopedSessionHost, - sessionKey: string, - item: ChatQueueItem, - agentId?: string, -): boolean { - const scope = resolveStoredChatOutboxScope(host, sessionKey, agentId); - const owner = chatOutboxOwner(host); - const outbox = owner.durable(host, item.id); - if (!outbox?.queue.some((entry) => entry.id === item.id)) { - return false; - } - owner.projectLive(host, scope, item.id, item); - return true; -} - -export function clearTransientQueuedMessageProjection( - host: ChatQueueScopedSessionHost, - sessionKey: string, - id: string, - agentId?: string, -) { - const scope = resolveStoredChatOutboxScope(host, sessionKey, agentId); - chatOutboxOwner(host).projectLive(host, scope, id); -} - export function subscribeChatOutboxProjection(host: ChatQueueScopedSessionHost): () => void { return chatOutboxOwner(host).subscribe(host); } @@ -160,13 +134,12 @@ export function enqueuePendingRunMessage( if (!trimmed && !hasAttachments) { return; } - // Local commands join an existing run without a wire chat.send, so this is - // intentionally a non-SteeredChip pending row with no fake sendRunId. + // Local commands join an existing run without a wire chat.send, so this + // pending row intentionally has no fake send identity. const item: ChatQueueItem = { id: generateUUID(), text: trimmed, createdAt: Date.now(), - kind: "steered", attachments: hasAttachments ? cloneChatAttachmentsMetadata(attachments ?? []) : undefined, pendingRunId, ...(sender ? { sender } : {}), @@ -183,30 +156,7 @@ export function readChatQueueForScope( return chatOutboxOwner(host).snapshot(host, scope); } -export function replacePendingQueuedMessageProjection( - host: ChatQueueScopedSessionHost, - sessionKey: string, - id: string, - pendingRunId: string, - replacement: ChatQueueItem, - agentId?: string, -): boolean { - const queue = readChatQueueForScope(host, sessionKey, agentId); - if (!queue.some((item) => item.id === id && item.pendingRunId === pendingRunId)) { - return false; - } - writeChatQueueForScope( - host, - sessionKey, - queue.map((item) => - item.id === id && item.pendingRunId === pendingRunId ? replacement : item, - ), - agentId, - ); - return true; -} - -export function writeChatQueueForScope( +function writeChatQueueForScope( host: ChatQueueScopedSessionHost, sessionKey: string, queue: ChatQueueItem[], diff --git a/ui/src/pages/chat/chat-reset-delivery.ts b/ui/src/pages/chat/chat-reset-delivery.ts index 7763297db187..3a5b480beb09 100644 --- a/ui/src/pages/chat/chat-reset-delivery.ts +++ b/ui/src/pages/chat/chat-reset-delivery.ts @@ -13,7 +13,7 @@ import { reconnectSafeQueuedSendState, setChatError, } from "./chat-send-queue-state.ts"; -import { OFFLINE_QUEUE_STORAGE_ERROR } from "./steer-lifecycle.ts"; +import { OFFLINE_QUEUE_STORAGE_ERROR } from "./chat-send-support.ts"; type DeliverChatQueueItem = ( host: ChatHost, diff --git a/ui/src/pages/chat/chat-send-ack.ts b/ui/src/pages/chat/chat-send-ack.ts index afed156baee8..65ca53d21d1e 100644 --- a/ui/src/pages/chat/chat-send-ack.ts +++ b/ui/src/pages/chat/chat-send-ack.ts @@ -1,5 +1,5 @@ // Leaf contract for chat.send acknowledgment shapes and timing records. -// Kept import-free of chat-page modules so lifecycle/steer/history layers +// Kept import-free of chat-page modules so lifecycle and history layers // can consume ack types without forming import cycles. import { asNonNegativeFiniteNumber as normalizeAckTimingValue } from "@openclaw/normalization-core/number-coercion"; import type { ChatQueueItem } from "../../lib/chat/chat-types.ts"; diff --git a/ui/src/pages/chat/chat-send-actions.ts b/ui/src/pages/chat/chat-send-actions.ts index ac1292d861ee..8cc7a51a753f 100644 --- a/ui/src/pages/chat/chat-send-actions.ts +++ b/ui/src/pages/chat/chat-send-actions.ts @@ -1,3 +1,4 @@ +import type { QueueMode } from "../../../../packages/gateway-protocol/src/schema/logs-chat.js"; import { GatewayRequestError } from "../../api/gateway.ts"; import { t } from "../../i18n/index.ts"; import { @@ -34,6 +35,7 @@ import { requestChatSend, resolveDisplayedLeafEntryId, } from "./chat-send-request.ts"; +import { OFFLINE_QUEUE_STORAGE_ERROR } from "./chat-send-support.ts"; import { listStoredChatOutboxes, storedChatOutboxScopeKey } from "./composer-persistence.ts"; import { formatConnectError } from "./connect-error.ts"; import { @@ -43,14 +45,8 @@ import { isQueuedMessageReorderBlocked, QUEUED_MESSAGE_RETRY_CONFLICT_ERROR, QUEUED_MESSAGE_REORDER_CONFLICT_ERROR, + QUEUED_MESSAGE_STEER_CONFLICT_ERROR, } from "./queued-message-edit.ts"; -import { hasDirectSessionRun } from "./run-lifecycle.ts"; -import { - OFFLINE_QUEUE_STORAGE_ERROR, - steerQueuedChatMessage as steerQueuedChatMessageLifecycle, - type SteerSendDependencies, -} from "./steer-lifecycle.ts"; -import { isInflightSteer } from "./steered-chip.ts"; function applyChatSendError(state: ChatState, err: unknown, canApplyError: () => boolean): string { const error = isActiveLeafChangedError(err) @@ -69,7 +65,13 @@ export async function sendChatMessageWithGeneratedRunId( state: ChatState, message: string, attachments?: ChatAttachment[], - options: Partial[3]> = {}, + options: { + canApplyError?: () => boolean; + expectedLeafEntryId?: string | null; + queueMode?: QueueMode; + replyToId?: string; + runId?: string; + } = {}, ) { const msg = message.trim(); if (!state.client || !state.connected || (!msg && !attachments?.length)) { @@ -87,12 +89,11 @@ export async function sendChatMessageWithGeneratedRunId( message: msg, attachments, runId, - ...(options.expectedLeafEntryId !== undefined + ...(options.queueMode !== "steer" && options.expectedLeafEntryId !== undefined ? { expectedLeafEntryId: options.expectedLeafEntryId } - : expectedLeafEntryId !== undefined + : options.queueMode !== "steer" && expectedLeafEntryId !== undefined ? { expectedLeafEntryId } : {}), - ...(options.expectedRunId ? { expectedRunId: options.expectedRunId } : {}), ...(options.queueMode ? { queueMode: options.queueMode } : {}), ...(options.replyToId ? { replyToId: options.replyToId } : {}), }); @@ -114,28 +115,23 @@ const resetRetryState = ( sendAttempts: 0, sendError: undefined, sendRequestStartedAtMs: undefined, - sendRunId: entry.sendState === "failed" ? generateUUID() : entry.sendRunId, + sendRunId: + entry.sendState === "failed" && entry.queueMode !== "steer" ? generateUUID() : entry.sendRunId, sendState, }); -export const steerSendDependencies: SteerSendDependencies = { - loadChatHistory: (host) => void loadChatHistory(host), - resumeRestoredOutbox: (host, itemId) => { - const restoredOutbox = findStoredOutbox(host as ChatHost, itemId); - if (!host.chatRunId && restoredOutbox) { - void scheduleStoredChatOutboxDrain( - host as ChatHost, - restoredOutbox, - chatOutboxDrainDependencies, - ); - } - }, - sendChatMessage: (host, message, attachments, options) => - sendChatMessageWithGeneratedRunId(host, message, attachments, options), -}; - -export const steerQueuedChatMessage = (host: ChatHost, id: string) => - steerQueuedChatMessageLifecycle(host, id, steerSendDependencies); +export async function steerQueuedChatMessage(host: ChatHost, id: string): Promise { + if (isQueuedMessageBeingEdited(host, id)) { + setChatError(host, QUEUED_MESSAGE_STEER_CONFLICT_ERROR); + return; + } + const item = updateQueuedMessage(host, id, (entry) => ({ ...entry, queueMode: "steer" })); + if (!item) { + setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR); + return; + } + await retryQueuedChatMessage(host, id); +} export const resumeStoredChatOutboxes = (host: ChatHost) => resumeStoredChatOutboxesDrain(host, chatOutboxDrainDependencies); @@ -220,7 +216,7 @@ export function moveQueuedChatMessage( } export async function retryQueuedChatMessage(host: ChatHost, id: string) { - let item = host.chatQueue.find((entry) => entry.id === id); + const item = host.chatQueue.find((entry) => entry.id === id); if (isQueuedMessageRetryBlocked(host, id)) { setChatError(host, QUEUED_MESSAGE_RETRY_CONFLICT_ERROR); return; @@ -229,47 +225,11 @@ export async function retryQueuedChatMessage(host: ChatHost, id: string) { !item || item.pendingRunId || item.sendState === "executing-command" || - isInflightSteer(item) || item.sendState === "sending" || item.sendState === "waiting-model" ) { return; } - if (item.kind === "steered") { - if (!host.connected || !host.client) { - setChatError(host, t("chat.sendErrors.steerRunNoLongerActive")); - return; - } - if (hasDirectSessionRun(host)) { - const retry = updateQueuedMessage(host, id, (entry) => ({ - ...entry, - sendAttempts: 0, - sendError: undefined, - sendRequestStartedAtMs: undefined, - sendState: "waiting-idle", - })); - if (!retry) { - setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR); - return; - } - await steerQueuedChatMessageLifecycle(host, id, steerSendDependencies); - return; - } - const converted = updateQueuedMessage(host, id, (entry) => { - const { - kind: _kind, - pendingRunId: _pendingRunId, - steerTargetRunId: _steerTargetRunId, - ...queued - } = entry; - return resetRetryState(queued, reconnectSafeQueuedSendState(host)); - }); - if (!converted) { - setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR); - return; - } - item = converted; - } let outbox = findStoredOutbox(host, item.id); if (!outbox) { const wasVolatile = isVolatileQueuedMessage(host, item.id); @@ -310,7 +270,13 @@ export async function retryQueuedChatMessage(host: ChatHost, id: string) { setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR); return; } - const drain = scheduleStoredChatOutboxDrain(host, outbox, chatOutboxDrainDependencies); + const drain = scheduleStoredChatOutboxDrain( + host, + outbox, + chatOutboxDrainDependencies, + retry.queueMode ? retry.id : undefined, + retry.queueMode ? { routingSessionKey: host.sessionKey } : undefined, + ); if (host.chatSending && host.chatSendingScopeKey === storedChatOutboxScopeKey(outbox)) { void drain; return; diff --git a/ui/src/pages/chat/chat-send-delivery.ts b/ui/src/pages/chat/chat-send-delivery.ts index b25f476cc4f3..780320b0938e 100644 --- a/ui/src/pages/chat/chat-send-delivery.ts +++ b/ui/src/pages/chat/chat-send-delivery.ts @@ -45,6 +45,11 @@ import { requestChatSend, requestSkillWorkshopRevisionChatSend, } from "./chat-send-request.ts"; +import { + formatTerminalChatSendAckError, + OFFLINE_QUEUE_STORAGE_ERROR, + surfaceChatDeliveryFailure, +} from "./chat-send-support.ts"; import { chatSendAckServerTimingEventFields, recordChatSendTiming, @@ -63,11 +68,6 @@ import { resetChatInputHistoryNavigation } from "./input-history.ts"; import { controlUiNowMs, roundedControlUiDurationMs } from "./performance.ts"; import { hasDirectSessionRun, isChatBusy, reconcileChatRunLifecycle } from "./run-lifecycle.ts"; import { resetChatScroll, scheduleChatScroll } from "./scroll.ts"; -import { - formatTerminalChatSendAckError, - OFFLINE_QUEUE_STORAGE_ERROR, - surfaceChatDeliveryFailure, -} from "./steer-lifecycle.ts"; import { resetToolStream } from "./tool-stream.ts"; import { buildUserChatMessageContentBlocks } from "./user-message-content.ts"; @@ -306,7 +306,8 @@ async function sendQueuedChatMessage( runId, sessionKey, agentId: prepared.agentId, - ...(options?.expectedLeafEntryId !== undefined + ...(prepared.queueMode ? { queueMode: prepared.queueMode } : {}), + ...(prepared.queueMode !== "steer" && options?.expectedLeafEntryId !== undefined ? { expectedLeafEntryId: options.expectedLeafEntryId } : {}), ...(prepared.replyToId ? { replyToId: prepared.replyToId } : {}), @@ -614,6 +615,8 @@ export async function deliverChatQueueItem( if ( drainResult === undefined && routeVisible && + !admittedItem.queueMode && + !sendOptions.allowActiveRunSend && (isChatBusy(host) || hasDirectSessionRun(host)) ) { const parked = finishChatDeliveryAdmission( diff --git a/ui/src/pages/chat/chat-send-queue-state.ts b/ui/src/pages/chat/chat-send-queue-state.ts index 2efaf2c1928a..fb6b50f20440 100644 --- a/ui/src/pages/chat/chat-send-queue-state.ts +++ b/ui/src/pages/chat/chat-send-queue-state.ts @@ -15,13 +15,13 @@ import { updateVolatileQueuedMessage, } from "./chat-queue.ts"; import type { ChatHost } from "./chat-send-contract.ts"; +import { OFFLINE_QUEUE_STORAGE_ERROR, surfaceChatDeliveryFailure } from "./chat-send-support.ts"; import { recordChatSendTiming, schedulePendingSendPaintTiming } from "./chat-send-timing.ts"; import { getPendingChatPickerPatch } from "./chat-session.ts"; import { storedChatOutboxScopeKey, type StoredChatOutboxScope } from "./composer-persistence.ts"; import { controlUiNowMs } from "./performance.ts"; import { hasDirectSessionRun, isChatBusy } from "./run-lifecycle.ts"; import { scheduleChatScroll } from "./scroll.ts"; -import { OFFLINE_QUEUE_STORAGE_ERROR, surfaceChatDeliveryFailure } from "./steer-lifecycle.ts"; const SKILL_WORKSHOP_CONNECTION_CHANGED_ERROR = "Skill Workshop revision request cancelled because the Gateway connection changed."; @@ -45,6 +45,7 @@ export function enqueuePendingSendMessage( skillWorkshopRevision?: ChatQueueItem["skillWorkshopRevision"], replyToId?: string, resumedOrderKey?: number, + queueMode?: ChatQueueItem["queueMode"], ): ChatQueueItem | null { const trimmed = text.trim(); const hasAttachments = Boolean(attachments && attachments.length > 0); @@ -64,6 +65,7 @@ export function enqueuePendingSendMessage( sendAttempts: 0, sendRunId: generateUUID(), sendState, + ...(queueMode ? { queueMode } : {}), sendSubmittedAtMs: submittedAtMs, sessionKey: host.sessionKey, agentId: scopedAgentIdForSession(host, host.sessionKey), @@ -192,7 +194,12 @@ export function finishChatDeliveryAdmission( } return "pending"; } - if (routeVisible(current.agentId) && (isChatBusy(host) || hasDirectSessionRun(host))) { + const sendsDuringActiveRun = Boolean(current.queueMode || options?.allowActiveRunSend); + if ( + !sendsDuringActiveRun && + routeVisible(current.agentId) && + (isChatBusy(host) || hasDirectSessionRun(host)) + ) { const parked = setState(host.connected && host.client ? "waiting-idle" : "waiting-reconnect"); if (!parked) { setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR); diff --git a/ui/src/pages/chat/chat-send-request.ts b/ui/src/pages/chat/chat-send-request.ts index 25abfe4a404b..067a065ed165 100644 --- a/ui/src/pages/chat/chat-send-request.ts +++ b/ui/src/pages/chat/chat-send-request.ts @@ -22,7 +22,6 @@ export async function requestChatSend( queueMode?: QueueMode; replyToId?: string; expectedLeafEntryId?: string | null; - expectedRunId?: string; }, ): Promise { const routing = resolveChatSendRouting(state, params); @@ -43,7 +42,6 @@ export async function requestChatSend( ...(params.expectedLeafEntryId !== undefined ? { expectedLeafEntryId: params.expectedLeafEntryId } : {}), - ...(params.expectedRunId ? { expectedRunId: params.expectedRunId } : {}), idempotencyKey: params.runId, attachments: buildChatApiAttachments(params.attachments), }); diff --git a/ui/src/pages/chat/chat-send-submit.ts b/ui/src/pages/chat/chat-send-submit.ts index 959969ba8a97..3d43cfcf3c1e 100644 --- a/ui/src/pages/chat/chat-send-submit.ts +++ b/ui/src/pages/chat/chat-send-submit.ts @@ -26,7 +26,7 @@ import { readQueuedMessageById, } from "./chat-queue.ts"; import { isTerminalFailureChatSendAck } from "./chat-send-ack.ts"; -import { sendChatMessageWithGeneratedRunId, steerSendDependencies } from "./chat-send-actions.ts"; +import { sendChatMessageWithGeneratedRunId } from "./chat-send-actions.ts"; import { captureChatCommandComposerRecovery, cancelChatDelivery, @@ -48,6 +48,10 @@ import { waitForPendingChatSettings, } from "./chat-send-queue-state.ts"; import { resolveDisplayedLeafEntryId } from "./chat-send-request.ts"; +import { + formatTerminalChatSendAckError, + OFFLINE_QUEUE_STORAGE_ERROR, +} from "./chat-send-support.ts"; import { recordChatSendTiming } from "./chat-send-timing.ts"; import { getPendingChatPickerPatch } from "./chat-session.ts"; import { withChatSubmitGuard } from "./chat-submit-guard.ts"; @@ -65,11 +69,6 @@ import { isChatBusy, isChatStopCommand, } from "./run-lifecycle.ts"; -import { - formatTerminalChatSendAckError, - OFFLINE_QUEUE_STORAGE_ERROR, - sendQueuedChatMessageWithQueueMode as sendQueuedChatMessageWithQueueModeLifecycle, -} from "./steer-lifecycle.ts"; type ChatSendSubmitOptions = { attachmentsOverride?: readonly ChatAttachment[]; @@ -520,6 +519,16 @@ export async function handleSendChat( const pendingSettings = getPendingChatPickerPatch(host, submittedSessionKey); const waitingForSettings = Boolean(pendingSettings); + const directRunActive = hasDirectSessionRun(host); + // Only an explicit browser override replaces inherited Gateway policy. + const followUpMode = + opts?.followUpMode ?? + host.chatFollowUpMode ?? + normalizeChatFollowUpModeOverride(host.settings?.chatFollowUpMode); + const activeRunQueueMode = + !skillWorkshopRevision && directRunActive && followUpMode !== "queue" + ? followUpMode + : undefined; // The edited row hands its place to the replacement and is retired by the same // store write, so a rejected write leaves the original queued and editable. const resumedEdit = @@ -534,6 +543,7 @@ export async function handleSendChat( skillWorkshopRevision, replyToId, resumedEdit?.orderKey, + activeRunQueueMode, ); if (!queued) { return; @@ -572,6 +582,9 @@ export async function handleSendChat( const sendResult = await deliverChatQueueItem(host, queued, { previousDraft: cleared.previousDraft, previousAttachments: cleared.previousAttachments, + ...(!skillWorkshopRevision && directRunActive && followUpMode !== "queue" + ? { allowActiveRunSend: true } + : {}), ...(expectedLeafEntryId !== undefined ? { expectedLeafEntryId } : {}), ...(pendingSettings ? { pendingSettings } : {}), restoreAttachments: Boolean(messageOverride && opts?.restoreDraft), @@ -588,24 +601,6 @@ export async function handleSendChat( (isChatBusy(host) || hasDirectSessionRun(host)); if (pendingBusySend) { recordChatSendTiming(host, pending, "queued-busy", submittedAtMs); - // Only an explicit browser override replaces inherited Gateway policy. - const followUpMode = - opts?.followUpMode ?? - host.chatFollowUpMode ?? - normalizeChatFollowUpModeOverride(host.settings?.chatFollowUpMode); - if ( - !skillWorkshopRevision && - followUpMode !== "queue" && - host.connected && - hasDirectSessionRun(host) - ) { - void sendQueuedChatMessageWithQueueModeLifecycle( - host, - pending.id, - followUpMode, - steerSendDependencies, - ); - } } if ( sendResult !== "failed" && diff --git a/ui/src/pages/chat/chat-send-support.ts b/ui/src/pages/chat/chat-send-support.ts new file mode 100644 index 000000000000..5b5d2563ac28 --- /dev/null +++ b/ui/src/pages/chat/chat-send-support.ts @@ -0,0 +1,151 @@ +import { asOptionalRecord, isRecord } from "@openclaw/normalization-core/record-coerce"; +import type { SessionsListResult } from "../../api/types.ts"; +import type { ChatAttachment, ChatQueueItem } from "../../lib/chat/chat-types.ts"; +import { formatUiError } from "../../lib/format-error.ts"; +import { resolveSessionDisplayName } from "../../lib/session-display.ts"; +import { visibleSessionMatches } from "../../lib/sessions/index.ts"; +import { + areUiSessionKeysEquivalent, + isUiGlobalSessionKey, + normalizeAgentId, +} from "../../lib/sessions/session-key.ts"; +import { showToast } from "../../lib/toast.ts"; +import { getChatAttachmentDataUrl } from "./attachment-payload-store.ts"; +import type { TerminalFailureChatSendAck } from "./chat-send-ack.ts"; +import type { ChatState } from "./chat-state-contract.ts"; +import { readChatSessionProjectionScope, reduceChatSessionProjection } from "./history-merge.ts"; +import { appendChatMessageToCache, readChatMessagesFromCache } from "./session-message-cache.ts"; +import { buildUserChatMessageContentBlocks } from "./user-message-content.ts"; + +type ChatSendSupportHost = ChatState & { + sessionsResult?: SessionsListResult | null; +}; + +export const OFFLINE_QUEUE_STORAGE_ERROR = + "Could not store this message for reconnect. Free browser storage or reconnect before sending."; + +export function formatTerminalChatSendAckError( + ack: TerminalFailureChatSendAck, + context: "chat" | "detached", +): string { + return ack.status === "error" + ? "Chat failed before the run started; try again." + : context === "detached" + ? "The active run ended before the detached message was accepted." + : "The run ended before the message was accepted."; +} + +export function chatMessagesContainQueuedSend( + messages: unknown, + item: ChatQueueItem, + userRoleOnly = false, +): boolean { + return findQueuedSendMessageIndex(messages, item, userRoleOnly) >= 0; +} + +function findQueuedSendMessageIndex( + messages: unknown, + item: ChatQueueItem, + userRoleOnly = false, +): number { + if (!item.sendRunId) { + return -1; + } + return (Array.isArray(messages) ? messages : []).findIndex((message) => { + if (!isRecord(message)) { + return false; + } + // Render retirement requires a user-role entry: an assistant entry can + // carry the same run key without proving the queued turn is visible. + if (userRoleOnly && message.role !== "user") { + return false; + } + const markerIdempotencyKey = asOptionalRecord(message["__openclaw"])?.idempotencyKey; + const idempotencyKey = markerIdempotencyKey ?? message.idempotencyKey; + return idempotencyKey === item.sendRunId || idempotencyKey === `${item.sendRunId}:user`; + }); +} + +function durableDeliveredAttachments( + attachments: readonly ChatAttachment[] | undefined, +): ChatAttachment[] | undefined { + return attachments?.flatMap((attachment) => { + // Composer uploads keep their bytes in the payload store; queue rows carry + // metadata only. Resolve through the store before queue ownership ends. + const dataUrl = getChatAttachmentDataUrl(attachment); + return dataUrl ? [{ ...attachment, dataUrl, previewUrl: dataUrl }] : []; + }); +} + +export function preserveQueuedUserTurn(state: ChatSendSupportHost, item: ChatQueueItem): void { + const runId = item.sendRunId; + const sessionKey = item.sessionKey ?? state.sessionKey; + if (!runId) { + return; + } + const content = buildUserChatMessageContentBlocks( + item.text, + durableDeliveredAttachments(item.attachments), + ); + if (!content.length) { + return; + } + const userMessage = { + role: "user", + content, + timestamp: item.createdAt, + __openclaw: { idempotencyKey: `${runId}:user` }, + }; + if (visibleSessionMatches(state, sessionKey, item.agentId)) { + if (!chatMessagesContainQueuedSend(state.chatMessages, item, true)) { + const scope = readChatSessionProjectionScope(state, { + sessionKey, + agentId: item.agentId, + }); + reduceChatSessionProjection( + state, + { type: "sendPending", runId, message: userMessage }, + { scope }, + ); + } + return; + } + if (!state.chatMessagesBySession) { + return; + } + const target = { sessionKey, agentId: item.agentId }; + const cached = readChatMessagesFromCache(state.chatMessagesBySession, state, target); + if (!chatMessagesContainQueuedSend(cached, item, true)) { + appendChatMessageToCache(state.chatMessagesBySession, state, target, userMessage); + } +} + +type ChatDeliveryFailureHost = Parameters[0] & { + lastError?: string | null; + chatError?: string | null; + sessionsResult?: SessionsListResult | null; +}; + +/** Surface a terminal delivery failure in the owning pane or a named toast. */ +export function surfaceChatDeliveryFailure( + host: ChatDeliveryFailureHost, + sessionKey: string, + agentId: string | undefined, + error: string, +): void { + const message = formatUiError(error); + if (visibleSessionMatches(host, sessionKey, agentId)) { + host.lastError = message; + host.chatError = message; + return; + } + const scopedAgentId = agentId ? normalizeAgentId(agentId) : undefined; + const row = host.sessionsResult?.sessions.find( + (session) => + areUiSessionKeysEquivalent(session.key, sessionKey) && + (!isUiGlobalSessionKey(sessionKey) || + !scopedAgentId || + (session.agentId !== undefined && normalizeAgentId(session.agentId) === scopedAgentId)), + ); + showToast({ message: `${resolveSessionDisplayName(sessionKey, row)}: ${message}` }); +} diff --git a/ui/src/pages/chat/chat-send.test.ts b/ui/src/pages/chat/chat-send.test.ts index 907f09236a83..c7c31c2cd5e2 100644 --- a/ui/src/pages/chat/chat-send.test.ts +++ b/ui/src/pages/chat/chat-send.test.ts @@ -9,12 +9,12 @@ import { createDeferred } from "../../../../test/helpers/promise.js"; import { GatewayRequestError } from "../../api/gateway.ts"; import type { AgentsListResult, GatewaySessionRow, SessionsListResult } from "../../api/types.ts"; import { rememberChatMetadata } from "../../lib/chat/chat-metadata-store.ts"; +import type { ChatQueueItem } from "../../lib/chat/chat-types.ts"; import { buildFallbackSlashCommands, buildSlashCommandsFromEntries, replaceSlashCommands, } from "../../lib/chat/commands.ts"; -import { extractText } from "../../lib/chat/message-extract.ts"; import { createResolvedModelPatch } from "../../test-helpers/chat-model.ts"; import { createTestGatewayClient, @@ -34,6 +34,7 @@ import * as chatCommandExecutor from "./chat-command-executor.ts"; import type { executeSlashCommand } from "./chat-command-executor.ts"; import { makeChatHost, makeRequestMock } from "./chat-host.test-support.ts"; import { UNCONFIRMED_CHAT_SEND_ERROR } from "./chat-outbox-drain.ts"; +import { chatOutboxOwner } from "./chat-outbox-owner.ts"; import { renderChatPaneComposerControls } from "./chat-pane-session-controls.ts"; import type { ChatHost } from "./chat-send-contract.ts"; import { @@ -43,7 +44,6 @@ import { } from "./chat-session.ts"; import { patchChatSessionSettings } from "./chat-settings-patches.ts"; import type { ChatPageHost } from "./chat-state-host.ts"; -import { buildChatItems } from "./chat-thread-build.ts"; import { admitStoredChatComposerQueueItem, listStoredChatOutboxes, @@ -71,6 +71,30 @@ function asChatPageHost(host: TestChatHost): ChatPageHost { return host as ChatPageHost; } +function writeChatQueueForScope( + host: TestChatHost, + sessionKey: string, + queue: ChatQueueItem[], + agentId?: string, +): void { + const scope = resolveStoredChatOutboxScope(host, sessionKey, agentId); + chatOutboxOwner(host).replace(host, scope, queue); +} + +function setTransientQueuedMessageProjection( + host: TestChatHost, + sessionKey: string, + item: ChatQueueItem, +): boolean { + const owner = chatOutboxOwner(host); + const scope = resolveStoredChatOutboxScope(host, sessionKey, item.agentId); + if (!owner.durable(host, item.id)) { + return false; + } + owner.projectLive(host, scope, item.id, item); + return true; +} + function requireChatMessageCache(host: ChatHost): ChatMessageCache { if (!host.chatMessagesBySession) { throw new Error("Expected chat message cache"); @@ -164,14 +188,12 @@ let loadChatHistory: typeof import("./chat-history.ts").loadChatHistory; let clearPendingQueueItemsForRun: typeof import("./chat-queue.ts").clearPendingQueueItemsForRun; let admitQueuedMessageForSession: typeof import("./chat-queue.ts").admitQueuedMessageForSession; let removeQueuedMessage: typeof import("./chat-queue.ts").removeQueuedMessage; -let setTransientQueuedMessageProjection: typeof import("./chat-queue.ts").setTransientQueuedMessageProjection; let removeDeliveredQueuedChatSendForRun: typeof import("./chat-queue.ts").removeDeliveredQueuedChatSendForRun; let removeVisibleOrScopedQueuedMessageWithoutReleasing: typeof import("./chat-queue.ts").removeVisibleOrScopedQueuedMessageWithoutReleasing; let markQueuedChatSendsWaitingForReconnect: typeof import("./chat-queue.ts").markQueuedChatSendsWaitingForReconnect; let subscribeChatOutboxProjection: typeof import("./chat-queue.ts").subscribeChatOutboxProjection; let syncVisibleChatQueueProjection: typeof import("./chat-queue.ts").syncVisibleChatQueueProjection; let readChatQueueForScope: typeof import("./chat-queue.ts").readChatQueueForScope; -let writeChatQueueForScope: typeof import("./chat-queue.ts").writeChatQueueForScope; let flushChatQueueForEvent: typeof import("./chat-send-actions.ts").flushChatQueueForEvent; let retryReconnectableQueuedChatSends: typeof import("./chat-send-actions.ts").retryReconnectableQueuedChatSends; let retryQueuedChatMessage: typeof import("./chat-send-actions.ts").retryQueuedChatMessage; @@ -198,13 +220,11 @@ async function loadChatHelpers(): Promise { clearPendingQueueItemsForRun, removeDeliveredQueuedChatSendForRun, removeQueuedMessage, - setTransientQueuedMessageProjection, markQueuedChatSendsWaitingForReconnect, removeVisibleOrScopedQueuedMessageWithoutReleasing, readChatQueueForScope, subscribeChatOutboxProjection, syncVisibleChatQueueProjection, - writeChatQueueForScope, } = await import("./chat-queue.ts")); } @@ -3691,13 +3711,15 @@ describe("handleSendChat", () => { expect(host.request).toHaveBeenCalledWith( "chat.send", expect.objectContaining({ - expectedRunId: "active-run", message: "steer this queued follow-up now", queueMode: "steer", sessionKey: "agent:main:main", }), ), ); + const payload = findRequestPayload(host.request, "chat.send", "steer override payload"); + expect(payload).not.toHaveProperty("expectedRunId"); + expect(payload).not.toHaveProperty("expectedLeafEntryId"); }); it("fails visibly when a busy send cannot be parked after durable admission", async () => { @@ -3761,77 +3783,52 @@ describe("handleSendChat", () => { }), ), ); - expect(host.chatRunId).toBe("run-1"); - await waitForFast(() => expect(host.chatQueue[0]?.kind).toBe("steered")); - expect(host.chatQueue[0]?.pendingRunId).toBe("run-1"); + expect(host.chatRunId).toBe("steer-run"); + expect(host.chatQueue).toEqual([ + expect.objectContaining({ + queueMode: "steer", + sendState: "sending", + text: "tighten the plan", + }), + ]); + const payload = findRequestPayload(host.request, "chat.send", "default steer payload"); + expect(payload).not.toHaveProperty("expectedRunId"); + expect(payload).not.toHaveProperty("expectedLeafEntryId"); }); - it("steers an active-run send without waiting for older outbox reconciliation", async () => { - const olderHistory = createDeferred(); - let historyRequests = 0; - const replyTarget = { - messageId: "steer-behind-outbox-reply", - sourceMessageId: "steer-behind-outbox-source", - text: "reply context", - senderLabel: "User", + it("sends a fresh mode-bearing row ahead of older outbox reconciliation", async () => { + const older = { + id: "older-reconciliation-head", + text: "already delivered older turn", + createdAt: 1, + sendAttempts: 1, + sendRunId: "older-reconciliation-run", + sendState: "waiting-reconnect" as const, + sessionKey: "agent:main", }; const host = makeChatHost({ requestHandlers: { "chat.history": () => { - historyRequests += 1; - return historyRequests === 1 - ? olderHistory.promise - : Promise.resolve({ - messages: [], - sessionInfo: row("agent:main", { hasActiveRun: true, status: "running" }), - }); + throw new Error("fresh active-run sends must not wait for older history"); }, - "chat.send": { status: "started", runId: "steer-with-backlog-run" }, + "chat.send": { status: "started", runId: "fresh-steer-run" }, }, chatMessage: "steer without waiting for history", - chatQueue: [ - { - id: "older-reconciliation-head", - text: "already delivered older turn", - createdAt: 1, - sendAttempts: 1, - sendRunId: "older-reconciliation-run", - sendState: "waiting-reconnect", - sessionKey: "agent:main", - }, - ], - chatReplyTarget: replyTarget, + chatQueue: [older], chatRunId: "active-run", - chatDisplayedLeafEntryId: "leaf-active", - chatStream: "Working...", settings: { chatFollowUpMode: "steer" }, }); - admitHostQueueItems(host); + expect(admitQueuedMessageForSession(host, host.sessionKey, older)).toBe(true); - const send = handleSendChat(host); - await waitForFast(() => expect(historyRequests).toBe(1)); - await waitForFast(() => - expect(host.request).toHaveBeenCalledWith( - "chat.send", - expect.objectContaining({ - message: "steer without waiting for history", - queueMode: "steer", - replyToId: "steer-behind-outbox-source", - }), - ), - ); - await send; + await handleSendChat(host); - expect(host.chatReplyTarget).toBeNull(); - expect(host.chatRunId).toBe("active-run"); - expect(host.chatQueue.find((item) => item.kind === "steered")?.replyToId).toBe( - "steer-behind-outbox-source", - ); - olderHistory.resolve({ - messages: [{ role: "user", __openclaw: { idempotencyKey: "older-reconciliation-run:user" } }], - sessionInfo: row("agent:main", { hasActiveRun: true, status: "running" }), + const sends = host.request.mock.calls.filter(([method]) => method === "chat.send"); + expect(sends).toHaveLength(1); + expect(sends[0]?.[1]).toMatchObject({ + message: "steer without waiting for history", + queueMode: "steer", }); - await waitForFast(() => expect(historyRequests).toBe(2)); + expect(host.request).not.toHaveBeenCalledWith("chat.history", expect.anything()); }); it("leaves active-run resolution to the Gateway while its effective mode is loading", async () => { @@ -3879,7 +3876,13 @@ describe("handleSendChat", () => { }), ), ); - await waitForFast(() => expect(host.chatQueue).toHaveLength(0)); + expect(host.chatQueue).toEqual([ + expect.objectContaining({ + queueMode, + sendState: "sending", + text: `send with ${queueMode}`, + }), + ]); }, ); @@ -3969,12 +3972,11 @@ describe("handleSendChat", () => { }); await handleSendChat(host); - await waitForFast(() => expect(host.chatQueue[0]?.kind).toBe("steered")); expect(host.chatQueue).toHaveLength(1); expect(host.chatQueue[0]).toMatchObject({ - kind: "steered", - pendingRunId: "steer-run", + queueMode: "steer", + sendState: "sending", sendRunId: wireRunId, text: "tighten the live plan", }); @@ -3995,7 +3997,7 @@ describe("handleSendChat", () => { expect(host.chatQueue).toHaveLength(1); expect(host.chatQueue[0]?.text).toBe("queued while offline"); expect(host.chatQueue[0]?.sendState).toBe("waiting-reconnect"); - expect(host.chatQueue[0]?.kind).not.toBe("steered"); + expect(host.chatQueue[0]?.queueMode).toBe("steer"); }); it("requires durable admission for offline input queued behind an active run", async () => { @@ -8206,1091 +8208,84 @@ describe("handleSendChat", () => { expect(host.chatQueue).toHaveLength(1); expect(host.chatQueue[0]?.text).toBe("/steer tighten the plan"); - expect(host.chatQueue[0]?.kind).toBe("steered"); expect(host.chatQueue[0]?.pendingRunId).toBe("run-1"); }); - it("steers a queued message into the active run without replacing run tracking", async () => { - const original = { id: "queued-1", text: "tighten the plan", createdAt: 1 }; - const host = makeChatHost({ - requestHandlers: { - "chat.send": { status: "started", runId: "steer-run" }, - }, - chatRunId: "run-1", - chatDisplayedLeafEntryId: "leaf-active", - chatStream: "Working...", - chatQueue: [original], + it("sends a queued row through the generic outbox with only its durable steer mode", async () => { + const ack = createDeferred(); + const original = { + id: "queued-steer", + text: "tighten the plan", + createdAt: 1, + sendRunId: "stable-steer-send", + sendState: "waiting-idle" as const, sessionKey: "agent:main:main", + agentId: "main", + }; + const host = makeChatHost({ + requestHandlers: { "chat.send": () => ack.promise }, + chatRunId: "active-run", + chatQueue: [original], + sessionKey: original.sessionKey, }); expect(admitQueuedMessageForSession(host, host.sessionKey, original)).toBe(true); - await steerQueuedChatMessage(host, "queued-1"); + const sending = steerQueuedChatMessage(host, original.id); + await waitForFast(() => + expect(host.request).toHaveBeenCalledWith("chat.send", expect.anything()), + ); - const payload = findRequestPayload(host.request, "chat.send", "steered chat send payload"); - const idempotencyKey = payload.idempotencyKey; - expect(typeof idempotencyKey).toBe("string"); - expect(uuidPattern.test(idempotencyKey as string)).toBe(true); - expect(payload).toEqual({ - sessionKey: "agent:main:main", - message: "tighten the plan", - deliver: false, - queueMode: "steer", - expectedRunId: "run-1", - expectedLeafEntryId: "leaf-active", - idempotencyKey, - attachments: undefined, - }); - expect(host.chatRunId).toBe("run-1"); - expect(host.chatStream).toBe("Working..."); - expect(host.chatQueue).toHaveLength(1); - expect(host.chatQueue[0]?.text).toBe("tighten the plan"); - expect(host.chatQueue[0]?.kind).toBe("steered"); - expect(host.chatQueue[0]?.pendingRunId).toBe("run-1"); - expect(host.chatQueue[0]?.sendRunId).toBe(idempotencyKey); - }); - - it("steers a queued message when only the session row reports an active run", async () => { - const original = { id: "queued-1", text: "tighten the plan", createdAt: 1 }; - const host = makeChatHost({ - requestHandlers: { - "chat.send": { status: "started", runId: "steer-run" }, - }, - chatQueue: [original], - sessionKey: "agent:main:main", - sessionsResult: createSessionsResult([ - row("agent:main:main", { - hasActiveRun: true, - activeRunIds: ["active-run"], - activeLeafEntryId: "leaf-active", - status: "running", - }), - ]), - }); - expect(admitQueuedMessageForSession(host, host.sessionKey, original)).toBe(true); - - await steerQueuedChatMessage(host, original.id); - - const payload = findRequestPayload(host.request, "chat.send", "session-row steer payload"); + const payload = findRequestPayload(host.request, "chat.send", "queued steer payload"); expect(payload).toMatchObject({ - sessionKey: "agent:main:main", - message: "tighten the plan", - deliver: false, + sessionKey: original.sessionKey, + message: original.text, queueMode: "steer", - expectedRunId: "active-run", + idempotencyKey: original.sendRunId, }); - expect(host.chatRunId).toBeNull(); - expect(host.chatQueue).toEqual([ - expect.objectContaining({ - kind: "steered", - pendingRunId: "steer-run", - sendRunId: payload.idempotencyKey, - text: original.text, - }), - ]); - expect(listStoredChatOutboxes(host)).toEqual([]); - }); - - it("materializes an immediately completed steer before retiring its queue row", async () => { - const original = { - id: "completed-steer", - text: "record this completed steer", - createdAt: 1, - sendRunId: "completed-steer-run", - sendState: "waiting-idle" as const, - sessionKey: "agent:main:main", - }; - const host = makeChatHost({ - requestHandlers: { - "chat.send": { status: "ok", runId: "completed-steer-run" }, - "chat.history": () => idleChatHistory("agent:main:main"), - }, - chatQueue: [original], - chatRunId: "active-run", - chatDisplayedLeafEntryId: "leaf-active", - sessionKey: original.sessionKey, - }); - expect(admitQueuedMessageForSession(host, host.sessionKey, original)).toBe(true); - - await steerQueuedChatMessage(host, original.id); - - expect(host.chatQueue).toEqual([]); - expect(host.chatMessages).toEqual([ - expect.objectContaining({ - role: "user", - __openclaw: { idempotencyKey: "completed-steer-run:user" }, - }), - ]); - expect(JSON.stringify(host.chatMessages[0])).toContain(original.text); - expect(host.request).toHaveBeenCalledWith( - "chat.history", - expect.objectContaining({ sessionKey: original.sessionKey }), - ); - }); - - it("dedupes the steer chip after authoritative history owns its rendered user turn", async () => { - const file = new File(["history"], "history.txt", { type: "text/plain" }); - const attachment = registerChatAttachmentPayload({ - attachment: { - id: "history-steer-attachment", - fileName: "history.txt", - mimeType: "text/plain", - sizeBytes: file.size, - }, - dataUrl: "data:text/plain;base64,aGlzdG9yeQ==", - file, - }); - const historyUser = { - role: "user", - content: [{ type: "text", text: "history-owned steer" }], - __openclaw: { idempotencyKey: "history-steer:user", seq: 1 }, - }; - const host = makeChatHost({ - client: clientWithRequest( - makeRequestMock({ - "chat.history": { messages: [historyUser] }, - }), - ), - chatQueue: [ - { - id: "history-steer-chip", - text: "history-owned steer", - createdAt: 1, - kind: "steered", - pendingRunId: "active-run", - sendRunId: "history-steer", - attachments: [attachment], - }, - ], - }); - - await loadChatHistory(host); - - expect(host.chatMessages).toEqual([historyUser]); - expect(host.chatQueue).toEqual([]); - expect(getChatAttachmentDataUrl(attachment)).toBeNull(); - }); - - it("retires a lingering steer chip for a different run from a top-level history key", async () => { - const host = makeChatHost({ - client: clientWithRequest( - makeRequestMock({ - "chat.history": { - messages: [ - { - role: "user", - content: [{ type: "text", text: "cross-run steer" }], - idempotencyKey: "cross-run-steer", - __openclaw: { seq: 1 }, - }, - ], - }, - }), - ), - chatRunId: "current-run", - chatQueue: [ - { - id: "cross-run-steer-chip", - text: "cross-run steer", - createdAt: 1, - kind: "steered", - pendingRunId: "filtered-terminal-run", - sendRunId: "cross-run-steer", - }, - ], - }); - - await loadChatHistory(host); - - expect(host.chatMessages).toHaveLength(1); - expect(host.chatQueue).toEqual([]); - }); - - it("keeps an in-flight steer chip across authoritative history loads", async () => { - const inflight = { - id: "inflight-steer-chip", - text: "still awaiting acknowledgement", - createdAt: 1, - kind: "steered" as const, - pendingRunId: "active-run", - sendRunId: "inflight-steer", - sendState: "steering" as const, - }; - const host = makeChatHost({ - client: clientWithRequest( - makeRequestMock({ - "chat.history": { - messages: [ - { - role: "user", - content: [{ type: "text", text: inflight.text }], - __openclaw: { idempotencyKey: "inflight-steer:user", seq: 1 }, - }, - ], - }, - }), - ), - chatQueue: [inflight], - }); - - await loadChatHistory(host); - - expect(host.chatQueue).toEqual([inflight]); - }); - - it("does not steer a queued message without a durable claim", async () => { - const original = { id: "memory-only-steer", text: "do not lose this", createdAt: 1 }; - const host = makeChatHost({ - requestHandlers: {}, - chatRunId: "run-1", - chatDisplayedLeafEntryId: "leaf-active", - chatQueue: [original], - sessionKey: "agent:main:main", - }); - - await steerQueuedChatMessage(host, original.id); - - expect(host.request).not.toHaveBeenCalled(); - expect(host.chatQueue).toEqual([original]); - expect(host.lastError).toBe( - "Could not store this message for reconnect. Free browser storage or reconnect before sending.", - ); - }); - - it("retires a durable queued turn after accepted steer before terminal resume", async () => { - const original = { - id: "durable-steer", - text: "tighten the durable plan", - createdAt: 1, - sendAttempts: 0, - sendRunId: "queued-run", - sendState: "waiting-idle" as const, - sessionKey: "agent:main:main", - agentId: "main", - }; - const host = makeChatHost({ - requestHandlers: { - "chat.send": { status: "started", runId: "steer-run" }, - }, - chatRunId: "active-run", - chatDisplayedLeafEntryId: "leaf-active", - chatQueue: [original], - sessionKey: "agent:main:main", - }); - expect(admitQueuedMessageForSession(host, host.sessionKey, original)).toBe(true); - - await steerQueuedChatMessage(host, original.id); - - expect(listStoredChatOutboxes(host)).toEqual([]); + expect(payload).not.toHaveProperty("expectedRunId"); + expect(payload).not.toHaveProperty("expectedLeafEntryId"); expect(host.chatQueue).toEqual([ expect.objectContaining({ id: original.id, - text: original.text, - createdAt: original.createdAt, - kind: "steered", - pendingRunId: "active-run", + queueMode: "steer", sendRunId: original.sendRunId, + sendState: "sending", }), ]); - - clearPendingQueueItemsForRun(host, "active-run"); - host.chatRunId = null; - await retryReconnectableQueuedChatSends(host); - - expect(host.chatQueue).toEqual([]); - expect(host.request.mock.calls.filter(([method]) => method === "chat.send")).toHaveLength(1); - }); - - it("restores a steer indicator when its only pending copy disappears before the acknowledgement", async () => { - let resolveRequest: (value: { status: "started"; runId: string }) => void = () => {}; - - const original = { - id: "late-ack-durable-steer", - text: "tighten the durable plan", - createdAt: 1, - sendAttempts: 0, - sendRunId: "queued-run", - sendState: "waiting-idle" as const, - sessionKey: "agent:main:main", - agentId: "main", - }; - const host = makeChatHost({ - requestHandlers: { - "chat.send": () => - new Promise<{ status: "started"; runId: string }>((resolve) => { - resolveRequest = resolve; - }), - }, - chatRunId: "active-run", - chatDisplayedLeafEntryId: "leaf-active", - chatQueue: [original], - sessionKey: "agent:main:main", - }); - expect(admitQueuedMessageForSession(host, host.sessionKey, original)).toBe(true); - - const steering = steerQueuedChatMessage(host, original.id); - await waitForFast(() => expect(host.request).toHaveBeenCalledOnce()); - clearPendingQueueItemsForRun(host, "active-run"); - host.chatRunId = null; - resolveRequest({ status: "started", runId: "steer-run" }); - await steering; - - expect(listStoredChatOutboxes(host)).toEqual([]); - // The captured run died mid-request, so the restored chip binds to the - // steer's own gateway lifecycle instead of the dead run id. - expect(host.chatQueue).toEqual([ + expect(loadChatComposerSnapshot(host, host.sessionKey)?.queue).toEqual([ expect.objectContaining({ id: original.id, - kind: "steered", - pendingRunId: "steer-run", + queueMode: "steer", sendRunId: original.sendRunId, - steerTargetRunId: "active-run", - text: original.text, + sendState: "waiting-reconnect", }), ]); + + ack.resolve({ runId: original.sendRunId, status: "started" }); + await sending; }); - it("does not materialize an unacknowledged steer when its run terminates mid-request", async () => { - let resolveSteer: (value: { status: "error"; runId: string }) => void = () => {}; - - const original = { - id: "phantom-guard-steer", - text: "must not become a phantom turn", - createdAt: 1, - sendAttempts: 0, - sendRunId: "queued-run", - sendState: "waiting-idle" as const, - sessionKey: "agent:main:main", - agentId: "main", - }; - const host = makeChatHost({ - requestHandlers: { - "chat.send": () => - new Promise<{ status: "error"; runId: string }>((resolve) => { - resolveSteer = resolve; - }), - }, - chatRunId: "active-run", - chatDisplayedLeafEntryId: "leaf-active", - chatQueue: [original], - sessionKey: original.sessionKey, - }); - expect(admitQueuedMessageForSession(host, host.sessionKey, original)).toBe(true); - - const steering = steerQueuedChatMessage(host, original.id); - await waitForFast(() => expect(host.request).toHaveBeenCalledOnce()); - handlePageGatewayEvent(asChatPageHost(host), { - event: "chat", - payload: { - state: "final", - runId: "active-run", - sessionKey: "agent:main:main", - message: { - role: "assistant", - content: [{ type: "text", text: "finished before the steer landed" }], - timestamp: 2, - }, - }, - } as Parameters[1]); - const userTurns = () => - host.chatMessages.filter((message) => (message as { role?: string }).role === "user"); - expect(userTurns()).toEqual([]); - - resolveSteer({ status: "error", runId: "steer-error" }); - await steering; - - expect(userTurns()).toEqual([]); - const restored = [ - ...host.chatQueue, - ...listStoredChatOutboxes(host).flatMap((outbox) => outbox.queue), - ].find((item) => item.id === original.id); - expect(restored?.text).toBe(original.text); - }); - - it("materializes a steered user turn before a terminal event clears its chip", () => { - const host = makeChatHost({ - chatRunId: "active-run", - chatQueue: [ - { - id: "terminal-steer", - text: "keep this visible", - createdAt: 1, - kind: "steered", - pendingRunId: "active-run", - sendRunId: "steer-send-run", - sessionKey: "agent:main:main", - }, - ], - sessionKey: "agent:main:main", - }); - - handlePageGatewayEvent(asChatPageHost(host), { - event: "chat", - payload: { - state: "final", - runId: "active-run", - sessionKey: "agent:main:main", - message: { - role: "assistant", - content: [{ type: "text", text: "done" }], - timestamp: 2, - }, - }, - } as Parameters[1]); - - expect(host.chatQueue).toEqual([]); - expect(host.chatMessages).toHaveLength(2); - expect(host.chatMessages[0]).toMatchObject({ - role: "user", - __openclaw: { idempotencyKey: "steer-send-run:user" }, - }); - expect(JSON.stringify(host.chatMessages[0])).toContain("keep this visible"); - }); - - it("still materializes the user turn when an assistant entry carries the steer's run key", () => { - const assistantWithRunKey = { - role: "assistant", - content: [{ type: "text", text: "assistant reply for the same run" }], - timestamp: 1, - __openclaw: { idempotencyKey: "steer-send-run" }, - }; - const host = makeChatHost({ - chatRunId: "active-run", - chatMessages: [assistantWithRunKey], - chatQueue: [ - { - id: "assistant-key-steer", - text: "user turn must still appear", - createdAt: 2, - kind: "steered", - pendingRunId: "active-run", - sendRunId: "steer-send-run", - sessionKey: "agent:main:main", - }, - ], - sessionKey: "agent:main:main", - }); - - handlePageGatewayEvent(asChatPageHost(host), { - event: "chat", - payload: { - state: "final", - runId: "active-run", - sessionKey: "agent:main:main", - message: { - role: "assistant", - content: [{ type: "text", text: "done" }], - timestamp: 3, - }, - }, - } as Parameters[1]); - - expect(host.chatQueue).toEqual([]); - const userTurn = host.chatMessages.find( - (message) => (message as { role?: string }).role === "user", - ); - expect(userTurn).toMatchObject({ - role: "user", - __openclaw: { idempotencyKey: "steer-send-run:user" }, - }); - expect(JSON.stringify(userTurn)).toContain("user turn must still appear"); - }); - - it("materializes an attachment-only steered chip from store-backed payload bytes", () => { - const file = new File(["fake-png"], "shot.png", { type: "image/png" }); - const dataUrl = "data:image/png;base64,ZmFrZS1wbmc="; - registerChatAttachmentPayload({ - attachment: { - id: "steer-att", - mimeType: "image/png", - fileName: "shot.png", - sizeBytes: file.size, - }, - dataUrl, - file, - }); - const host = makeChatHost({ - chatRunId: "active-run", - chatQueue: [ - { - id: "attachment-only-steer", - text: "", - createdAt: 1, - kind: "steered", - pendingRunId: "active-run", - sendRunId: "steer-att-run", - sessionKey: "agent:main:main", - // Queue rows carry attachment metadata only; bytes live in the store. - attachments: [ - { id: "steer-att", mimeType: "image/png", fileName: "shot.png", sizeBytes: file.size }, - ], - }, - ], - sessionKey: "agent:main:main", - }); - - handlePageGatewayEvent(asChatPageHost(host), { - event: "chat", - payload: { - state: "final", - runId: "active-run", - sessionKey: "agent:main:main", - message: { - role: "assistant", - content: [{ type: "text", text: "done" }], - timestamp: 2, - }, - }, - } as Parameters[1]); - - expect(host.chatQueue).toEqual([]); - expect(host.chatMessages[0]).toMatchObject({ - role: "user", - content: [ - { - type: "image", - url: dataUrl, - source: { type: "url", url: dataUrl }, - }, - ], - __openclaw: { idempotencyKey: "steer-att-run:user" }, - }); - expect(JSON.stringify(host.chatMessages[0])).not.toContain("Attached image"); - }); - - it("materializes both the run's queued turn and its steered follow-up at the terminal event", () => { - const original = { - id: "original-turn", - text: "original queued turn", - createdAt: 1, - sendRunId: "active-run", - sendState: "sending" as const, - sessionKey: "agent:main:main", - }; - const host = makeChatHost({ - chatRunId: "active-run", - chatQueue: [ - original, - { - id: "steer-follow-up", - text: "steered follow-up", - createdAt: 2, - kind: "steered", - pendingRunId: "active-run", - sendRunId: "steer-send-run", - sessionKey: "agent:main:main", - }, - ], - sessionKey: "agent:main:main", - }); - expect(admitQueuedMessageForSession(host, host.sessionKey, original)).toBe(true); - - handlePageGatewayEvent(asChatPageHost(host), { - event: "chat", - payload: { - state: "final", - runId: "active-run", - sessionKey: "agent:main:main", - message: { - role: "assistant", - content: [{ type: "text", text: "done" }], - timestamp: 3, - }, - }, - } as Parameters[1]); - - expect(listStoredChatOutboxes(host)).toEqual([]); - expect(host.chatQueue).toEqual([]); - const idempotencyKeys = host.chatMessages.map((message) => { - const marker = (message as { __openclaw?: { idempotencyKey?: string } })["__openclaw"]; - return marker?.idempotencyKey; - }); - expect(idempotencyKeys.slice(0, 2)).toEqual(["active-run:user", "steer-send-run:user"]); - expect(JSON.stringify(host.chatMessages[0])).toContain("original queued turn"); - expect(JSON.stringify(host.chatMessages[1])).toContain("steered follow-up"); - }); - - it("resumes a restored steer after its active run ended before a terminal error", async () => { - let resolveSteer: (value: { status: "error"; runId: string }) => void = () => {}; - let sends = 0; - - const original = { - id: "late-terminal-steer", - text: "send after the steer fails", - createdAt: 1, - sendAttempts: 0, - sendRunId: "queued-run", - sendState: "waiting-idle" as const, - sessionKey: "agent:main:main", - agentId: "main", - }; - const host = makeChatHost({ - requestHandlers: { - "chat.history": { - messages: [], - sessionInfo: row("agent:main:main", { hasActiveRun: false, status: "done" }), - }, - "chat.send": async () => { - sends += 1; - if (sends === 1) { - return await new Promise<{ status: "error"; runId: string }>((resolve) => { - resolveSteer = resolve; - }); - } - return { status: "ok", runId: "resumed-run" }; - }, - }, - chatRunId: "active-run", - chatDisplayedLeafEntryId: "leaf-active", - chatQueue: [original], - sessionKey: original.sessionKey, - }); - expect(admitQueuedMessageForSession(host, host.sessionKey, original)).toBe(true); - - const steering = steerQueuedChatMessage(host, original.id); - await waitForFast(() => expect(sends).toBe(1)); - clearPendingQueueItemsForRun(host, "active-run"); - host.chatRunId = null; - await flushChatQueueForEvent(host); - resolveSteer({ status: "error", runId: "steer-error" }); - await steering; - await waitForFast(() => expect(sends).toBe(2)); - - expect(listStoredChatOutboxes(host)).toEqual([]); - expect(host.chatQueue).toEqual([]); - }); - - it("resumes a restored steer after its run ends offscreen before a terminal error", async () => { - let resolveSteer: (value: { status: "error"; runId: string }) => void = () => {}; - let sends = 0; - - const original = { - id: "offscreen-late-terminal-steer", - text: "send after the offscreen steer fails", - createdAt: 1, - sendAttempts: 0, - sendRunId: "queued-run", - sendState: "waiting-idle" as const, - sessionKey: "agent:main:original", - agentId: "main", - }; - const host = makeChatHost({ - requestHandlers: { - "chat.history": { - messages: [], - sessionInfo: row("agent:main:original", { hasActiveRun: false, status: "done" }), - }, - "chat.send": async () => { - sends += 1; - if (sends === 1) { - return await new Promise<{ status: "error"; runId: string }>((resolve) => { - resolveSteer = resolve; - }); - } - return { status: "ok", runId: "resumed-offscreen-run" }; - }, - }, - chatRunId: "active-run", - chatDisplayedLeafEntryId: "leaf-active", - chatQueue: [original], - sessionKey: original.sessionKey, - }); - expect(admitQueuedMessageForSession(host, host.sessionKey, original)).toBe(true); - - const steering = steerQueuedChatMessage(host, original.id); - await waitForFast(() => expect(sends).toBe(1)); - writeChatQueueForScope(host, original.sessionKey, host.chatQueue, original.agentId); - host.sessionKey = "agent:main:replacement"; - syncVisibleChatQueueProjection(host); - host.chatRunId = null; - - // The terminal event cannot replay an unconfirmed steer. The later - // definitive rejection must provide the final wakeup for this old scope. - await flushChatQueueForEvent(host); - expect(sends).toBe(1); - resolveSteer({ status: "error", runId: "steer-error" }); - await steering; - await waitForFast(() => expect(sends).toBe(2)); - await waitForFast(() => expect(listStoredChatOutboxes(host)).toEqual([])); - - expect(host.chatQueue).toEqual([]); - expect(readChatQueueForScope(host, original.sessionKey, original.agentId)).toStrictEqual([]); - }); - - it("resumes a restored steer attachment from its durable payload after terminal cleanup", async () => { - vi.stubGlobal( - "URL", - class extends URL { - static override createObjectURL = vi.fn(() => "blob:late-terminal-steer"); - static override revokeObjectURL = vi.fn(); - }, - ); - let resolveSteer: (value: { status: "error"; runId: string }) => void = () => {}; - const sendPayloads: Array> = []; - - const file = new File(["%PDF-1.4\n"], "brief.pdf", { type: "application/pdf" }); - const attachment = registerChatAttachmentPayload({ - attachment: { - id: "late-terminal-steer-attachment", - mimeType: "application/pdf", - fileName: "brief.pdf", - sizeBytes: file.size, - }, - dataUrl: "data:application/pdf;base64,JVBERi0xLjQK", - file, - }); - const original = { - id: "late-terminal-attachment-steer", - text: "send the attachment after the steer fails", - attachments: [attachment], - createdAt: 1, - sendAttempts: 0, - sendRunId: "queued-attachment-run", - sendState: "waiting-idle" as const, - sessionKey: "agent:main:main", - agentId: "main", - }; - const host = makeChatHost({ - requestHandlers: { - "chat.history": { - messages: [], - sessionInfo: row("agent:main:main", { hasActiveRun: false, status: "done" }), - }, - "chat.send": async (params: unknown) => { - sendPayloads.push(requireRecord(params, "late terminal steer attachment payload")); - if (sendPayloads.length === 1) { - return await new Promise<{ status: "error"; runId: string }>((resolve) => { - resolveSteer = resolve; - }); - } - return { status: "ok", runId: "resumed-attachment-run" }; - }, - }, - chatRunId: "active-run", - chatDisplayedLeafEntryId: "leaf-active", - chatQueue: [original], - sessionKey: original.sessionKey, - }); - expect(admitQueuedMessageForSession(host, host.sessionKey, original)).toBe(true); - - const steering = steerQueuedChatMessage(host, original.id); - await waitForFast(() => expect(sendPayloads).toHaveLength(1)); - clearPendingQueueItemsForRun(host, "active-run"); - expect(getChatAttachmentDataUrl(attachment)).toBeNull(); - host.chatRunId = null; - await flushChatQueueForEvent(host); - resolveSteer({ status: "error", runId: "steer-error" }); - await steering; - await waitForFast(() => expect(sendPayloads).toHaveLength(2)); - await waitForFast(() => expect(listStoredChatOutboxes(host)).toEqual([])); - - const replayAttachments = sendPayloads[1]?.attachments as Array>; - expect(replayAttachments).toHaveLength(1); - expect(replayAttachments[0]?.content).toBe("JVBERi0xLjQK"); - expect(replayAttachments[0]?.fileName).toBe("brief.pdf"); - expect(host.chatQueue).toEqual([]); - }); - - it("does not project a late steer acknowledgement into a newly selected session", async () => { - let resolveRequest: (value: { status: "started"; runId: string }) => void = () => {}; - - const original = { - id: "route-switch-steer", - text: "tighten the plan", - createdAt: 1, - sendAttempts: 0, - sendRunId: "queued-run", - sendState: "waiting-idle" as const, - sessionKey: "agent:main:original", - agentId: "main", - }; - const host = makeChatHost({ - requestHandlers: { - "chat.send": () => - new Promise<{ status: "started"; runId: string }>((resolve) => { - resolveRequest = resolve; - }), - }, - chatRunId: "active-run", - chatDisplayedLeafEntryId: "leaf-active", - chatQueue: [original], - sessionKey: "agent:main:original", - }); - expect(admitQueuedMessageForSession(host, host.sessionKey, original)).toBe(true); - - const steering = steerQueuedChatMessage(host, original.id); - await waitForFast(() => expect(host.request).toHaveBeenCalledOnce()); - writeChatQueueForScope(host, original.sessionKey, host.chatQueue, original.agentId); - host.sessionKey = "agent:main:replacement"; - syncVisibleChatQueueProjection(host); - resolveRequest({ status: "started", runId: "steer-run" }); - await steering; - - expect(listStoredChatOutboxes(host)).toEqual([]); - expect(host.chatQueue).toEqual([]); - expect(readChatQueueForScope(host, original.sessionKey, original.agentId)).toStrictEqual([]); - expect(host.applySettings).not.toHaveBeenCalled(); - }); - - it.each(["terminal error", "ambiguous acknowledgement"] as const)( - "keeps the durable steer recoverable after a route switch and %s", - async (outcome) => { - let resolveRequest: (value: { status: "error"; runId: string }) => void = () => {}; - let rejectRequest: (reason: Error) => void = () => {}; - - const original = { - id: `route-switch-${outcome}`, - text: "keep this steer recoverable", - createdAt: 1, - sendAttempts: 0, - sendRunId: "queued-run", - sendState: "waiting-idle" as const, - sessionKey: "agent:main:original", - agentId: "main", - }; - const host = makeChatHost({ - requestHandlers: { - "chat.send": () => - new Promise<{ status: "error"; runId: string }>((resolve, reject) => { - resolveRequest = resolve; - rejectRequest = reject; - }), - }, - chatError: null, - chatRunId: "active-run", - chatDisplayedLeafEntryId: "leaf-active", - chatQueue: [original], - sessionKey: original.sessionKey, - }); - expect(admitQueuedMessageForSession(host, host.sessionKey, original)).toBe(true); - - const steering = steerQueuedChatMessage(host, original.id); - await waitForFast(() => expect(host.request).toHaveBeenCalledOnce()); - writeChatQueueForScope(host, original.sessionKey, host.chatQueue, original.agentId); - host.sessionKey = "agent:main:replacement"; - syncVisibleChatQueueProjection(host); - if (outcome === "terminal error") { - resolveRequest({ status: "error", runId: "steer-error" }); - } else { - rejectRequest(new Error("socket closed")); - } - await steering; - - const expectedState = outcome === "terminal error" ? "waiting-idle" : "unconfirmed"; - expect(listStoredChatOutboxes(host)[0]?.queue).toMatchObject([ - { id: original.id, sendState: expectedState }, - ]); - expect(host.chatQueue).toEqual([]); - expect(readChatQueueForScope(host, original.sessionKey, original.agentId)).toMatchObject([ - { id: original.id, sendState: expectedState }, - ]); - expect(host.lastError).toBeNull(); - expect(host.chatError).toBeNull(); - expect(host.applySettings).not.toHaveBeenCalled(); - expect(host.request).toHaveBeenCalledTimes(1); - }, - ); - - it("parks a durable queued turn when the steer acknowledgement is ambiguous", async () => { - let rejectRequest: (reason: Error) => void = () => {}; - const request = makeRequestMock({ - "chat.send": () => - new Promise((_resolve, reject) => { - rejectRequest = reject; - }), - }); - const original = { - id: "ambiguous-durable-steer", - text: "tighten the durable plan", - createdAt: 1, - sendAttempts: 0, - sendRunId: "queued-run", - sendState: "waiting-idle" as const, - sessionKey: "agent:main:main", - agentId: "main", - }; - const client = clientWithRequest(request); - const host = makeChatHost({ - client, - chatRunId: "active-run", - chatDisplayedLeafEntryId: "leaf-active", - chatQueue: [original], - sessionKey: "agent:main:main", - }); - const peer = makeChatHost({ - client, - chatRunId: "active-run", - chatDisplayedLeafEntryId: "leaf-active", - chatQueue: [{ ...original }], - sessionKey: host.sessionKey, - }); - const latePeer = makeChatHost({ client, chatQueue: [], sessionKey: host.sessionKey }); - const stopHost = subscribeChatOutboxProjection(host); - const stopPeer = subscribeChatOutboxProjection(peer); - let stopLatePeer = () => {}; - expect(admitQueuedMessageForSession(host, host.sessionKey, original)).toBe(true); - - try { - const steering = steerQueuedChatMessage(host, original.id); - await waitForFast(() => expect(request).toHaveBeenCalledOnce()); - expect(host.chatQueue).toHaveLength(1); - expect(host.chatQueue[0]?.pendingRunId).toBe("active-run"); - expect(peer.chatQueue).toHaveLength(1); - expect(peer.chatQueue[0]?.sendState).toBe("steering"); - await steerQueuedChatMessage(peer, original.id); - expect(request).toHaveBeenCalledTimes(1); - stopLatePeer = subscribeChatOutboxProjection(latePeer); - expect(latePeer.chatQueue).toHaveLength(1); - expect(latePeer.chatQueue[0]?.sendState).toBe("steering"); - await retryQueuedChatMessage(peer, original.id); - expect(request).toHaveBeenCalledTimes(1); - expect(loadChatComposerSnapshot(host, host.sessionKey)?.queue[0]?.sendState).toBe( - "unconfirmed", - ); - const outbox = listStoredChatOutboxes(host)[0]; - expect(outbox).toBeDefined(); - syncVisibleChatQueueProjection(peer); - expect(peer.chatQueue).toHaveLength(1); - expect(peer.chatQueue[0]?.sendState).toBe("steering"); - - clearPendingQueueItemsForRun(host, "active-run"); - host.chatRunId = null; - rejectRequest(new Error("socket closed")); - await steering; - - expect(listStoredChatOutboxes(host)[0]?.queue).toMatchObject([ - { - id: original.id, - text: original.text, - sendRunId: original.sendRunId, - sendError: "Steer delivery could not be confirmed. Check the active run before retrying.", - sendState: "unconfirmed", - }, - ]); - expect(host.chatQueue).toHaveLength(1); - expect(host.chatQueue[0]).toMatchObject({ - id: original.id, - sendState: "unconfirmed", - }); - expect(peer.chatQueue).toHaveLength(1); - expect(peer.chatQueue[0]).toMatchObject({ - id: original.id, - sendState: "unconfirmed", - }); - expect(latePeer.chatQueue).toHaveLength(1); - expect(latePeer.chatQueue[0]).toMatchObject({ - id: original.id, - sendState: "unconfirmed", - }); - expect(host.lastError).toBe( - "Steer delivery could not be confirmed. Check the active run before retrying.", - ); - - await retryReconnectableQueuedChatSends(host); - - expect(request.mock.calls.filter(([method]) => method === "chat.send")).toHaveLength(1); - } finally { - stopLatePeer(); - stopPeer(); - stopHost(); - } - }); - - it("keeps a definitive steer rejection retryable as the same steer", async () => { + it("retries a failed steer row generically with the same idempotency key", async () => { const payloads: Array> = []; const original = { - id: "rejected-durable-steer", - text: "tighten the plan", + id: "failed-steer", + text: "try again", createdAt: 1, - sendAttempts: 0, - sendRunId: "stable-steer-request", - sendState: "waiting-idle" as const, - sessionKey: "agent:main:main", - agentId: "main", - }; - const host = makeChatHost({ - requestHandlers: { - "chat.send": (params: unknown) => { - const payload = requireRecord(params, "definitive steer rejection payload"); - payloads.push(payload); - if (payloads.length === 1) { - throw new GatewayRequestError({ - code: "INVALID_REQUEST", - message: "no active turn to steer", - }); - } - return { status: "started", runId: payload.idempotencyKey }; - }, - }, - chatRunId: "active-run", - chatDisplayedLeafEntryId: "leaf-active", - chatQueue: [original], - sessionKey: original.sessionKey, - }); - expect(admitQueuedMessageForSession(host, host.sessionKey, original)).toBe(true); - - await steerQueuedChatMessage(host, original.id); - - expect(host.chatQueue).toEqual([ - expect.objectContaining({ - id: original.id, - kind: "steered", - sendError: "no active turn to steer", - sendRunId: original.sendRunId, - sendState: "failed", - }), - ]); - expect(host.lastError).toBe("no active turn to steer"); - - host.connected = false; - await retryQueuedChatMessage(host, original.id); - expect(payloads).toHaveLength(1); - expect(host.lastError).toBe( - "This steer still targets the previous run, but that run is no longer active.", - ); - - host.connected = true; - host.chatRunId = "active-run"; - host.chatDisplayedLeafEntryId = "leaf-advanced-during-tool-work"; - await retryQueuedChatMessage(host, original.id); - - expect(payloads).toHaveLength(2); - expect(payloads.map((payload) => payload.idempotencyKey)).toEqual([ - original.sendRunId, - original.sendRunId, - ]); - expect(payloads.map((payload) => payload.queueMode)).toEqual(["steer", "steer"]); - expect(payloads.map((payload) => payload.expectedRunId)).toEqual(["active-run", "active-run"]); - expect(payloads.map((payload) => payload.expectedLeafEntryId)).toEqual([ - "leaf-active", - "leaf-advanced-during-tool-work", - ]); - }); - - it("retries a failed steer as a new turn when the session is idle", async () => { - const payloads: Array> = []; - const original = { - id: "idle-failed-steer", - text: "deliver this as a new turn", - createdAt: 1, - kind: "steered" as const, + queueMode: "steer" as const, sendAttempts: 1, - sendError: "The session switched branches — review and resend.", - sendRequestStartedAtMs: 123, - sendRunId: "previous-steer-request", + sendError: "rejected", + sendRunId: "stable-steer-send", sendState: "failed" as const, - steerTargetRunId: "previous-run", sessionKey: "agent:main:main", agentId: "main", }; const host = makeChatHost({ requestHandlers: { - "chat.history": idleChatHistory(original.sessionKey), "chat.send": (params: unknown) => { - payloads.push(requireRecord(params, "idle steer retry payload")); - return { status: "ok", runId: "new-turn" }; + payloads.push(requireRecord(params, "retried steer payload")); + return { runId: original.sendRunId, status: "ok" }; }, }, - chatError: "The session switched branches — review and resend.", chatQueue: [original], sessionKey: original.sessionKey, }); @@ -9298,232 +8293,18 @@ describe("handleSendChat", () => { await retryQueuedChatMessage(host, original.id); - expect(payloads).toHaveLength(1); - expect(payloads[0]).toMatchObject({ message: original.text }); + expect(payloads).toEqual([ + expect.objectContaining({ + idempotencyKey: original.sendRunId, + message: original.text, + queueMode: "steer", + }), + ]); expect(payloads[0]).not.toHaveProperty("expectedRunId"); expect(payloads[0]).not.toHaveProperty("expectedLeafEntryId"); - expect(payloads[0]).not.toHaveProperty("queueMode"); - expect(payloads[0]?.idempotencyKey).not.toBe(original.sendRunId); - expect(host.chatQueue).toEqual([]); - expect(host.chatError).toBeNull(); - expect(host.lastError).toBeNull(); }); - it("fails a restored steer that predates durable target identity", async () => { - const original = { - id: "legacy-targetless-steer", - text: "do not redirect this", - createdAt: 1, - kind: "steered" as const, - sendRunId: "stable-request", - sendState: "failed" as const, - sessionKey: "agent:main:main", - }; - const host = makeChatHost({ - requestHandlers: { "chat.send": { status: "started", runId: "successor" } }, - chatRunId: "successor", - chatDisplayedLeafEntryId: "successor-leaf", - chatQueue: [original], - sessionKey: original.sessionKey, - }); - expect(admitQueuedMessageForSession(host, host.sessionKey, original)).toBe(true); - - await retryQueuedChatMessage(host, original.id); - - expect(host.request).not.toHaveBeenCalledWith("chat.send", expect.anything()); - expect(host.chatQueue[0]).toMatchObject({ - kind: "steered", - sendState: "failed", - sendError: "This restored steer has no original run target and cannot be retried safely.", - }); - }); - - it("retries a restored steer against its run with the refreshed current leaf", async () => { - const payloads: Array> = []; - const original = { - id: "restored-run-bound-steer", - text: "continue the same turn", - createdAt: 1, - kind: "steered" as const, - sendRunId: "stable-steer-request", - sendState: "failed" as const, - steerTargetRunId: "active-run", - sessionKey: "agent:main:main", - }; - const host = makeChatHost({ - requestHandlers: { - "chat.send": (params: unknown) => { - const payload = requireRecord(params, "restored run-bound steer payload"); - payloads.push(payload); - return { status: "started", runId: payload.idempotencyKey }; - }, - }, - chatRunId: null, - chatQueue: [original], - sessionKey: original.sessionKey, - sessionsResult: createSessionsResult([ - row(original.sessionKey, { - activeLeafEntryId: "leaf-advanced-during-tool-work", - activeRunIds: ["active-run"], - hasActiveRun: true, - status: "running", - }), - ]), - }); - expect(admitQueuedMessageForSession(host, host.sessionKey, original)).toBe(true); - - await retryQueuedChatMessage(host, original.id); - - expect(payloads).toHaveLength(1); - expect(payloads[0]).toMatchObject({ - expectedLeafEntryId: "leaf-advanced-during-tool-work", - expectedRunId: "active-run", - idempotencyKey: original.sendRunId, - queueMode: "steer", - }); - }); - - it("does not guess among multiple server-reported active runs", async () => { - const original = { id: "ambiguous-server-steer", text: "pick neither", createdAt: 1 }; - const host = makeChatHost({ - requestHandlers: {}, - chatQueue: [original], - sessionKey: "agent:main:main", - sessionsResult: createSessionsResult([ - row("agent:main:main", { - hasActiveRun: true, - activeRunIds: ["run-a", "run-b"], - activeLeafEntryId: "leaf-active", - status: "running", - }), - ]), - }); - expect(admitQueuedMessageForSession(host, host.sessionKey, original)).toBe(true); - - await steerQueuedChatMessage(host, original.id); - - expect(host.request).not.toHaveBeenCalledWith("chat.send", expect.anything()); - expect(host.chatQueue[0]?.sendState).toBe("failed"); - }); - - it("removes queued steer indicators when chat.send returns terminal ok", async () => { - const original = { id: "queued-1", text: "tighten the plan", createdAt: 1 }; - let steerRequestRunId: string | undefined; - const host = makeChatHost({ - requestHandlers: { - "chat.send": (params: unknown) => { - const payload = requireRecord(params, "terminal steer payload"); - steerRequestRunId = String(payload.idempotencyKey); - return { status: "ok", runId: "steer-ok" }; - }, - }, - chatRunId: "run-1", - chatDisplayedLeafEntryId: "leaf-active", - chatStream: "Working...", - chatQueue: [original], - sessionKey: "agent:main:main", - }); - expect(admitQueuedMessageForSession(host, host.sessionKey, original)).toBe(true); - - await steerQueuedChatMessage(host, "queued-1"); - - expect(host.chatRunId).toBe("run-1"); - expect(host.chatStream).toBeNull(); - expect(host.chatStreamSegments).toEqual([ - { - text: "Working...", - ts: expect.any(Number), - runId: "run-1", - boundaryRunId: steerRequestRunId, - }, - ]); - expect(host.chatMessages).toEqual([ - expect.objectContaining({ - role: "user", - __openclaw: { idempotencyKey: `${steerRequestRunId}:user` }, - }), - ]); - const renderedText = buildChatItems({ - paneId: "terminal-steer", - sessionKey: host.sessionKey, - runId: host.chatRunId, - messages: host.chatMessages, - toolMessages: host.chatToolMessages, - streamSegments: host.chatStreamSegments, - stream: host.chatStream, - streamStartedAt: host.chatStreamStartedAt, - showToolCalls: true, - }).flatMap((item) => - item.kind === "stream" - ? [item.text] - : item.kind === "group" - ? item.messages.map(({ message }) => extractText(message)) - : [], - ); - expect(renderedText).toEqual(["Working...", "tighten the plan"]); - expect(host.chatQueue).toStrictEqual([]); - expect(host.applySettings).toHaveBeenCalledWith( - expect.objectContaining({ lastActiveSessionKey: "agent:main:main" }), - ); - expect(host.settings?.lastActiveSessionKey).toBe(""); - }); - - it("restores queued steer items when chat.send returns terminal error", async () => { - const original = { id: "queued-1", text: "tighten the plan", createdAt: 1 }; - const host = makeChatHost({ - requestHandlers: { - "chat.send": { status: "error", runId: "steer-error" }, - }, - chatRunId: "run-1", - chatDisplayedLeafEntryId: "leaf-active", - chatStream: "Working...", - chatQueue: [original], - sessionKey: "agent:main:main", - }); - expect(admitQueuedMessageForSession(host, host.sessionKey, original)).toBe(true); - - await steerQueuedChatMessage(host, "queued-1"); - - expect(host.chatRunId).toBe("run-1"); - expect(host.chatStream).toBe("Working..."); - expect(host.chatQueue).toStrictEqual([original]); - expect(host.lastError).toBe("Steer failed before it reached the run; try again."); - expect(host.applySettings).not.toHaveBeenCalled(); - }); - - it("surfaces an unconfirmed steer failure globally when the pane is no longer visible", async () => { - const toastHost = document.createElement("openclaw-toast-host"); - document.body.append(toastHost); - const original = { id: "queued-1", text: "tighten the plan", createdAt: 1 }; - const host = makeChatHost({ - requestHandlers: { - "chat.send": () => { - // The operator navigates away before transport fails, so the stale - // visibility gate used to swallow the terminal outcome entirely. - host.sessionKey = "agent:main:second"; - throw new Error("network dropped"); - }, - }, - chatRunId: "run-1", - chatDisplayedLeafEntryId: "leaf-active", - chatQueue: [original], - sessionKey: "agent:main:main", - }); - expect(admitQueuedMessageForSession(host, host.sessionKey, original)).toBe(true); - - await steerQueuedChatMessage(host, "queued-1"); - - // Pre-fix: the failure was parked on the queue row with no visible outcome. - expect(host.lastError).toBeNull(); - await waitForFast(() => - expect(document.body.textContent).toContain( - "Steer delivery could not be confirmed. Check the active run before retrying.", - ), - ); - document.body.replaceChildren(); - }); - - it("removes pending steer indicators when the run finishes", () => { + it("removes generic pending-run indicators when the run finishes", () => { const host = makeChatHost({ chatQueue: [ { diff --git a/ui/src/pages/chat/chat-state-events.ts b/ui/src/pages/chat/chat-state-events.ts index 7e456f5e1bfd..a47dfc4851fa 100644 --- a/ui/src/pages/chat/chat-state-events.ts +++ b/ui/src/pages/chat/chat-state-events.ts @@ -27,10 +27,12 @@ import { shouldHideAssistantChatMessage, } from "./chat-history.ts"; import { + clearPendingQueueItemsForRun, readDeliveredQueuedChatSendForRun, removeDeliveredQueuedChatSendForRun, } from "./chat-queue.ts"; import { flushChatQueueForEvent, resumeStoredChatOutboxes } from "./chat-send-actions.ts"; +import { preserveQueuedUserTurn } from "./chat-send-support.ts"; import { recordChatSendServerTiming } from "./chat-send-timing.ts"; import { refreshCurrentChatSessionList } from "./chat-session.ts"; import type { ChatPageHost } from "./chat-state-host.ts"; @@ -45,12 +47,6 @@ import { reconcileStaleChatRunAfterSessionStatePublication, } from "./run-lifecycle.ts"; import { applySessionMessagePayload } from "./session-message-apply.ts"; -import { - preserveQueuedUserTurn, - retirePersistedSteeredChips, - retireSteeredChipsForTerminalRun, -} from "./steer-lifecycle.ts"; -import { isAckedSteeredChip } from "./steered-chip.ts"; import { rememberAuthoritativeTerminal } from "./terminal-message-identity.ts"; import { handleAgentEvent, handleSessionOperationEvent } from "./tool-stream.ts"; @@ -108,7 +104,7 @@ function finishSessionMessageRunReconcile( if (!cleared) { return false; } - retireSteeredChipsForTerminalRun(state, runId ?? undefined); + clearPendingQueueItemsForRun(state, runId ?? undefined); void loadChatHistory(state) .finally(() => { if (!areUiSessionKeysEquivalent(state.sessionKey, sessionKey)) { @@ -135,7 +131,6 @@ function handleSessionMessageEvent(state: ChatPageHost, payload: unknown) { kind: "live", activeRunId: state.chatRunId, }); - retirePersistedSteeredChips(state); } if (matchesChat && event.archived !== null) { state.selectedChatSessionArchived = event.archived; @@ -426,6 +421,9 @@ export function handlePageGatewayEvent(state: ChatPageHost, event: GatewayEventF preserveQueuedUserTurn(state, delivered); } const result = handleChatGatewayEvent(state, payload); + if (terminal) { + clearPendingQueueItemsForRun(state, payload?.runId); + } if (shouldCelebrateFirstReply && result === "final") { fireFirstReplyConfetti(); } @@ -514,9 +512,6 @@ function rememberDeliveredQueuedUserTurn( turns = new Map(); deliveredQueueTurnsByClient.set(owner, turns); } - const pending = state.chatQueue.find( - (item) => isAckedSteeredChip(item) && item.pendingRunId === runId, - ); const stored = readDeliveredQueuedChatSendForRun(state, runId)?.item; if (stored) { turns.delete(runId); @@ -529,9 +524,5 @@ function rememberDeliveredQueuedUserTurn( turns.delete(oldestRunId); } } - // Original-turn copies first: a run can own both its queued turn (stored, or - // its remembered fallback in `turns`) and a steered follow-up chip; the chip - // is preserved separately by retireSteeredChipsForTerminalRun and must not - // mask the original copy here. - return stored ?? turns.get(runId) ?? pending ?? null; + return stored ?? turns.get(runId) ?? null; } diff --git a/ui/src/pages/chat/chat-state-page.ts b/ui/src/pages/chat/chat-state-page.ts index f7faf9e2162d..7a2e97419844 100644 --- a/ui/src/pages/chat/chat-state-page.ts +++ b/ui/src/pages/chat/chat-state-page.ts @@ -27,6 +27,7 @@ import { } from "./chat-send-actions.ts"; import { setChatError } from "./chat-send-queue-state.ts"; import { handleSendChat } from "./chat-send-submit.ts"; +import { OFFLINE_QUEUE_STORAGE_ERROR } from "./chat-send-support.ts"; import { retireChatModelSelectionOwnership } from "./chat-session.ts"; import type { ChatPageHost } from "./chat-state-host.ts"; import { @@ -59,7 +60,6 @@ import { normalizeSidebarLayout, openSlot, } from "./sidebar-layout.ts"; -import { OFFLINE_QUEUE_STORAGE_ERROR } from "./steer-lifecycle.ts"; import { resetToolStream } from "./tool-stream.ts"; type ChatPageElement = { diff --git a/ui/src/pages/chat/chat-state.test.ts b/ui/src/pages/chat/chat-state.test.ts index 96e0a2508e18..cef45a5373d2 100644 --- a/ui/src/pages/chat/chat-state.test.ts +++ b/ui/src/pages/chat/chat-state.test.ts @@ -1,4 +1,3 @@ -import { readSessionMessageIdentity } from "@openclaw/gateway-client/browser"; import type { ReactiveController, ReactiveControllerHost } from "lit"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; @@ -150,18 +149,6 @@ describe("canonical session message recovery", () => { const { state } = createSessionEventState({ connected: false, chatMessages: [originalPrompt], - chatQueue: [ - { - id: "landed-steer", - text: "Steer prompt", - createdAt: 50, - kind: "steered", - pendingRunId: steerRunId, - sendRunId: steerRunId, - steerTargetRunId: activeRunId, - sessionKey: "agent:main:main", - }, - ], chatRunId: activeRunId, chatStream: null, chatStreamSegments: [], @@ -181,23 +168,12 @@ describe("canonical session message recovery", () => { }, }, }); - handlePageGatewayEvent(state, { - type: "event", - event: "chat", - payload: { - sessionKey: state.sessionKey, - runId: steerRunId, - state: "final", - }, - }); expect(renderedTranscript(state)).toEqual([ { role: "user", text: "Original prompt" }, { role: "assistant", text: "Before steer." }, - { role: "user", text: "Steer prompt" }, ]); expect(state.chatRunId).toBe(activeRunId); expect(state.chatQueue).toEqual([]); - const segmentsAfterRequestBoundary = state.chatStreamSegments; const steerEvent = { type: "event", @@ -222,6 +198,7 @@ describe("canonical session message recovery", () => { }, } satisfies Parameters[1]; handlePageGatewayEvent(state, steerEvent); + const segmentsAfterRequestBoundary = state.chatStreamSegments; expect(state.chatStreamSegments).toBe(segmentsAfterRequestBoundary); expect( state.chatMessages.filter((message) => extractText(message) === "Steer prompt"), @@ -556,48 +533,6 @@ describe("canonical session message recovery", () => { expect(state.chatStream).toBe("Current partial reply"); }); - it("orders an active queued turn before its landed steer", () => { - const activePrompt = { - id: "active-prompt", - text: "Keep this run active", - createdAt: 1, - sendRunId: "active-run", - sendState: "waiting-model" as const, - sessionKey: "main", - }; - const { state } = createSessionEventState({ - chatRunId: "active-run", - chatQueue: [ - activePrompt, - { - id: "landed-steer-chip", - text: "Use the deployment plan", - createdAt: 2, - kind: "steered", - pendingRunId: "steer-request-run", - sendRunId: "steer-request-run", - steerTargetRunId: "active-run", - sessionKey: "main", - }, - ], - }); - - handlePageGatewayEvent(state, { - type: "event", - event: "chat", - payload: { - runId: "steer-request-run", - sessionKey: state.sessionKey, - state: "final", - }, - }); - - expect(state.chatQueue).toEqual([activePrompt]); - expect( - state.chatMessages.map((message) => readSessionMessageIdentity(message)?.idempotencyKey), - ).toEqual(["active-run:user", "steer-request-run:user"]); - }); - it("renders distinct live peers immediately and coalesces their stale history", async () => { let resolveHistory!: (result: { messages: unknown[]; diff --git a/ui/src/pages/chat/chat-thread-build.ts b/ui/src/pages/chat/chat-thread-build.ts index 97b1d37ae83c..9a2600691fc4 100644 --- a/ui/src/pages/chat/chat-thread-build.ts +++ b/ui/src/pages/chat/chat-thread-build.ts @@ -27,6 +27,7 @@ import { resolveWorkingProgress, shouldRenderQueuedSendInThread, } from "./chat-progress.ts"; +import { chatMessagesContainQueuedSend } from "./chat-send-support.ts"; import { coalesceToolActivityMessages, groupMessages, @@ -56,7 +57,6 @@ import { type TurnInsertionBounds, } from "./chat-thread-items.ts"; import { safeNormalizeMessage } from "./chat-turn-boundary.ts"; -import { chatMessagesContainQueuedSend } from "./steer-lifecycle.ts"; import { resolveSystemNoticeKind } from "./system-notice-kinds.ts"; import { isLiveTerminalForRun } from "./terminal-message-identity.ts"; import { diff --git a/ui/src/pages/chat/components/chat-composer-queue.ts b/ui/src/pages/chat/components/chat-composer-queue.ts index 60784a03cb2a..3ab883c65d6f 100644 --- a/ui/src/pages/chat/components/chat-composer-queue.ts +++ b/ui/src/pages/chat/components/chat-composer-queue.ts @@ -9,7 +9,6 @@ import { } from "../../../lib/chat/chat-queue-order.ts"; import type { ChatQueueItem } from "../../../lib/chat/chat-types.ts"; import { isSteerableQueuedMessage } from "../chat-queue.ts"; -import { isInflightSteer, isSteeredQueueItem } from "../steered-chip.ts"; import { renderChatAuthorAvatar } from "./chat-author-avatar.ts"; type ChatQueueProps = { @@ -102,9 +101,9 @@ function renderChatQueueItem( ) { const stateLabel = sendStateLabel(item); const failed = item.sendState === "failed" || item.sendState === "unconfirmed"; - const steered = isSteeredQueueItem(item) && !failed; + const steerMode = item.queueMode === "steer"; const reconnecting = item.sendState === "waiting-reconnect"; - const busy = item.sendState === "executing-command" || isInflightSteer(item); + const busy = item.sendState === "executing-command"; const editing = props.editingId === item.id; const canSteer = Boolean(props.canAbort && props.onQueueSteer) && isSteerableQueuedMessage(item) && !editing; @@ -126,11 +125,9 @@ function renderChatQueueItem( (item.attachments?.length ? t("chat.queue.imageCount", { count: String(item.attachments.length) }) : ""); - const itemClass = `chat-queue__item${steered ? " chat-queue__item--steered" : ""}${ - failed ? " chat-queue__item--failed" : "" - }${reconnecting ? " chat-queue__item--reconnect" : ""}${ - editing ? " chat-queue__item--editing" : "" - }`; + const itemClass = `chat-queue__item${failed ? " chat-queue__item--failed" : ""}${ + reconnecting ? " chat-queue__item--reconnect" : "" + }${editing ? " chat-queue__item--editing" : ""}`; // Row order keeps the actions on the first flex line; the error wraps below // them via flex-basis so failed rows grow by one line instead of a card. return html` @@ -200,14 +197,10 @@ function renderChatQueueItem( ${reconnecting ? html`` : html``} ${renderChatAuthorAvatar(item.sender)} - ${steered - ? html`${t("chat.queue.states.steering")}` - : nothing} + ${steerMode ? html`${t("chat.queue.steer")}` : nothing} ${editing ? html`${t("chat.queue.states.editing")}` : stateLabel @@ -245,7 +238,7 @@ function renderChatQueueItem( ${failed && !editing && props.onQueueRetry ? html`