diff --git a/src/features/chat/lib/__tests__/steerCore.test.ts b/src/features/chat/lib/__tests__/steerCore.test.ts index a11963d08..be7b405a9 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) => @@ -29,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(); @@ -273,3 +346,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.test.ts b/src/features/chat/lib/sendCore.test.ts index b66ab0e94..63f5aa43d 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,8 @@ import { setVoiceConversationMode } from "@/features/voice-conversation/lib/voic const mocks = vi.hoisted(() => ({ acpExportSession: vi.fn(), acpSendMessage: vi.fn(), + archiveSession: vi.fn(), + unarchiveSession: vi.fn(), })); vi.mock("@/shared/api/acp", () => ({ @@ -17,6 +20,14 @@ vi.mock("@/shared/api/acp", () => ({ acpSendMessage: (...args: unknown[]) => mocks.acpSendMessage(...args), })); +vi.mock("@/shared/api/acpApi", () => ({ + archiveSession: (...args: unknown[]) => mocks.archiveSession(...args), + 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 +645,132 @@ describe("dispatchPrompt realtime Master transcript recovery", () => { release(); }); }); + +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", + 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("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(); + + await dispatchPrompt("session-1", "hello", {}); + + expect(mocks.unarchiveSession).not.toHaveBeenCalled(); + expect(mocks.acpSendMessage).toHaveBeenCalledTimes(1); + }); + + it("does not dispatch when the restore fails", async () => { + seedSession({ archivedAt: ARCHIVED_AT }); + mocks.unarchiveSession.mockRejectedValue(new Error("backend down")); + + 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 14acdf02d..38d4fa82e 100644 --- a/src/features/chat/lib/sendCore.ts +++ b/src/features/chat/lib/sendCore.ts @@ -315,6 +315,13 @@ export function resolveAssistantCancellation( finalizeAssistantCancellationRace(promptOwner); } +/** Restore an archived chat and require durable success before sending. */ +export async function restoreArchivedSessionBeforeSend( + sessionId: string, +): Promise { + await useChatSessionStore.getState().ensureSessionActive(sessionId); +} + /** * Foreground send core: commits the user message, drives the * thinking-to-streaming-to-idle chat-state transitions, patches the session @@ -445,6 +452,10 @@ export async function dispatchPrompt( await prepare?.(); throwIfAborted(signal); + await restoreArchivedSessionBeforeSend(sessionId); + throwIfAborted(signal); + useChatSessionStore.getState().assertSessionActive(sessionId); + const commitUserMessage = () => { throwIfAborted(signal); beforeUserMessageCommitted?.(); @@ -538,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 645c2e26e..c9a63ff90 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"; @@ -126,6 +127,8 @@ export async function steerPromptInSession( }); try { + await restoreArchivedSessionBeforeSend(sessionId); + useChatSessionStore.getState().assertSessionActive(sessionId); const steerResponse = await acpSteerMessage( sessionId, activeRunId, 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.`); } },