Skip to content
Draft
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
6 changes: 6 additions & 0 deletions __mocks__/obsidian.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,16 @@ module.exports = {
this.write = jest.fn();
this.exists = jest.fn().mockResolvedValue(true);
this.mkdir = jest.fn().mockResolvedValue(undefined);
this.list = jest.fn().mockResolvedValue({ files: [], folders: [] });
this.remove = jest.fn().mockResolvedValue(undefined);
}
getBasePath() {
return this._basePath;
}
getFullPath(p) {
const rel = String(p).replace(/\\/g, "/").replace(/^\/+/, "");
return rel ? `${this._basePath}/${rel}` : this._basePath;
}
},
normalizePath: (p) => String(p).replace(/\\\\/g, "/").replace(/\/+/g, "/"),
parseYaml: jest.fn().mockImplementation((content) => {
Expand Down
384 changes: 0 additions & 384 deletions designdocs/AGENT_HOME_ARCHITECTURE.md

This file was deleted.

230 changes: 230 additions & 0 deletions src/agentMode/acp/AcpBackendProcess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,40 @@ jest.mock("@/logger", () => ({
logError: jest.fn(),
}));

// Controllable ACP SDK mock (richer than the shared `__mocks__` stub): lets a
// test set the `initialize` response (to advertise capabilities) and capture
// the `newSession` request. `mock`-prefixed names satisfy ts-jest's jest.mock
// hoisting rules.
let mockInitializeResult: unknown = { protocolVersion: 1 };
const mockNewSession = jest.fn(async (..._args: unknown[]) => ({ sessionId: "test-session" }));

jest.mock("@agentclientprotocol/sdk", () => {
class RequestError extends Error {
code: number;
constructor(code: number, message?: string) {
super(message);
this.code = code;
this.name = "RequestError";
}
}
class ClientSideConnection {
_client: unknown;
constructor(toClient: (c: unknown) => unknown) {
this._client = toClient(this);
}
initialize = jest.fn(async () => mockInitializeResult);
newSession = (...args: unknown[]) => mockNewSession(...args);
prompt = jest.fn(async () => ({ stopReason: "end_turn" }));
cancel = jest.fn(async () => undefined);
}
return {
RequestError,
ClientSideConnection,
ndJsonStream: jest.fn(() => ({})),
PROTOCOL_VERSION: 1,
};
});

const exitListeners = new Set<() => void>();
let mockProcessIsRunning = true;

Expand Down Expand Up @@ -67,6 +101,9 @@ describe("AcpBackendProcess", () => {
beforeEach(() => {
exitListeners.clear();
mockProcessIsRunning = true;
mockInitializeResult = { protocolVersion: 1 };
mockNewSession.mockClear();
mockNewSession.mockResolvedValue({ sessionId: "test-session" });
});

it("routes session updates to the matching session handler and drops unknown ones", async () => {
Expand Down Expand Up @@ -101,6 +138,129 @@ describe("AcpBackendProcess", () => {
expect(handler).toHaveBeenCalledTimes(1);
});

it("scopes todowrite id tracking per session — a registered id does not bleed across sessions", async () => {
const backend = new AcpBackendProcess(
buildApp(),
buildStubBackend(),
"1.0.0",
buildStubDescriptor()
);
await backend.start();

const handlerA = jest.fn();
const handlerB = jest.fn();
backend.registerSessionHandler("sess-A", handlerA);
backend.registerSessionHandler("sess-B", handlerB);
const client = getVaultClient(backend);

// Session A registers "shared-id" as a todowrite call (synthesizes a plan).
await client.sessionUpdate({
sessionId: "sess-A",
update: {
sessionUpdate: "tool_call",
toolCallId: "shared-id",
title: "todowrite",
rawInput: { todos: [{ content: "a", status: "pending", priority: "high" }] },
},
} as unknown as Parameters<typeof client.sessionUpdate>[0]);
expect(handlerA.mock.calls.some(([e]) => e.update.sessionUpdate === "plan")).toBe(true);

// Session B sends a titleless update reusing the SAME id with a todos
// payload. With a process-wide Set this would masquerade as a plan; scoped
// per session, B's tracker is empty so no plan is synthesized.
handlerB.mockClear();
await client.sessionUpdate({
sessionId: "sess-B",
update: {
sessionUpdate: "tool_call_update",
toolCallId: "shared-id",
rawInput: { todos: [{ content: "leaked", status: "pending", priority: "low" }] },
},
} as unknown as Parameters<typeof client.sessionUpdate>[0]);
expect(handlerB.mock.calls.some(([e]) => e.update.sessionUpdate === "plan")).toBe(false);
});

it("keeps a session's todo tracker when re-registering the same sessionId (stale unsubscribe is a no-op)", async () => {
const backend = new AcpBackendProcess(
buildApp(),
buildStubBackend(),
"1.0.0",
buildStubDescriptor()
);
await backend.start();
const client = getVaultClient(backend);

// First handler registers "todo-1" as a todowrite call, then unsubscribes —
// but a SECOND handler for the same session is already registered, so the
// stale unsubscribe must not delete the live tracker.
const stale = backend.registerSessionHandler("sess-X", jest.fn());
await client.sessionUpdate({
sessionId: "sess-X",
update: {
sessionUpdate: "tool_call",
toolCallId: "todo-1",
title: "todowrite",
rawInput: { todos: [{ content: "a", status: "pending", priority: "high" }] },
},
} as unknown as Parameters<typeof client.sessionUpdate>[0]);

const fresh = jest.fn();
backend.registerSessionHandler("sess-X", fresh); // replaces the handler
stale(); // stale unsubscribe — must NOT drop sess-X's tracker

// A titleless follow-up for the registered id must still synthesize a plan,
// proving the tracker survived the stale unsubscribe.
await client.sessionUpdate({
sessionId: "sess-X",
update: {
sessionUpdate: "tool_call_update",
toolCallId: "todo-1",
rawInput: { todos: [{ content: "a", status: "in_progress", priority: "high" }] },
},
} as unknown as Parameters<typeof client.sessionUpdate>[0]);
expect(fresh.mock.calls.some(([e]) => e.update.sessionUpdate === "plan")).toBe(true);
});

it("drops todo trackers on subprocess exit so a restarted process starts clean", async () => {
const backend = new AcpBackendProcess(
buildApp(),
buildStubBackend(),
"1.0.0",
buildStubDescriptor()
);
await backend.start();
const client = getVaultClient(backend);

backend.registerSessionHandler("sess-E", jest.fn());
await client.sessionUpdate({
sessionId: "sess-E",
update: {
sessionUpdate: "tool_call",
toolCallId: "todo-e",
title: "todowrite",
rawInput: { todos: [{ content: "a", status: "pending", priority: "high" }] },
},
} as unknown as Parameters<typeof client.sessionUpdate>[0]);

// Subprocess exits, then the process is restarted and the same sessionId +
// todo id reappear. With the onExit cleanup the tracker is gone, so a
// titleless update is NOT mistaken for the old todowrite call.
for (const fn of exitListeners) fn();
await backend.start();
const client2 = getVaultClient(backend);
const handler = jest.fn();
backend.registerSessionHandler("sess-E", handler);
await client2.sessionUpdate({
sessionId: "sess-E",
update: {
sessionUpdate: "tool_call_update",
toolCallId: "todo-e",
rawInput: { todos: [{ content: "stale", status: "pending", priority: "low" }] },
},
} as unknown as Parameters<typeof client2.sessionUpdate>[0]);
expect(handler.mock.calls.some(([e]) => e.update.sessionUpdate === "plan")).toBe(false);
});

it("returns cancelled outcome when permission is requested but no prompter is registered", async () => {
const backend = new AcpBackendProcess(
buildApp(),
Expand Down Expand Up @@ -179,4 +339,74 @@ describe("AcpBackendProcess", () => {
);
await expect(backend.prompt({ sessionId: "s1", prompt: [] })).rejects.toThrow(/start\(\)/);
});

describe("additionalDirectories (capability-gated)", () => {
async function startBackend(): Promise<AcpBackendProcess> {
const backend = new AcpBackendProcess(
buildApp(),
buildStubBackend(),
"1.0.0",
buildStubDescriptor()
);
await backend.start();
return backend;
}

it("reflects the probed capability — false when the agent does not advertise it", async () => {
// codex 0.135 / opencode 1.2.27 shape: no additionalDirectories advertised.
mockInitializeResult = {
protocolVersion: 1,
agentCapabilities: { sessionCapabilities: { list: {}, close: {} } },
};
const backend = await startBackend();
expect(backend.supportsAdditionalDirectories()).toBe(false);
});

it("reflects the probed capability — true when the agent advertises it", async () => {
mockInitializeResult = {
protocolVersion: 1,
agentCapabilities: { sessionCapabilities: { additionalDirectories: {} } },
};
const backend = await startBackend();
expect(backend.supportsAdditionalDirectories()).toBe(true);
});

it("does NOT forward additionalDirectories at session/new when uncapable", async () => {
mockInitializeResult = { protocolVersion: 1 };
const backend = await startBackend();
await backend.newSession({
cwd: "/vault",
mcpServers: [],
additionalDirectories: ["/abs/context"],
});
const req = mockNewSession.mock.calls[0][0] as { additionalDirectories?: string[] };
expect(req.additionalDirectories).toBeUndefined();
});

it("forwards additionalDirectories at session/new only when capable", async () => {
mockInitializeResult = {
protocolVersion: 1,
agentCapabilities: { sessionCapabilities: { additionalDirectories: {} } },
};
const backend = await startBackend();
await backend.newSession({
cwd: "/vault",
mcpServers: [],
additionalDirectories: ["/abs/context-a", "/abs/context-b"],
});
const req = mockNewSession.mock.calls[0][0] as { additionalDirectories?: string[] };
expect(req.additionalDirectories).toEqual(["/abs/context-a", "/abs/context-b"]);
});

it("omits the field for a capable agent when no extra roots are supplied", async () => {
mockInitializeResult = {
protocolVersion: 1,
agentCapabilities: { sessionCapabilities: { additionalDirectories: {} } },
};
const backend = await startBackend();
await backend.newSession({ cwd: "/vault", mcpServers: [] });
const req = mockNewSession.mock.calls[0][0] as { additionalDirectories?: string[] };
expect(req.additionalDirectories).toBeUndefined();
});
});
});
Loading
Loading