From fb4f1d42e842245df14ee001aa48209b4a789d17 Mon Sep 17 00:00:00 2001 From: Nick Esposito Date: Thu, 3 Sep 2026 16:07:15 -0400 Subject: [PATCH 1/4] feat(chat): restore an archived chat when a new message is sent Co-authored-by: Goose --- src/features/chat/lib/sendCore.test.ts | 91 ++++++++++++++++++++++++++ src/features/chat/lib/sendCore.ts | 26 ++++++++ 2 files changed, 117 insertions(+) diff --git a/src/features/chat/lib/sendCore.test.ts b/src/features/chat/lib/sendCore.test.ts index b66ab0e94..34a044d4e 100644 --- a/src/features/chat/lib/sendCore.test.ts +++ b/src/features/chat/lib/sendCore.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { useChatStore } from "@/features/chat/stores/chatStore"; import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; +import type { ChatSession } from "@/features/chat/stores/chatSessionStore"; import type { SessionChatRuntime } from "@/shared/types/chat"; import { QueuedMessageOwnershipLostError } from "./preCommitSendRejection"; import { dispatchPrompt } from "./sendCore"; @@ -10,6 +11,7 @@ import { setVoiceConversationMode } from "@/features/voice-conversation/lib/voic const mocks = vi.hoisted(() => ({ acpExportSession: vi.fn(), acpSendMessage: vi.fn(), + unarchiveSession: vi.fn(), })); vi.mock("@/shared/api/acp", () => ({ @@ -17,6 +19,14 @@ vi.mock("@/shared/api/acp", () => ({ acpSendMessage: (...args: unknown[]) => mocks.acpSendMessage(...args), })); +vi.mock("@/shared/api/acpApi", () => ({ + archiveSession: vi.fn().mockResolvedValue(undefined), + unarchiveSession: (...args: unknown[]) => mocks.unarchiveSession(...args), + renameSession: vi.fn().mockResolvedValue(undefined), + updateSessionProject: vi.fn().mockResolvedValue(undefined), + updateWorkingDir: vi.fn().mockResolvedValue(undefined), +})); + describe("dispatchPrompt pre-commit rejection", () => { beforeEach(() => { vi.clearAllMocks(); @@ -634,3 +644,84 @@ describe("dispatchPrompt realtime Master transcript recovery", () => { release(); }); }); + +describe("dispatchPrompt archived session restore", () => { + const ARCHIVED_AT = "2026-04-02T00:00:00.000Z"; + + function seedSession(overrides: Partial = {}): ChatSession { + const session: ChatSession = { + id: "session-1", + title: "Test Session", + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + messageCount: 1, + ...overrides, + }; + useChatSessionStore.setState((state) => ({ + sessions: [ + session, + ...state.sessions.filter((candidate) => candidate.id !== session.id), + ], + })); + return session; + } + + beforeEach(() => { + vi.clearAllMocks(); + mocks.acpSendMessage.mockResolvedValue(undefined); + mocks.unarchiveSession.mockResolvedValue(undefined); + useChatSessionStore.setState({ + sessions: [], + activeSessionId: null, + activeWorkspaceBySession: {}, + archiveMutationBySessionId: {}, + }); + }); + + it("restores an archived session before dispatching the prompt", async () => { + seedSession({ archivedAt: ARCHIVED_AT }); + + await dispatchPrompt("session-1", "hello again", {}); + + expect(mocks.unarchiveSession).toHaveBeenCalledTimes(1); + expect(mocks.unarchiveSession).toHaveBeenCalledWith("session-1"); + expect( + useChatSessionStore.getState().getSession("session-1")?.archivedAt, + ).toBeUndefined(); + expect(mocks.acpSendMessage).toHaveBeenCalledTimes(1); + expect(mocks.unarchiveSession.mock.invocationCallOrder[0]).toBeLessThan( + mocks.acpSendMessage.mock.invocationCallOrder[0], + ); + }); + + it("leaves active sessions untouched", async () => { + seedSession(); + + await dispatchPrompt("session-1", "hello", {}); + + expect(mocks.unarchiveSession).not.toHaveBeenCalled(); + expect(mocks.acpSendMessage).toHaveBeenCalledTimes(1); + }); + + it("still dispatches when the restore fails", async () => { + seedSession({ archivedAt: ARCHIVED_AT }); + mocks.unarchiveSession.mockRejectedValue(new Error("backend down")); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + await dispatchPrompt("session-1", "hello again", {}); + expect(mocks.acpSendMessage).toHaveBeenCalledTimes(1); + // The store rolls the optimistic unarchive back when the backend call + // fails. + expect( + useChatSessionStore.getState().getSession("session-1")?.archivedAt, + ).toBe(ARCHIVED_AT); + expect(warn).toHaveBeenCalledWith( + "[send] failed to restore archived session session-1", + expect.any(Error), + ); + } finally { + warn.mockRestore(); + } + }); +}); diff --git a/src/features/chat/lib/sendCore.ts b/src/features/chat/lib/sendCore.ts index 14acdf02d..249a721fe 100644 --- a/src/features/chat/lib/sendCore.ts +++ b/src/features/chat/lib/sendCore.ts @@ -315,6 +315,31 @@ export function resolveAssistantCancellation( finalizeAssistantCancellationRace(promptOwner); } +/** + * Sending a message to an archived chat restores it: the archive flag clears + * locally and on the backend before any prompt preparation or dispatch, so + * the conversation moves back to the active list instead of replying while + * hidden. Best-effort: a failed restore logs a warning and the send proceeds; + * the backend still surfaces its own error if the archived session cannot + * accept the prompt. + */ +async function restoreArchivedSessionBeforeSend( + sessionId: string, +): Promise { + const sessionStore = useChatSessionStore.getState(); + if (!sessionStore.getSession(sessionId)?.archivedAt) { + return; + } + try { + await sessionStore.unarchiveSession(sessionId); + } catch (error) { + console.warn( + `[send] failed to restore archived session ${sessionId}`, + error, + ); + } +} + /** * Foreground send core: commits the user message, drives the * thinking-to-streaming-to-idle chat-state transitions, patches the session @@ -442,6 +467,7 @@ export async function dispatchPrompt( // local transcript state so a retained queued record can retry without // duplicating the user turn. throwIfAborted(signal); + await restoreArchivedSessionBeforeSend(sessionId); await prepare?.(); throwIfAborted(signal); From cf9bdebff05e911bb732c8f6e078285350243b4e Mon Sep 17 00:00:00 2001 From: Nick Esposito Date: Thu, 3 Sep 2026 16:25:33 -0400 Subject: [PATCH 2/4] fix(chat): also restore an archived chat before steering it Co-authored-by: Goose --- .../chat/lib/__tests__/steerCore.test.ts | 85 +++++++++++++++++++ src/features/chat/lib/sendCore.ts | 2 +- src/features/chat/lib/steerCore.ts | 4 + 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/features/chat/lib/__tests__/steerCore.test.ts b/src/features/chat/lib/__tests__/steerCore.test.ts index a11963d08..2fcd3247d 100644 --- a/src/features/chat/lib/__tests__/steerCore.test.ts +++ b/src/features/chat/lib/__tests__/steerCore.test.ts @@ -1,13 +1,24 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { useChatStore } from "../../stores/chatStore"; +import { useChatSessionStore } from "../../stores/chatSessionStore"; +import type { ChatSession } from "../../stores/chatSessionStore"; import { MAX_PROMPT_ATTACHMENT_BYTES } from "../attachmentPayloadBudget"; const mockAcpSteerMessage = vi.fn(); +const mockUnarchiveSession = vi.fn(); vi.mock("@/shared/api/acp", () => ({ acpSteerMessage: (...args: unknown[]) => mockAcpSteerMessage(...args), })); +vi.mock("@/shared/api/acpApi", () => ({ + archiveSession: vi.fn().mockResolvedValue(undefined), + unarchiveSession: (...args: unknown[]) => mockUnarchiveSession(...args), + renameSession: vi.fn().mockResolvedValue(undefined), + updateSessionProject: vi.fn().mockResolvedValue(undefined), + updateWorkingDir: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("@/shared/i18n", () => ({ i18n: { t: (key: string, params?: Record) => @@ -273,3 +284,77 @@ describe("steerPromptInSession voice no-op", () => { }); }); }); + +// Steering is a send: an archived chat must be restored before the steer is +// injected, mirroring the dispatchPrompt restore. +describe("steerPromptInSession archived session restore", () => { + const ARCHIVED_AT = "2026-04-02T00:00:00.000Z"; + + beforeEach(() => { + vi.clearAllMocks(); + mockAcpSteerMessage.mockResolvedValue({ + runId: "run-1", + messageId: "msg-1", + }); + mockUnarchiveSession.mockResolvedValue(undefined); + useChatStore.setState({ + messagesBySession: {}, + sessionStateById: {}, + activeSessionId: null, + isConnected: true, + }); + useChatSessionStore.setState({ + sessions: [], + activeSessionId: null, + activeWorkspaceBySession: {}, + archiveMutationBySessionId: {}, + }); + }); + + it("restores an archived session before steering", async () => { + useChatSessionStore.setState((state) => ({ + sessions: [ + { + id: "session-1", + title: "Test Session", + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + messageCount: 1, + archivedAt: ARCHIVED_AT, + }, + ...state.sessions, + ], + })); + + const accepted = await steerPromptInSession("session-1", "one more thing"); + + expect(accepted).toBe(true); + expect(mockUnarchiveSession).toHaveBeenCalledWith("session-1"); + expect( + useChatSessionStore.getState().getSession("session-1")?.archivedAt, + ).toBeUndefined(); + expect(mockUnarchiveSession.mock.invocationCallOrder[0]).toBeLessThan( + mockAcpSteerMessage.mock.invocationCallOrder[0], + ); + }); + + it("does not restore an active session", async () => { + useChatSessionStore.setState((state) => ({ + sessions: [ + { + id: "session-1", + title: "Test Session", + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + messageCount: 1, + } satisfies ChatSession, + ...state.sessions, + ], + })); + + const accepted = await steerPromptInSession("session-1", "one more thing"); + + expect(accepted).toBe(true); + expect(mockUnarchiveSession).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/chat/lib/sendCore.ts b/src/features/chat/lib/sendCore.ts index 249a721fe..5b3bccf73 100644 --- a/src/features/chat/lib/sendCore.ts +++ b/src/features/chat/lib/sendCore.ts @@ -323,7 +323,7 @@ export function resolveAssistantCancellation( * the backend still surfaces its own error if the archived session cannot * accept the prompt. */ -async function restoreArchivedSessionBeforeSend( +export async function restoreArchivedSessionBeforeSend( sessionId: string, ): Promise { const sessionStore = useChatSessionStore.getState(); diff --git a/src/features/chat/lib/steerCore.ts b/src/features/chat/lib/steerCore.ts index 645c2e26e..2f53867c8 100644 --- a/src/features/chat/lib/steerCore.ts +++ b/src/features/chat/lib/steerCore.ts @@ -22,6 +22,7 @@ import { } from "./attachments"; import { isSessionRunning } from "./sessionActivity"; import { getSessionPromptOwner } from "./sessionPromptOwnership"; +import { restoreArchivedSessionBeforeSend } from "./sendCore"; import { isVoiceConversationEmptyResponse } from "./voiceConversationNoop"; import { i18n } from "@/shared/i18n"; @@ -42,6 +43,9 @@ export async function steerPromptInSession( reportErrorInTranscript?: boolean; } = {}, ): Promise { + // Steering is a send: restore an archived chat before injecting into the + // run, mirroring dispatchPrompt. Best-effort; see sendCore for details. + await restoreArchivedSessionBeforeSend(sessionId); const sessionRunsRemotely = Boolean( useChatSessionStore.getState().getSession(sessionId)?.remoteHost, ); From 3678fe39eb4e75a72e266ab3ef8bf9841c5e953d Mon Sep 17 00:00:00 2001 From: Nick Esposito Date: Fri, 4 Sep 2026 16:12:23 -0400 Subject: [PATCH 3/4] fix(chat): gate sends on archived session restore Co-authored-by: Goose --- .../chat/lib/__tests__/steerCore.test.ts | 62 +++++++++ src/features/chat/lib/sendCore.test.ts | 87 ++++++++++--- src/features/chat/lib/sendCore.ts | 32 ++--- src/features/chat/lib/steerCore.ts | 8 +- src/features/chat/stores/chatSessionStore.ts | 121 ++++++++++++------ src/shared/api/acp.ts | 3 +- src/shared/api/acpApi.ts | 2 + 7 files changed, 233 insertions(+), 82 deletions(-) diff --git a/src/features/chat/lib/__tests__/steerCore.test.ts b/src/features/chat/lib/__tests__/steerCore.test.ts index 2fcd3247d..be7b405a9 100644 --- a/src/features/chat/lib/__tests__/steerCore.test.ts +++ b/src/features/chat/lib/__tests__/steerCore.test.ts @@ -40,6 +40,68 @@ function oversizedImageDraft() { }; } +function seedArchivedSession() { + useChatSessionStore.setState({ + sessions: [ + { + id: "session-1", + title: "Archived", + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + archivedAt: "2026-04-02T00:00:00.000Z", + messageCount: 1, + }, + ], + archiveMutationBySessionId: {}, + }); +} + +describe("steerPromptInSession archived session restore", () => { + beforeEach(() => { + vi.clearAllMocks(); + useChatSessionStore.setState({ + sessions: [], + archiveMutationBySessionId: {}, + }); + mockUnarchiveSession.mockResolvedValue(undefined); + mockAcpSteerMessage.mockResolvedValue({ + runId: "run-1", + messageId: "msg-1", + }); + }); + + it("does not restore an empty steer", async () => { + seedArchivedSession(); + await expect(steerPromptInSession("session-1", "")).resolves.toBe(false); + expect(mockUnarchiveSession).not.toHaveBeenCalled(); + expect(mockAcpSteerMessage).not.toHaveBeenCalled(); + }); + + it("does not restore an oversized steer", async () => { + seedArchivedSession(); + await expect( + steerPromptInSession("session-1", "look", [oversizedImageDraft()]), + ).resolves.toBe(false); + expect(mockUnarchiveSession).not.toHaveBeenCalled(); + expect(mockAcpSteerMessage).not.toHaveBeenCalled(); + }); + + it("restores only after validation and waits for durable success", async () => { + seedArchivedSession(); + let resolveRestore!: () => void; + const restore = new Promise((resolve) => { + resolveRestore = resolve; + }); + mockUnarchiveSession.mockReturnValueOnce(restore); + const pending = steerPromptInSession("session-1", "look"); + await Promise.resolve(); + expect(mockAcpSteerMessage).not.toHaveBeenCalled(); + resolveRestore(); + await pending; + expect(mockAcpSteerMessage).toHaveBeenCalledTimes(1); + }); +}); + describe("steerPromptInSession payload budget", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/src/features/chat/lib/sendCore.test.ts b/src/features/chat/lib/sendCore.test.ts index 34a044d4e..63f5aa43d 100644 --- a/src/features/chat/lib/sendCore.test.ts +++ b/src/features/chat/lib/sendCore.test.ts @@ -11,6 +11,7 @@ import { setVoiceConversationMode } from "@/features/voice-conversation/lib/voic const mocks = vi.hoisted(() => ({ acpExportSession: vi.fn(), acpSendMessage: vi.fn(), + archiveSession: vi.fn(), unarchiveSession: vi.fn(), })); @@ -20,7 +21,7 @@ vi.mock("@/shared/api/acp", () => ({ })); vi.mock("@/shared/api/acpApi", () => ({ - archiveSession: vi.fn().mockResolvedValue(undefined), + archiveSession: (...args: unknown[]) => mocks.archiveSession(...args), unarchiveSession: (...args: unknown[]) => mocks.unarchiveSession(...args), renameSession: vi.fn().mockResolvedValue(undefined), updateSessionProject: vi.fn().mockResolvedValue(undefined), @@ -648,6 +649,16 @@ describe("dispatchPrompt realtime Master transcript recovery", () => { describe("dispatchPrompt archived session restore", () => { const ARCHIVED_AT = "2026-04-02T00:00:00.000Z"; + function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; + } + function seedSession(overrides: Partial = {}): ChatSession { const session: ChatSession = { id: "session-1", @@ -694,6 +705,39 @@ describe("dispatchPrompt archived session restore", () => { ); }); + it("waits for a shared durable restore before dispatching", async () => { + seedSession({ archivedAt: ARCHIVED_AT }); + const restore = deferred(); + mocks.unarchiveSession.mockReturnValue(restore.promise); + + const firstSend = dispatchPrompt("session-1", "one", {}); + const secondSend = dispatchPrompt("session-1", "two", {}); + await Promise.resolve(); + + expect(mocks.unarchiveSession).toHaveBeenCalledTimes(1); + expect(mocks.acpSendMessage).not.toHaveBeenCalled(); + restore.resolve(undefined); + await Promise.all([firstSend, secondSend]); + expect(mocks.acpSendMessage).toHaveBeenCalledTimes(2); + }); + + it("does not dispatch if a newer archive wins the restore race", async () => { + seedSession({ archivedAt: ARCHIVED_AT }); + const restore = deferred(); + mocks.unarchiveSession.mockReturnValue(restore.promise); + + const send = dispatchPrompt("session-1", "hello", {}); + await Promise.resolve(); + await useChatSessionStore.getState().archiveSession("session-1"); + restore.resolve(undefined); + + await expect(send).rejects.toThrow("was archived"); + expect(mocks.acpSendMessage).not.toHaveBeenCalled(); + expect( + useChatSessionStore.getState().getSession("session-1")?.archivedAt, + ).toEqual(expect.any(String)); + }); + it("leaves active sessions untouched", async () => { seedSession(); @@ -703,25 +747,30 @@ describe("dispatchPrompt archived session restore", () => { expect(mocks.acpSendMessage).toHaveBeenCalledTimes(1); }); - it("still dispatches when the restore fails", async () => { + it("does not dispatch when the restore fails", async () => { seedSession({ archivedAt: ARCHIVED_AT }); mocks.unarchiveSession.mockRejectedValue(new Error("backend down")); - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - - try { - await dispatchPrompt("session-1", "hello again", {}); - expect(mocks.acpSendMessage).toHaveBeenCalledTimes(1); - // The store rolls the optimistic unarchive back when the backend call - // fails. - expect( - useChatSessionStore.getState().getSession("session-1")?.archivedAt, - ).toBe(ARCHIVED_AT); - expect(warn).toHaveBeenCalledWith( - "[send] failed to restore archived session session-1", - expect.any(Error), - ); - } finally { - warn.mockRestore(); - } + + await expect( + dispatchPrompt("session-1", "hello again", {}), + ).rejects.toThrow("backend down"); + expect(mocks.acpSendMessage).not.toHaveBeenCalled(); + expect( + useChatSessionStore.getState().getSession("session-1")?.archivedAt, + ).toBe(ARCHIVED_AT); + }); + + it("does not restore a rejected preparation", async () => { + seedSession({ archivedAt: ARCHIVED_AT }); + + await expect( + dispatchPrompt("session-1", "stale", { + prepare: () => { + throw new Error("superseded"); + }, + }), + ).rejects.toThrow("superseded"); + expect(mocks.unarchiveSession).not.toHaveBeenCalled(); + expect(mocks.acpSendMessage).not.toHaveBeenCalled(); }); }); diff --git a/src/features/chat/lib/sendCore.ts b/src/features/chat/lib/sendCore.ts index 5b3bccf73..38d4fa82e 100644 --- a/src/features/chat/lib/sendCore.ts +++ b/src/features/chat/lib/sendCore.ts @@ -315,29 +315,11 @@ export function resolveAssistantCancellation( finalizeAssistantCancellationRace(promptOwner); } -/** - * Sending a message to an archived chat restores it: the archive flag clears - * locally and on the backend before any prompt preparation or dispatch, so - * the conversation moves back to the active list instead of replying while - * hidden. Best-effort: a failed restore logs a warning and the send proceeds; - * the backend still surfaces its own error if the archived session cannot - * accept the prompt. - */ +/** Restore an archived chat and require durable success before sending. */ export async function restoreArchivedSessionBeforeSend( sessionId: string, ): Promise { - const sessionStore = useChatSessionStore.getState(); - if (!sessionStore.getSession(sessionId)?.archivedAt) { - return; - } - try { - await sessionStore.unarchiveSession(sessionId); - } catch (error) { - console.warn( - `[send] failed to restore archived session ${sessionId}`, - error, - ); - } + await useChatSessionStore.getState().ensureSessionActive(sessionId); } /** @@ -467,10 +449,13 @@ export async function dispatchPrompt( // local transcript state so a retained queued record can retry without // duplicating the user turn. throwIfAborted(signal); - await restoreArchivedSessionBeforeSend(sessionId); await prepare?.(); throwIfAborted(signal); + await restoreArchivedSessionBeforeSend(sessionId); + throwIfAborted(signal); + useChatSessionStore.getState().assertSessionActive(sessionId); + const commitUserMessage = () => { throwIfAborted(signal); beforeUserMessageCommitted?.(); @@ -564,7 +549,10 @@ export async function dispatchPrompt( images: images?.map( (img) => [img.base64, img.mimeType] as [string, string], ), - onPromptDispatching: commitUserMessage, + onPromptDispatching: () => { + useChatSessionStore.getState().assertSessionActive(sessionId); + commitUserMessage(); + }, onPromptDispatched: () => { onPromptDispatched?.(); }, diff --git a/src/features/chat/lib/steerCore.ts b/src/features/chat/lib/steerCore.ts index 2f53867c8..0d4278afe 100644 --- a/src/features/chat/lib/steerCore.ts +++ b/src/features/chat/lib/steerCore.ts @@ -43,9 +43,6 @@ export async function steerPromptInSession( reportErrorInTranscript?: boolean; } = {}, ): Promise { - // Steering is a send: restore an archived chat before injecting into the - // run, mirroring dispatchPrompt. Best-effort; see sendCore for details. - await restoreArchivedSessionBeforeSend(sessionId); const sessionRunsRemotely = Boolean( useChatSessionStore.getState().getSession(sessionId)?.remoteHost, ); @@ -130,6 +127,8 @@ export async function steerPromptInSession( }); try { + await restoreArchivedSessionBeforeSend(sessionId); + useChatSessionStore.getState().assertSessionActive(sessionId); const steerResponse = await acpSteerMessage( sessionId, activeRunId, @@ -142,6 +141,9 @@ export async function steerPromptInSession( images: images?.map( (img) => [img.base64, img.mimeType] as [string, string], ), + onSteerDispatching: () => { + useChatSessionStore.getState().assertSessionActive(sessionId); + }, }, ); const steeredRunId = steerResponse.runId; diff --git a/src/features/chat/stores/chatSessionStore.ts b/src/features/chat/stores/chatSessionStore.ts index 8406673cb..8d899d39e 100644 --- a/src/features/chat/stores/chatSessionStore.ts +++ b/src/features/chat/stores/chatSessionStore.ts @@ -56,6 +56,7 @@ const LEGACY_CONTEXT_PANEL_OPEN_STORAGE_KEY = "goose:context-panel-open"; let sessionLoadEpoch = 0; let archiveMutationOperationId = 0; const inFlightArchiveMutationIdsBySessionId = new Map>(); +const inFlightUnarchiveBySessionId = new Map>(); /** Thrown by archiveSession when the id matches no session in the store. */ export class SessionNotFoundError extends Error { @@ -248,6 +249,12 @@ interface ChatSessionStoreActions { * rethrown. */ unarchiveSession: (id: string) => Promise; + /** + * Wait until a session is durably active. Concurrent callers share one + * restore operation, and an archive that wins the race rejects the gate. + */ + ensureSessionActive: (id: string) => Promise; + assertSessionActive: (id: string) => void; setActiveSession: (sessionId: string | null) => void; setRightRailOpen: (open: boolean) => void; @@ -1097,45 +1104,85 @@ export const useChatSessionStore = create((set, get) => ({ } }, - unarchiveSession: async (id) => { - const session = get().sessions.find((candidate) => candidate.id === id); - if (!session) { - return; + unarchiveSession: (id) => { + const existing = inFlightUnarchiveBySessionId.get(id); + if (existing) { + return existing; } - const operationId = ++archiveMutationOperationId; - const mutation: ArchiveSessionMutation = { - operationId, - desiredState: "unarchived", - previousArchivedAt: getArchiveMutationRollbackArchivedAt( - session, - get().archiveMutationBySessionId[id], - ), - status: "pending", - }; - trackArchiveMutation(id, operationId); - set((state) => ({ - sessions: state.sessions.map((candidate) => - candidate.id === id - ? { ...candidate, archivedAt: undefined } - : candidate, - ), - archiveMutationBySessionId: { - ...state.archiveMutationBySessionId, - [id]: mutation, - }, - })); - try { - await acpUnarchiveSession(session.id); - set((state) => recordArchiveMutationSuccess(state, id, mutation)); - settleArchiveMutationAndCancelIfArchived(get(), id, operationId); - const unarchived = get().getSession(id); - if (unarchived?.remoteHost) { - persistRemoteSessionRecordForSession(unarchived); + + const restore = (async () => { + const session = get().sessions.find((candidate) => candidate.id === id); + if (!session) { + return; } - } catch (error) { - set((state) => rollbackFailedArchiveMutation(state, id, operationId)); - settleArchiveMutationAndCancelIfArchived(get(), id, operationId); - throw error; + const operationId = ++archiveMutationOperationId; + const mutation: ArchiveSessionMutation = { + operationId, + desiredState: "unarchived", + previousArchivedAt: getArchiveMutationRollbackArchivedAt( + session, + get().archiveMutationBySessionId[id], + ), + status: "pending", + }; + trackArchiveMutation(id, operationId); + set((state) => ({ + sessions: state.sessions.map((candidate) => + candidate.id === id + ? { ...candidate, archivedAt: undefined } + : candidate, + ), + archiveMutationBySessionId: { + ...state.archiveMutationBySessionId, + [id]: mutation, + }, + })); + try { + await acpUnarchiveSession(session.id); + set((state) => recordArchiveMutationSuccess(state, id, mutation)); + settleArchiveMutationAndCancelIfArchived(get(), id, operationId); + const unarchived = get().getSession(id); + if (unarchived?.remoteHost) { + persistRemoteSessionRecordForSession(unarchived); + } + } catch (error) { + set((state) => rollbackFailedArchiveMutation(state, id, operationId)); + settleArchiveMutationAndCancelIfArchived(get(), id, operationId); + throw error; + } + })(); + inFlightUnarchiveBySessionId.set(id, restore); + void restore.then( + () => { + if (inFlightUnarchiveBySessionId.get(id) === restore) { + inFlightUnarchiveBySessionId.delete(id); + } + }, + () => { + if (inFlightUnarchiveBySessionId.get(id) === restore) { + inFlightUnarchiveBySessionId.delete(id); + } + }, + ); + return restore; + }, + + ensureSessionActive: async (id) => { + const existing = inFlightUnarchiveBySessionId.get(id); + if (existing) { + await existing; + } else if (get().getSession(id)?.archivedAt) { + await get().unarchiveSession(id); + } + + if (get().getSession(id)?.archivedAt) { + throw new Error(`Session ${id} was archived before the send started.`); + } + }, + + assertSessionActive: (id) => { + if (get().getSession(id)?.archivedAt) { + throw new Error(`Session ${id} was archived before the send started.`); } }, diff --git a/src/shared/api/acp.ts b/src/shared/api/acp.ts index 29925b06d..614e0d660 100644 --- a/src/shared/api/acp.ts +++ b/src/shared/api/acp.ts @@ -303,7 +303,7 @@ export async function acpSteerMessage( options: Pick< AcpSendMessageOptions, "assistantPrompt" | "goose" | "images" - > = {}, + > & { onSteerDispatching?: () => void } = {}, ): Promise { sessionRegistry.requireSessionInvocationSelection(sessionId); const { assistantPrompt, goose, images } = options; @@ -328,6 +328,7 @@ export async function acpSteerMessage( content, expectedRunId, goose && Object.keys(goose).length > 0 ? { goose } : undefined, + { onSteerDispatching: options.onSteerDispatching }, ); } diff --git a/src/shared/api/acpApi.ts b/src/shared/api/acpApi.ts index ee6561198..177eb0bc7 100644 --- a/src/shared/api/acpApi.ts +++ b/src/shared/api/acpApi.ts @@ -649,9 +649,11 @@ export async function steerSession( content: ContentBlock[], expectedRunId: string | null, meta?: Record, + callbacks: { onSteerDispatching?: () => void } = {}, ): Promise { const client = await getClientForSession(sessionId); const steer = async (runId: string): Promise => { + callbacks.onSteerDispatching?.(); const response = await client.extMethod("_goose/unstable/session/steer", { sessionId: getWireSessionId(sessionId), prompt: content, From 1a52dc5a338d62f50692083657ed812ba62e86aa Mon Sep 17 00:00:00 2001 From: Nick Esposito Date: Fri, 4 Sep 2026 16:28:47 -0400 Subject: [PATCH 4/4] fix(chat): keep steer API options stable Co-authored-by: Goose --- src/features/chat/lib/steerCore.ts | 3 --- src/shared/api/acp.ts | 3 +-- src/shared/api/acpApi.ts | 2 -- 3 files changed, 1 insertion(+), 7 deletions(-) diff --git a/src/features/chat/lib/steerCore.ts b/src/features/chat/lib/steerCore.ts index 0d4278afe..c9a63ff90 100644 --- a/src/features/chat/lib/steerCore.ts +++ b/src/features/chat/lib/steerCore.ts @@ -141,9 +141,6 @@ export async function steerPromptInSession( images: images?.map( (img) => [img.base64, img.mimeType] as [string, string], ), - onSteerDispatching: () => { - useChatSessionStore.getState().assertSessionActive(sessionId); - }, }, ); const steeredRunId = steerResponse.runId; diff --git a/src/shared/api/acp.ts b/src/shared/api/acp.ts index 614e0d660..29925b06d 100644 --- a/src/shared/api/acp.ts +++ b/src/shared/api/acp.ts @@ -303,7 +303,7 @@ export async function acpSteerMessage( options: Pick< AcpSendMessageOptions, "assistantPrompt" | "goose" | "images" - > & { onSteerDispatching?: () => void } = {}, + > = {}, ): Promise { sessionRegistry.requireSessionInvocationSelection(sessionId); const { assistantPrompt, goose, images } = options; @@ -328,7 +328,6 @@ export async function acpSteerMessage( content, expectedRunId, goose && Object.keys(goose).length > 0 ? { goose } : undefined, - { onSteerDispatching: options.onSteerDispatching }, ); } diff --git a/src/shared/api/acpApi.ts b/src/shared/api/acpApi.ts index 177eb0bc7..ee6561198 100644 --- a/src/shared/api/acpApi.ts +++ b/src/shared/api/acpApi.ts @@ -649,11 +649,9 @@ export async function steerSession( content: ContentBlock[], expectedRunId: string | null, meta?: Record, - callbacks: { onSteerDispatching?: () => void } = {}, ): Promise { const client = await getClientForSession(sessionId); const steer = async (runId: string): Promise => { - callbacks.onSteerDispatching?.(); const response = await client.extMethod("_goose/unstable/session/steer", { sessionId: getWireSessionId(sessionId), prompt: content,