diff --git a/ui/src/ui/app-chat.test.ts b/ui/src/ui/app-chat.test.ts index b0f736babf24..7748981f6f26 100644 --- a/ui/src/ui/app-chat.test.ts +++ b/ui/src/ui/app-chat.test.ts @@ -10,6 +10,7 @@ import { resetChatAttachmentPayloadStoreForTest, } from "./chat/attachment-payload-store.ts"; import type { executeSlashCommand } from "./chat/slash-command-executor.ts"; +import { loadSessions } from "./controllers/sessions.ts"; import type { GatewaySessionRow, SessionsListResult } from "./types.ts"; type ExecuteSlashCommand = typeof executeSlashCommand; @@ -2116,6 +2117,43 @@ describe("handleSendChat", () => { expect(userMessage.role).toBe("user"); }); + it("keeps ACK-completed sends idle when sessions.list returns a stale active row", async () => { + const request = vi.fn(async (method: string, params?: unknown) => { + if (method === "chat.send") { + const payload = requireRecord(params, "chat send payload"); + return { runId: payload.idempotencyKey, status: "ok" }; + } + if (method === "chat.history") { + return { messages: [] }; + } + if (method === "sessions.list") { + return createSessionsResult([ + row("agent:main", { hasActiveRun: true, status: "running", startedAt: 1 }), + ]); + } + throw new Error(`Unexpected request: ${method}`); + }); + const host = makeHost({ + client: { request } as unknown as ChatHost["client"], + chatMessage: "already done", + sessionsResult: createSessionsResult([ + row("agent:main", { hasActiveRun: true, status: "running", startedAt: 1 }), + ]), + }); + + await handleSendChat(host); + await Promise.resolve(); + await loadSessions(host as unknown as Parameters[0]); + + expect(host.chatRunId).toBeNull(); + expect(host.chatStream).toBeNull(); + expect(hasAbortableSessionRun(host)).toBe(false); + expect(host.sessionsResult?.sessions[0]).toMatchObject({ + hasActiveRun: false, + status: "done", + }); + }); + it("keeps delayed chat.send ACK effects scoped to the submitted session", async () => { const sent = createDeferred(); const request = vi.fn((method: string) => { diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index c3fdb6d829ec..45227c3fd192 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -928,6 +928,7 @@ async function sendQueuedChatMessage( reconcileChatRunLifecycle( host as unknown as Parameters[0], { + outcome: "done", sessionStatus: "done", runId: ack.runId, sessionKey, @@ -935,7 +936,8 @@ async function sendQueuedChatMessage( clearChatStream: true, clearToolStream: true, clearSideResultTerminalRuns: true, - clearRunStatus: true, + publishRunStatus: false, + armLocalTerminalReconcile: true, }, ); void loadChatHistory(host as unknown as ChatState); diff --git a/ui/src/ui/controllers/chat.test.ts b/ui/src/ui/controllers/chat.test.ts index 18762c51f50d..7271eb8b083f 100644 --- a/ui/src/ui/controllers/chat.test.ts +++ b/ui/src/ui/controllers/chat.test.ts @@ -1538,6 +1538,20 @@ describe("sendChatMessage", () => { expect(state.chatRunId).toBeNull(); expect(state.chatStream).toBeNull(); expect(state.chatStreamStartedAt).toBeNull(); + const runState = state as ChatState & { + chatRunStatus?: unknown; + lastLocalTerminalReconcile?: unknown; + }; + expect(runState.chatRunStatus).toMatchObject({ + phase: "done", + runId: "gateway-complete-run", + sessionKey: "main", + }); + expect(runState.lastLocalTerminalReconcile).toMatchObject({ + phase: "done", + runId: "gateway-complete-run", + sessionKey: "main", + }); }); it("serializes non-image chat attachments as files", async () => { diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index fa25b93bcc64..6b161ee0afce 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -949,9 +949,18 @@ export async function sendChatMessage( try { const ack = await requestChatSend(state, { message: msg, attachments, runId }); if (ack.status === "ok") { - state.chatRunId = null; - state.chatStream = null; - state.chatStreamStartedAt = null; + reconcileChatRunLifecycle( + state as unknown as Parameters[0], + { + outcome: "done", + sessionStatus: "done", + runId: ack.runId, + sessionKey: state.sessionKey, + clearLocalRun: true, + clearChatStream: true, + armLocalTerminalReconcile: true, + }, + ); } else { state.chatRunId = ack.runId; }