Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions src/features/chat/hooks/__tests__/useChat.compaction.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -47,6 +48,10 @@ function createTextMessage(

describe("useChat compaction", () => {
beforeEach(() => {
useChatSessionStore.setState({
sessions: [],
activeWorkspaceBySession: {},
});
mockAcpSendMessage.mockReset();
mockAcpLoadSession.mockReset();
clearBufferedStreamingUpdatesForSession("session-1");
Expand Down Expand Up @@ -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", {
Expand Down
165 changes: 165 additions & 0 deletions src/shared/api/__tests__/acpLoadSession.cwd.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>(),
Expand All @@ -22,13 +23,18 @@ 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", () => {
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
mocks.generations.clear();
mocks.resolvePath.mockResolvedValue({ path: "/Users/dev" });
mocks.invalidateBackendConnection.mockImplementation(
async (backendId: string) => {
mocks.generations.set(
Expand Down Expand Up @@ -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({
Expand Down
15 changes: 15 additions & 0 deletions src/shared/api/acpSessionRegistry.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading