diff --git a/ui/src/e2e/chat-flow.e2e.test.ts b/ui/src/e2e/chat-flow.e2e.test.ts index edb0c97ffdf1..f1b98237f271 100644 --- a/ui/src/e2e/chat-flow.e2e.test.ts +++ b/ui/src/e2e/chat-flow.e2e.test.ts @@ -1976,6 +1976,58 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { } }); + it("steers a restored queued message when only the session row reports the active run", async () => { + const context = await 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(`${server.baseUrl}chat`); + + await page.locator(".agent-chat__composer-combobox textarea").fill("keep this run active"); + await page.getByRole("button", { name: "Send message" }).click(); + await gateway.waitForRequest("chat.send"); + await page.getByRole("button", { name: "Stop generating" }).waitFor({ timeout: 10_000 }); + + const queuedPrompt = "steer this after restoring the queue"; + await page.locator(".agent-chat__composer-combobox textarea").fill(queuedPrompt); + await page.getByRole("button", { name: "Queue message" }).click(); + await page.locator(".chat-queue").getByText(queuedPrompt).waitFor({ timeout: 10_000 }); + + await gateway.setMethodResponse( + "sessions.list", + chatSessionListResponse([ + { + hasActiveRun: true, + key: "main", + kind: "direct", + label: "Main", + updatedAt: Date.now(), + }, + ]), + ); + await page.reload(); + + const queue = page.locator(".chat-queue"); + await queue.getByText(queuedPrompt).waitFor({ timeout: 10_000 }); + await queue.getByRole("button", { name: "Steer" }).click(); + + const steerRequest = await gateway.waitForRequest("chat.send"); + expect(requireRecord(steerRequest.params)).toMatchObject({ + deliver: false, + message: queuedPrompt, + sessionKey: "main", + }); + await queue.getByText(queuedPrompt).waitFor({ state: "detached", timeout: 10_000 }); + } finally { + await closeBrowserContext(context); + } + }); + it("scrolls a delayed pending send into view before the ACK resolves", async () => { const context = await newBrowserContext({ locale: "en-US", diff --git a/ui/src/pages/chat/chat-send.test.ts b/ui/src/pages/chat/chat-send.test.ts index 9493fadf997c..93b2e4c34bba 100644 --- a/ui/src/pages/chat/chat-send.test.ts +++ b/ui/src/pages/chat/chat-send.test.ts @@ -6686,6 +6686,39 @@ describe("handleSendChat", () => { expect(host.chatQueue[0]?.pendingRunId).toBe("run-1"); }); + it("steers a queued message when only the session row reports an active run", async () => { + const request = vi.fn(async (method: string) => { + if (method === "chat.send") { + return { status: "started", runId: "steer-run" }; + } + throw new Error(`Unexpected request: ${method}`); + }); + const original = { id: "queued-1", text: "tighten the plan", createdAt: 1 }; + const host = makeHost({ + client: { request } as unknown as ChatHost["client"], + chatQueue: [original], + sessionKey: "agent:main:main", + sessionsResult: createSessionsResult([ + row("agent:main:main", { hasActiveRun: true, status: "running" }), + ]), + }); + expect(admitQueuedMessageForSession(host, host.sessionKey, original)).toBe(true); + + await steerQueuedChatMessage(host, original.id); + + expect(request).toHaveBeenCalledWith( + "chat.send", + expect.objectContaining({ + sessionKey: "agent:main:main", + message: "tighten the plan", + deliver: false, + }), + ); + expect(host.chatRunId).toBeNull(); + expect(host.chatQueue).toEqual([]); + expect(listStoredChatOutboxes(host)).toEqual([]); + }); + it("does not steer a queued message without a durable claim", async () => { const request = vi.fn(); const original = { id: "memory-only-steer", text: "do not lose this", createdAt: 1 }; diff --git a/ui/src/pages/chat/chat-send.ts b/ui/src/pages/chat/chat-send.ts index b056c70b5b17..0aefc32088cc 100644 --- a/ui/src/pages/chat/chat-send.ts +++ b/ui/src/pages/chat/chat-send.ts @@ -1294,7 +1294,7 @@ async function sendDetachedCommandMessage( } export async function steerQueuedChatMessage(host: ChatHost, id: string) { - if (!host.connected || !host.chatRunId) { + if (!host.connected || !hasAbortableSessionRun(host)) { return; } const activeRunId = host.chatRunId; @@ -1333,7 +1333,7 @@ export async function steerQueuedChatMessage(host: ChatHost, id: string) { createdAt: item.createdAt, kind: "steered", ...(item.attachments?.length ? { attachments: item.attachments } : {}), - pendingRunId: activeRunId, + ...(activeRunId ? { pendingRunId: activeRunId } : { sendState: "steering" as const }), }; const hasTransientProjection = setTransientQueuedMessageProjection( host, @@ -1361,17 +1361,19 @@ export async function steerQueuedChatMessage(host: ChatHost, id: string) { hasAttachments ? attachments : undefined, () => visibleSessionMatches(host, itemSessionKey, item.agentId), ); - const pendingStillVisible = host.chatQueue.some( - (entry) => entry.id === id && entry.pendingRunId === activeRunId, - ); - replacePendingQueuedMessageProjection( - host, - itemSessionKey, - id, - activeRunId, - claimed, - item.agentId, - ); + const pendingStillVisible = activeRunId + ? host.chatQueue.some((entry) => entry.id === id && entry.pendingRunId === activeRunId) + : false; + if (activeRunId) { + replacePendingQueuedMessageProjection( + host, + itemSessionKey, + id, + activeRunId, + claimed, + item.agentId, + ); + } clearTransientQueuedMessageProjection(host, itemSessionKey, id, item.agentId); const itemStillVisible = visibleSessionMatches(host, itemSessionKey, item.agentId); if (!ack) {