diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 78038ac4..eb1b7b79 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -151,6 +151,16 @@ type Session = { * DEFAULT_CONTEXT_WINDOW, refreshed from each result's modelUsage, and * invalidated when the user switches the session's model. */ contextWindowSize: number; + /** Ephemeral side-question Query spawned by /btw. Tracked so cancel() can + * interrupt it alongside the main query. Undefined when no /btw is in flight. */ + btwQuery?: Query; + /** Resolvers for /btw handlers waiting for the main query to become idle. + * Drained by prompt()'s finally block when promptRunning flips to false, + * and by cancel() so queued /btw calls bail out. */ + idleResolvers: Array<() => void>; + /** True once prompt() has started a main turn at least once, meaning the + * session JSONL exists on disk and is safe for /btw to resume from. */ + hasRunMainPrompt: boolean; }; /** Compute a stable fingerprint of the session-defining params so we can @@ -732,6 +742,14 @@ export class ClaudeAcpAgent implements Agent { const isLocalOnlyCommand = firstText.startsWith("/") && LOCAL_ONLY_COMMANDS.has(firstText.split(" ", 1)[0]); + // /btw : ephemeral side-question. Routes to a separate query() + // that resumes the main session's context but does not persist, so the + // main session's transcript stays clean for subsequent turns. + if (firstText === "/btw" || firstText.startsWith("/btw ")) { + const question = firstText.slice(4).trim(); + return await this.handleBtwQuestion(session, params, question); + } + if (session.promptRunning) { session.input.push(userMessage); const order = session.nextPendingOrder++; @@ -753,6 +771,13 @@ export class ClaudeAcpAgent implements Agent { while (true) { const { value: message, done } = await session.query.next(); + // The SDK subprocess has emitted a message — the session JSONL exists + // on disk by now, so subsequent /btw calls can safely `resume` from it. + // Flipping here rather than before the loop ensures we don't mark the + // session as run if the subprocess fails to start (errors are caught + // below and /btw resume would have failed against a nonexistent file). + session.hasRunMainPrompt = true; + if (done || !message) { if (session.cancelled) { return { stopReason: "cancelled" }; @@ -1166,6 +1191,9 @@ export class ClaudeAcpAgent implements Agent { } finally { if (!handedOff) { session.promptRunning = false; + // Release any /btw handlers waiting on main-session idle. + const idleWaiters = session.idleResolvers.splice(0); + for (const resolve of idleWaiters) resolve(); // This usually should not happen, but in case the loop finishes // without claude sending all message replays, we resolve the // next pending prompt call to ensure no prompts get stuck. @@ -1192,9 +1220,175 @@ export class ClaudeAcpAgent implements Agent { pending.resolve(true); } session.pendingMessages.clear(); + // Release any /btw handlers waiting on main-session idle; they observe + // session.cancelled and return stopReason: "cancelled". + const idleWaiters = session.idleResolvers.splice(0); + for (const resolve of idleWaiters) resolve(); + // Interrupt an in-flight /btw side-question Query, if any. + if (session.btwQuery) { + try { + await session.btwQuery.interrupt(); + } catch (error) { + this.logger.error( + `[claude-agent-acp] Error interrupting /btw query: ${(error as Error).message}`, + ); + } + } await session.query.interrupt(); } + /** + * Handle a `/btw ` side question. Spawns an ephemeral Query that + * resumes the main session's transcript for context but uses + * `persistSession: false` so nothing is written to disk. The assistant's + * response is forwarded to Zed on the main ACP sessionId. Tools are + * disabled via `tools: []` + `disallowedTools: ["*"]` and turns are capped + * at 1, so the side query can only produce a single text answer. + * + * Serialized behind any in-flight main turn via session.idleResolvers so + * the two queries don't interleave output on the same ACP sessionId. + */ + private async handleBtwQuestion( + session: Session, + params: PromptRequest, + question: string, + ): Promise { + if (question === "") { + await this.client.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Usage: `/btw `" }, + }, + }); + return { stopReason: "end_turn" }; + } + + // Only one /btw in flight per session. ACP clients serialize prompt() + // per session so this shouldn't happen from Zed, but guard the invariant + // so a stray concurrent call doesn't orphan an in-flight Query. + if (session.btwQuery) { + await this.client.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: "A `/btw` question is already in progress — wait for it to finish.", + }, + }, + }); + return { stopReason: "end_turn" }; + } + + // Wait for main query to idle so output streams don't interleave and + // so the side query's `resume` reads a stable on-disk transcript. + if (session.promptRunning) { + await new Promise((resolve) => { + session.idleResolvers.push(resolve); + }); + if (session.cancelled) { + return { stopReason: "cancelled" }; + } + } + + const input = new Pushable(); + input.push({ + type: "user", + message: { role: "user", content: [{ type: "text", text: question }] }, + session_id: params.sessionId, + parent_tool_use_id: null, + }); + // maxTurns: 1 means the SDK won't need more input; close the stream so + // the subprocess sees EOF cleanly rather than relying solely on close(). + input.end(); + + const canResume = session.hasRunMainPrompt; + const sideOptions: Options = { + cwd: session.cwd, + persistSession: false, + maxTurns: 1, + tools: [], + disallowedTools: ["*"], + systemPrompt: { type: "preset", preset: "claude_code" }, + settingSources: ["user", "project", "local"], + pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE ?? (await claudeCliPath()), + ...(canResume ? { resume: params.sessionId } : {}), + }; + + let sideQuery: Query; + try { + sideQuery = query({ prompt: input, options: sideOptions }); + session.btwQuery = sideQuery; + await sideQuery.initializationResult(); + if (session.models.currentModelId) { + try { + await sideQuery.setModel(session.models.currentModelId); + } catch (error) { + this.logger.error(`[claude-agent-acp] /btw setModel failed: ${(error as Error).message}`); + } + } + } catch (error) { + session.btwQuery = undefined; + await this.client.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: `\`/btw\` failed: ${(error as Error).message}`, + }, + }, + }); + return { stopReason: "end_turn" }; + } + + const ephemeralCache: ToolUseCache = {}; + try { + for await (const message of sideQuery) { + if (session.cancelled) { + return { stopReason: "cancelled" }; + } + if (message.type === "assistant" && message.parent_tool_use_id === null) { + const notifications = toAcpNotifications( + message.message.content, + "assistant", + params.sessionId, + ephemeralCache, + this.client, + this.logger, + { + clientCapabilities: this.clientCapabilities, + cwd: session.cwd, + registerHooks: false, + }, + ); + for (const notification of notifications) { + notification.update._meta = { + ...notification.update._meta, + claudeCode: { + ...(notification.update._meta?.claudeCode || {}), + btw: true, + }, + }; + await this.client.sessionUpdate(notification); + } + } else if (message.type === "result") { + break; + } + } + } finally { + session.btwQuery = undefined; + try { + sideQuery.close(); + } catch { + // ignore close errors; the subprocess may already be gone + } + } + + return { stopReason: session.cancelled ? "cancelled" : "end_turn" }; + } + /** Cleanly tear down a session: cancel in-flight work, dispose resources, * and remove it from the session map. */ private async teardownSession(sessionId: string): Promise { @@ -2021,6 +2215,8 @@ export class ClaudeAcpAgent implements Agent { emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false, contextWindowSize: inferContextWindowFromModel(models.currentModelId) ?? DEFAULT_CONTEXT_WINDOW, + idleResolvers: [], + hasRunMainPrompt: false, }; return { @@ -2376,7 +2572,7 @@ async function getAvailableModels( }; } -function getAvailableSlashCommands(commands: SlashCommand[]): AvailableCommand[] { +export function getAvailableSlashCommands(commands: SlashCommand[]): AvailableCommand[] { const UNSUPPORTED_COMMANDS = [ "cost", "keybindings-help", @@ -2387,7 +2583,7 @@ function getAvailableSlashCommands(commands: SlashCommand[]): AvailableCommand[] "todos", ]; - return commands + const result = commands .map((command) => { const input = command.argumentHint ? { @@ -2407,6 +2603,17 @@ function getAvailableSlashCommands(commands: SlashCommand[]): AvailableCommand[] }; }) .filter((command: AvailableCommand) => !UNSUPPORTED_COMMANDS.includes(command.name)); + + // Append /btw — implemented in this agent layer (not backed by the SDK's + // supportedCommands list), for ephemeral side questions that don't persist + // to the session transcript. See handleBtwQuestion. + result.push({ + name: "btw", + description: "Ask a side question — not saved to this thread's context", + input: { hint: "question" }, + }); + + return result; } function formatUriAsLink(uri: string): string { diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index 13dc808a..f778f0e6 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -32,6 +32,7 @@ import { ClaudeAcpAgent, claudeCliPath, describeAlwaysAllow, + getAvailableSlashCommands, } from "../acp-agent.js"; import { Pushable } from "../utils.js"; import { query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk"; @@ -1532,6 +1533,8 @@ describe("stop reason propagation", () => { abortController: new AbortController(), emitRawSDKMessages: false, contextWindowSize: 200000, + idleResolvers: [], + hasRunMainPrompt: false, }; } @@ -1675,6 +1678,8 @@ describe("stop reason propagation", () => { nextPendingOrder: 0, emitRawSDKMessages: false, contextWindowSize: 200000, + idleResolvers: [], + hasRunMainPrompt: false, }; const response = await agent.prompt({ @@ -1833,6 +1838,8 @@ describe("session/close", () => { abortController: new AbortController(), emitRawSDKMessages: false, contextWindowSize: 200000, + idleResolvers: [], + hasRunMainPrompt: false, }; return agent.sessions[sessionId]!; } @@ -1929,6 +1936,8 @@ describe("getOrCreateSession param change detection", () => { abortController: new AbortController(), emitRawSDKMessages: false, contextWindowSize: 200000, + idleResolvers: [], + hasRunMainPrompt: false, }; return agent.sessions[sessionId]!; } @@ -2163,6 +2172,8 @@ describe("usage_update computation", () => { abortController: new AbortController(), emitRawSDKMessages: false, contextWindowSize: 200000, + idleResolvers: [], + hasRunMainPrompt: false, }; } @@ -3062,6 +3073,8 @@ describe("emitRawSDKMessages", () => { abortController: new AbortController(), emitRawSDKMessages, contextWindowSize: 200000, + idleResolvers: [], + hasRunMainPrompt: false, }; } @@ -3199,3 +3212,167 @@ describe("emitRawSDKMessages", () => { expect(sdkMessages[1].params.message.type).toBe("result"); }); }); + +describe("/btw side-question", () => { + function createMockAgentWithCapture() { + const updates: SessionNotification[] = []; + const mockClient = { + sessionUpdate: async (notification: SessionNotification) => { + updates.push(notification); + }, + } as unknown as AgentSideConnection; + const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); + return { agent, updates }; + } + + function injectBareSession( + agent: ClaudeAcpAgent, + overrides: { + promptRunning?: boolean; + btwQuery?: any; + hasRunMainPrompt?: boolean; + } = {}, + ) { + agent.sessions["test-session"] = { + query: (async function* () {})() as any, + input: new Pushable(), + cancelled: false, + cwd: "/test", + sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }), + modes: { currentModeId: "default", availableModes: [] }, + models: { currentModelId: "claude-sonnet-4-5", availableModels: [] }, + modelInfos: [], + settingsManager: { dispose: vi.fn() } as any, + accumulatedUsage: { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, + }, + configOptions: [], + promptRunning: overrides.promptRunning ?? false, + pendingMessages: new Map(), + nextPendingOrder: 0, + abortController: new AbortController(), + emitRawSDKMessages: false, + contextWindowSize: 200000, + idleResolvers: [], + hasRunMainPrompt: overrides.hasRunMainPrompt ?? true, + btwQuery: overrides.btwQuery, + }; + return agent.sessions["test-session"]; + } + + it("empty argument shows usage notice and returns end_turn without spawning a subprocess", async () => { + const { agent, updates } = createMockAgentWithCapture(); + injectBareSession(agent); + + const response = await agent.prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "/btw" }], + }); + + expect(response.stopReason).toBe("end_turn"); + expect(updates).toHaveLength(1); + const update = updates[0].update; + expect(update.sessionUpdate).toBe("agent_message_chunk"); + if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text") { + expect(update.content.text).toContain("Usage:"); + expect(update.content.text).toContain("/btw"); + } + // Subprocess should not have been spawned — btwQuery stays undefined + expect(agent.sessions["test-session"].btwQuery).toBeUndefined(); + }); + + it("empty argument with trailing space also shows usage notice", async () => { + const { agent, updates } = createMockAgentWithCapture(); + injectBareSession(agent); + + const response = await agent.prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "/btw " }], + }); + + expect(response.stopReason).toBe("end_turn"); + const update = updates[0].update; + if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text") { + expect(update.content.text).toContain("Usage:"); + } + }); + + it("concurrent /btw returns an in-progress notice without spawning a second subprocess", async () => { + const { agent, updates } = createMockAgentWithCapture(); + const fakeExistingQuery = { interrupt: vi.fn() }; + injectBareSession(agent, { btwQuery: fakeExistingQuery }); + + const response = await agent.prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "/btw another question" }], + }); + + expect(response.stopReason).toBe("end_turn"); + expect(updates).toHaveLength(1); + const update = updates[0].update; + if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text") { + expect(update.content.text.toLowerCase()).toContain("already in progress"); + } + // The original in-flight query should not have been overwritten + expect(agent.sessions["test-session"].btwQuery).toBe(fakeExistingQuery); + expect(fakeExistingQuery.interrupt).not.toHaveBeenCalled(); + }); + + it("cancel while /btw is waiting for main idle returns stopReason cancelled", async () => { + const { agent, updates } = createMockAgentWithCapture(); + const session = injectBareSession(agent, { promptRunning: true }); + + // Kick off /btw — it should enter the idle-wait branch. + const btwPromise = agent.prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "/btw what color is the sky" }], + }); + + // Give /btw a microtask tick to register its resolver. + await new Promise((r) => setImmediate(r)); + expect(session.idleResolvers).toHaveLength(1); + + // Simulate main-session cancel — should drain the waiter and flip cancelled. + session.cancelled = true; + const waiters = session.idleResolvers.splice(0); + for (const resolve of waiters) resolve(); + + const response = await btwPromise; + expect(response.stopReason).toBe("cancelled"); + // No agent_message_chunk should have been sent for a cancelled-before-start /btw + expect(updates).toHaveLength(0); + }); + + it("getAvailableSlashCommands appends /btw to the SDK-provided list", () => { + const result = getAvailableSlashCommands([]); + const btw = result.find((c) => c.name === "btw"); + expect(btw).toBeDefined(); + expect(btw?.description.toLowerCase()).toContain("side question"); + expect(btw?.input).toEqual({ hint: "question" }); + }); + + it("getAvailableSlashCommands preserves SDK commands alongside /btw", () => { + const result = getAvailableSlashCommands([ + { + name: "compact", + description: "Compact the conversation", + argumentHint: null, + } as any, + ]); + const names = result.map((c) => c.name); + expect(names).toContain("compact"); + expect(names).toContain("btw"); + }); + + it("/btw is not listed among unsupported commands", () => { + // Regression guard: if someone adds "btw" to UNSUPPORTED_COMMANDS by accident, + // the filter would strip it before we append — verify it survives when SDK + // happens to report it too. + const result = getAvailableSlashCommands([]); + const btwEntries = result.filter((c) => c.name === "btw"); + expect(btwEntries).toHaveLength(1); + }); +});