import { describe, it, expect } from "vitest"; import { randomUUID } from "crypto"; import { AgentSideConnection, RequestError, SessionNotification } from "@agentclientprotocol/sdk"; import { query, getSessionMessages } from "@anthropic-ai/claude-agent-sdk"; import { ClaudeAcpAgent } from "../acp-agent.js"; import { Pushable } from "../utils.js"; import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; function createMockClient(): AgentSideConnection { return { sessionUpdate: async (_notification: SessionNotification) => {}, requestPermission: async () => ({ outcome: { outcome: "cancelled" } }), readTextFile: async () => ({ content: "" }), writeTextFile: async () => ({}), } as unknown as AgentSideConnection; } describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("session load/resume lifecycle", () => { it("SDK: session created but never prompted has no messages and is not resumable", async () => { // Create a session via the SDK, initialize it, but never send a prompt const sessionId = randomUUID(); const input = new Pushable(); const q = query({ prompt: input, options: { systemPrompt: { type: "preset", preset: "claude_code" }, sessionId, settingSources: ["user", "project", "local"], includePartialMessages: true, }, }); // initializationResult() works without needing a prompt pushed const initResult = await q.initializationResult(); expect(initResult).toBeDefined(); // Close without ever prompting input.end(); q.return(undefined); // Verify no messages were stored const messages = await getSessionMessages(sessionId); expect(messages).toEqual([]); // Verify the session is not resumable const input2 = new Pushable(); const q2 = query({ prompt: input2, options: { systemPrompt: { type: "preset", preset: "claude_code" }, resume: sessionId, settingSources: ["user", "project", "local"], includePartialMessages: true, }, }); await expect(q2.initializationResult()).rejects.toThrow( /No conversation found with session ID/, ); input2.end(); q2.return(undefined); }, 30000); it("ACP: loadSession throws resourceNotFound for a non-existent session", async () => { const agent = new ClaudeAcpAgent(createMockClient()); const bogusSessionId = randomUUID(); try { await expect( agent.loadSession({ sessionId: bogusSessionId, cwd: process.cwd(), mcpServers: [], }), ).rejects.toThrow(RequestError); } finally { await agent.dispose(); } }, 30000); it("ACP: resumeSession throws resourceNotFound for a non-existent session", async () => { const agent = new ClaudeAcpAgent(createMockClient()); const bogusSessionId = randomUUID(); try { await expect( agent.resumeSession({ sessionId: bogusSessionId, cwd: process.cwd(), mcpServers: [], }), ).rejects.toThrow(RequestError); } finally { await agent.dispose(); } }, 30000); it("ACP: newSession without prompt, then loadSession on fresh agent throws resourceNotFound", async () => { // Step 1: Create a real session via ACP, never prompt, dispose const agentA = new ClaudeAcpAgent(createMockClient()); const { sessionId } = await agentA.newSession({ cwd: process.cwd(), mcpServers: [], }); expect(sessionId).toBeDefined(); await agentA.dispose(); // Step 2: Fresh agent tries to load that session const agentB = new ClaudeAcpAgent(createMockClient()); try { await expect( agentB.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [], }), ).rejects.toThrow(RequestError); } finally { await agentB.dispose(); } }, 30000); it("ACP: newSession without prompt, then resumeSession on fresh agent throws resourceNotFound", async () => { const agentA = new ClaudeAcpAgent(createMockClient()); const { sessionId } = await agentA.newSession({ cwd: process.cwd(), mcpServers: [], }); await agentA.dispose(); const agentB = new ClaudeAcpAgent(createMockClient()); try { await expect( agentB.resumeSession({ sessionId, cwd: process.cwd(), mcpServers: [], }), ).rejects.toThrow(RequestError); } finally { await agentB.dispose(); } }, 30000); // Regression test for https://github.com/zed-industries/claude-code-acp/issues/579 // The client (Zed) renders its own local slash commands — e.g. `/model` — // by injecting user-message prompts whose text is wrapped in // `` / `` markers. The Claude SDK // persists those messages verbatim in the session transcript. Without // filtering, `loadSession` replays the markers as user_message_chunks // ahead of the real prompt, leaking CLI internals into the transcript. it("ACP: loadSession does not replay local-command metadata user messages", async () => { const recordedUserChunks: string[] = []; const client = { sessionUpdate: async (notification: SessionNotification) => { if (notification.update.sessionUpdate === "user_message_chunk") { const content = notification.update.content; if (content.type === "text") recordedUserChunks.push(content.text); } }, requestPermission: async () => ({ outcome: { outcome: "cancelled" } }), readTextFile: async () => ({ content: "" }), writeTextFile: async () => ({}), } as unknown as AgentSideConnection; const commandName = "/model\n model\n opus"; const commandStdout = "Set model to opus (claude-opus-4-7)"; // Step 1: create a session via the SDK, push the marker user messages // followed by a real "hi" prompt. We bypass the ACP agent's prompt loop // so we're only testing what the loadSession replay path does, not how // prompts get routed. const sessionId = randomUUID(); const input = new Pushable(); const q = query({ prompt: input, options: { systemPrompt: { type: "preset", preset: "claude_code" }, sessionId, settingSources: ["user", "project", "local"], includePartialMessages: true, }, }); await q.initializationResult(); // Zed renders its local slash commands by mixing the marker blocks into // the user's prompt content array alongside the real text. The Claude // SDK persists the message verbatim — including the marker blocks — // which is what loadSession must filter when it replays. input.push({ type: "user", message: { role: "user", content: [ { type: "text", text: commandName }, { type: "text", text: commandStdout }, { type: "text", text: "hi" }, ], }, session_id: sessionId, parent_tool_use_id: null, }); for await (const msg of q) { if (msg.type === "result") break; } input.end(); q.return(undefined); // Sanity check: the SDK transcript contains the metadata we're filtering. const sdkMessages = await getSessionMessages(sessionId); const sdkTexts = sdkMessages .map((m) => { const c = (m as { message?: { content?: unknown } }).message?.content; if (typeof c === "string") return c; if (Array.isArray(c)) return c .map((b) => b && typeof b === "object" && "text" in b ? String((b as { text: unknown }).text) : "", ) .join(""); return ""; }) .join("\n"); expect(sdkTexts).toContain(""); expect(sdkTexts).toContain(""); expect(sdkTexts).toContain("hi"); // Step 2: load the session through the ACP agent and confirm the markers // never reach the client as user_message_chunks, while the real "hi" // prompt does. const agent = new ClaudeAcpAgent(client); try { await agent.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [], }); } finally { await agent.dispose(); } for (const chunk of recordedUserChunks) { expect(chunk).not.toContain(""); expect(chunk).not.toContain(""); expect(chunk).not.toContain(""); expect(chunk).not.toContain(""); expect(chunk).not.toContain(""); } expect(recordedUserChunks.some((c) => c.includes("hi"))).toBe(true); }, 60000); // Second regression: the original issue showed the markers and the real // "hi" prompt all concatenated into a single string ending with "hi". // loadSession must strip the marker tags in-place rather than dropping the // whole message. it("ACP: loadSession strips marker tags from a concatenated single-string prompt", async () => { const recordedUserChunks: string[] = []; const client = { sessionUpdate: async (notification: SessionNotification) => { if (notification.update.sessionUpdate === "user_message_chunk") { const content = notification.update.content; if (content.type === "text") recordedUserChunks.push(content.text); } }, requestPermission: async () => ({ outcome: { outcome: "cancelled" } }), readTextFile: async () => ({ content: "" }), writeTextFile: async () => ({}), } as unknown as AgentSideConnection; // Exact shape from https://github.com/zed-industries/claude-code-acp/issues/579. const concatenated = "/model\n model\n opus" + "Set model to opus (claude-opus-4-7)\n" + "/model\n model\n opus[1m]" + "Set model to opus[1m] (claude-opus-4-7[1m])" + "hi"; const sessionId = randomUUID(); const input = new Pushable(); const q = query({ prompt: input, options: { systemPrompt: { type: "preset", preset: "claude_code" }, sessionId, settingSources: ["user", "project", "local"], includePartialMessages: true, }, }); await q.initializationResult(); input.push({ type: "user", message: { role: "user", content: concatenated }, session_id: sessionId, parent_tool_use_id: null, }); for await (const msg of q) { if (msg.type === "result") break; } input.end(); q.return(undefined); const agent = new ClaudeAcpAgent(client); try { await agent.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [], }); } finally { await agent.dispose(); } expect(recordedUserChunks.length).toBeGreaterThan(0); for (const chunk of recordedUserChunks) { expect(chunk).not.toContain(""); expect(chunk).not.toContain(""); expect(chunk).not.toContain(""); expect(chunk).not.toContain(""); expect(chunk).not.toContain(""); } expect(recordedUserChunks.some((c) => c.includes("hi"))).toBe(true); }, 60000); });