diff --git a/src/features/chat/hooks/__tests__/useChat.compaction.test.ts b/src/features/chat/hooks/__tests__/useChat.compaction.test.ts index 03f1d53fc..6747bc306 100644 --- a/src/features/chat/hooks/__tests__/useChat.compaction.test.ts +++ b/src/features/chat/hooks/__tests__/useChat.compaction.test.ts @@ -1,6 +1,7 @@ import { act, renderHook } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Message } from "@/shared/types/messages"; +import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; import { useChatStore } from "../../stores/chatStore"; import { clearBufferedStreamingUpdatesForSession, @@ -47,6 +48,10 @@ function createTextMessage( describe("useChat compaction", () => { beforeEach(() => { + useChatSessionStore.setState({ + sessions: [], + activeWorkspaceBySession: {}, + }); mockAcpSendMessage.mockReset(); mockAcpLoadSession.mockReset(); clearBufferedStreamingUpdatesForSession("session-1"); @@ -122,6 +127,61 @@ describe("useChat compaction", () => { ); }); + it("reloads compaction using the attached home-relative artifacts workspace", async () => { + useChatSessionStore.setState({ + sessions: [ + { + id: "session-1", + title: "Artifacts", + createdAt: "2026-10-06T00:00:00Z", + updatedAt: "2026-10-06T00:00:00Z", + messageCount: 1, + workingDir: "/Users/dev/goose artifacts", + activeWorkspaceId: "artifacts", + workspaceAttachments: [ + { + id: "artifacts", + path: "~/goose artifacts", + kind: "directory", + source: "selected", + usedByAgent: true, + }, + ], + }, + ], + }); + mockAcpLoadSession.mockImplementation(async (sessionId: string) => { + ensureReplayBuffer(sessionId).push( + createTextMessage("replayed", "assistant", "Compacted history"), + ); + }); + useChatStore + .getState() + .setMessages("session-1", [ + createTextMessage("stale", "assistant", "Previous history"), + ]); + const { result } = renderHook(() => useChat("session-1")); + + await act(async () => { + await result.current.compactConversation(); + }); + + // The shared load boundary resolves this local path, not the hook. + expect(mockAcpLoadSession).toHaveBeenCalledWith( + "session-1", + "~/goose artifacts", + ); + const messages = useChatStore.getState().messagesBySession["session-1"]; + expect(messages.map((message) => message.id)).toEqual([ + "replayed", + expect.any(String), + ]); + expect(messages[1].content[0]).toMatchObject({ + type: "systemNotification", + notificationType: "compaction", + }); + }); + it("drops buffered compact command output before replacing the transcript", async () => { mockAcpSendMessage.mockImplementation(async () => { useChatStore.getState().addMessage("session-1", { diff --git a/src/shared/api/__tests__/acpLoadSession.cwd.test.ts b/src/shared/api/__tests__/acpLoadSession.cwd.test.ts index 29ff12d10..a4c7e6d17 100644 --- a/src/shared/api/__tests__/acpLoadSession.cwd.test.ts +++ b/src/shared/api/__tests__/acpLoadSession.cwd.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ getBackendClient: vi.fn(), sessionInfo: vi.fn(), + resolvePath: vi.fn(), loadSession: vi.fn(), invalidateBackendConnection: vi.fn(), generations: new Map(), @@ -22,6 +23,10 @@ vi.mock("../acpConnection", () => ({ interceptSessionNotifications: vi.fn(), })); +vi.mock("@/shared/api/pathResolver", () => ({ + resolvePath: mocks.resolvePath, +})); + // Keep acpLoadSession, the mutation registry, and acpApi real. A mock at the // acpLoadSession boundary would hide the literal "~" sent to the backend. describe("acpLoadSession working directory at the transport boundary", () => { @@ -29,6 +34,7 @@ describe("acpLoadSession working directory at the transport boundary", () => { vi.resetModules(); vi.resetAllMocks(); mocks.generations.clear(); + mocks.resolvePath.mockResolvedValue({ path: "/Users/dev" }); mocks.invalidateBackendConnection.mockImplementation( async (backendId: string) => { mocks.generations.set( @@ -111,6 +117,165 @@ describe("acpLoadSession working directory at the transport boundary", () => { }); }); + it.each([ + ["~/goose artifacts", "/Users/dev", "/Users/dev/goose artifacts"], + ["~", "/Users/dev", "/Users/dev"], + ["~\\project", "C:\\Users\\dev", "C:\\Users\\dev\\project"], + [ + "~/ project with trailing space ", + "/Users/dev", + "/Users/dev/ project with trailing space ", + ], + ])("expands the local home prefix in %s before transport", async (cwd, home, expected) => { + const { acpLoadSession } = await import("../acp"); + mocks.resolvePath.mockResolvedValue({ path: home }); + + await acpLoadSession("session-1", cwd); + + expect(mocks.resolvePath).toHaveBeenCalledExactlyOnceWith({ parts: ["~"] }); + expect(mocks.sessionInfo).not.toHaveBeenCalled(); + expect(mocks.loadSession).toHaveBeenCalledWith({ + sessionId: "session-1", + cwd: expected, + mcpServers: [], + }); + // The prepared cwd is absolute too, so a later reload needs no resolution. + await acpLoadSession("session-1"); + expect(mocks.resolvePath).toHaveBeenCalledOnce(); + expect(mocks.loadSession).toHaveBeenLastCalledWith({ + sessionId: "session-1", + cwd: expected, + mcpServers: [], + }); + }); + + it.each([ + "prepared", + "metadata", + ])("expands a local home prefix from %s", async (source) => { + const registry = await import("../acpSessionRegistry"); + const { acpLoadSession } = await import("../acp"); + if (source === "prepared") { + registry.registerPreparedSession( + "session-1", + "openai", + "~/goose artifacts", + ); + } else { + mocks.sessionInfo.mockResolvedValue({ + session: { sessionId: "session-1", cwd: "~/goose artifacts" }, + }); + } + + await acpLoadSession("session-1"); + + expect(mocks.loadSession).toHaveBeenCalledWith({ + sessionId: "session-1", + cwd: "/Users/dev/goose artifacts", + mcpServers: [], + }); + }); + + it.each([ + "ssh:devbox#session-1", + "registered-remote", + ])("preserves remote home paths for %s", async (sessionId) => { + const { registerSessionBackend } = await import("../acpSessionBackends"); + const { acpLoadSession } = await import("../acp"); + if (sessionId === "registered-remote") { + registerSessionBackend(sessionId, "ssh:devbox", "session-1"); + } + mocks.loadSession.mockResolvedValue({ configOptions: [] }); + + await acpLoadSession(sessionId, "~/goose artifacts"); + + expect(mocks.resolvePath).not.toHaveBeenCalled(); + expect(mocks.getBackendClient).toHaveBeenLastCalledWith("ssh:devbox"); + expect(mocks.loadSession).toHaveBeenCalledWith({ + sessionId: "session-1", + cwd: "~/goose artifacts", + mcpServers: [], + }); + }); + + it("does not expand named-user home paths", async () => { + const { acpLoadSession } = await import("../acp"); + + await expect( + acpLoadSession("session-1", "~someone/project"), + ).rejects.toThrow("cwd must be an absolute path"); + + expect(mocks.resolvePath).not.toHaveBeenCalled(); + }); + + it("does not dispatch history when home resolution fails", async () => { + const { acpLoadSession } = await import("../acp"); + mocks.resolvePath.mockRejectedValue(new Error("home unavailable")); + + await expect( + acpLoadSession("session-1", "~/goose artifacts"), + ).rejects.toThrow("home unavailable"); + + expect(mocks.loadSession).not.toHaveBeenCalled(); + }); + + it("times out home resolution and admits a queued absolute load", async () => { + vi.useFakeTimers(); + const { acpLoadSession } = await import("../acp"); + let resolveHome!: (value: { path: string }) => void; + mocks.resolvePath.mockReturnValueOnce( + new Promise((resolve) => { + resolveHome = resolve; + }), + ); + const rejection = expect( + acpLoadSession("session-1", "~/goose artifacts"), + ).rejects.toThrow("ACP operation timed out"); + await vi.advanceTimersByTimeAsync(0); + const newerLoad = acpLoadSession("session-1", "/new/project"); + + await vi.advanceTimersByTimeAsync(60_000); + await rejection; + await newerLoad; + resolveHome({ path: "/stale/home" }); + await vi.advanceTimersByTimeAsync(0); + + expect(mocks.invalidateBackendConnection).toHaveBeenCalledExactlyOnceWith( + "local", + ); + expect(mocks.loadSession).toHaveBeenCalledExactlyOnceWith({ + sessionId: "session-1", + cwd: "/new/project", + mcpServers: [], + }); + await acpLoadSession("session-1"); + expect(mocks.loadSession).toHaveBeenLastCalledWith({ + sessionId: "session-1", + cwd: "/new/project", + mcpServers: [], + }); + }); + + it("does not dispatch replay when the connection detaches during home resolution", async () => { + const { acpLoadSession } = await import("../acp"); + let resolveHome!: (value: { path: string }) => void; + mocks.resolvePath.mockReturnValueOnce( + new Promise((resolve) => { + resolveHome = resolve; + }), + ); + const rejection = expect( + acpLoadSession("session-1", "~/goose artifacts"), + ).rejects.toThrow("ACP history replay was abandoned"); + await vi.waitFor(() => expect(mocks.resolvePath).toHaveBeenCalledOnce()); + + await mocks.invalidateBackendConnection("local"); + resolveHome({ path: "/Users/dev" }); + await rejection; + + expect(mocks.loadSession).not.toHaveBeenCalled(); + }); + it("recovers a remote directory from its owning backend, not the local home", async () => { const { acpLoadSession } = await import("../acp"); mocks.sessionInfo.mockResolvedValue({ diff --git a/src/shared/api/acpSessionRegistry.ts b/src/shared/api/acpSessionRegistry.ts index 697475ead..10983e916 100644 --- a/src/shared/api/acpSessionRegistry.ts +++ b/src/shared/api/acpSessionRegistry.ts @@ -1,6 +1,8 @@ import * as acpApi from "./acpApi"; import { captureBackendConnectionGeneration } from "./acpConnection"; import { getSessionBackend } from "./acpSessionBackends"; +import { LOCAL_BACKEND_ID } from "@/shared/api/acpBackendId"; +import { resolvePath } from "@/shared/api/pathResolver"; import { readSessionExecutionConfigSnapshot, type AcpSessionConfigSnapshotContext, @@ -474,6 +476,19 @@ export async function loadSession( if (!effectiveWorkingDir) { throw new Error("Session working directory is unavailable."); } + if ( + getSessionBackend(sessionId) === LOCAL_BACKEND_ID && + /^(?:~$|~[/\\])/.test(effectiveWorkingDir) + ) { + // Attached local workspaces can retain a home-relative path. Resolve + // only the home prefix so directory-name spaces remain untouched. + // Never expand a remote cwd against this machine's home directory. + const { path: home } = await runBoundedSessionMutation(sessionId, () => + resolvePath({ parts: ["~"] }), + ); + assertActive(); + effectiveWorkingDir = home + effectiveWorkingDir.slice(1); + } const response = await acpApi.loadSession( sessionId, effectiveWorkingDir,