From 6fbd13bd8acb4e5d3325ffd5a05ab1224256ef51 Mon Sep 17 00:00:00 2001 From: Rohan Patra Date: Sun, 19 Apr 2026 16:15:17 -0700 Subject: [PATCH] feat: add /btw ephemeral side-question command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `/btw ` slash command that answers a tangential question with full access to the current conversation's context, but does NOT persist either the question or answer to the session transcript. Subsequent turns see a pristine context. This mirrors the Claude Code CLI's `/btw` semantics using only public SDK primitives: - persistSession: false — nothing written to ~/.claude/projects/ - resume: — side query has access to main context - maxTurns: 1 + tools: [] + disallowedTools: ["*"] — single turn, tools disabled at model-context level (not just execution) The side query's assistant text is forwarded to the ACP client on the main sessionId via toAcpNotifications so the answer appears inline. Serialized behind any in-flight main turn via a new `idleResolvers` list so the two queries don't interleave output. Interruptible via ACP cancel(), which interrupts btwQuery alongside the main query. Session type additions: - btwQuery?: Query — cancel handle for in-flight side question - idleResolvers: Array<() => void> — wait-for-idle barrier - hasRunMainPrompt: boolean — gates whether resume is safe to use; flipped inside the main loop after the first successful query.next() so a failed subprocess-start doesn't leave /btw trying to resume a nonexistent JSONL Guards: - Empty argument shows a usage notice without spawning a subprocess - Concurrent /btw returns an in-progress notice (ACP clients serialize prompt() per session, but the invariant is enforced) Tests cover: empty-argument (with and without trailing space), concurrent-guard, cancel-while-waiting-for-idle, and the available-commands append path. --- src/acp-agent.ts | 216 +++++++++++++++++++++++++++++++++++- src/tests/acp-agent.test.ts | 183 +++++++++++++++++++++++++++++- 2 files changed, 396 insertions(+), 3 deletions(-) diff --git a/src/acp-agent.ts b/src/acp-agent.ts index c37315ca..4d4e26a7 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -147,6 +147,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 @@ -581,6 +591,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++; @@ -602,6 +620,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" }; @@ -1008,6 +1033,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. @@ -1034,9 +1062,180 @@ 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"], + executable: isStaticBinary() ? undefined : (process.execPath as any), + ...(process.env.CLAUDE_CODE_EXECUTABLE + ? { pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE } + : isStaticBinary() + ? { pathToClaudeCodeExecutable: 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 { @@ -1716,6 +1915,8 @@ export class ClaudeAcpAgent implements Agent { emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false, contextWindowSize: inferContextWindowFromModel(models.currentModelId) ?? DEFAULT_CONTEXT_WINDOW, + idleResolvers: [], + hasRunMainPrompt: false, }; return { @@ -1944,7 +2145,7 @@ async function getAvailableModels( }; } -function getAvailableSlashCommands(commands: SlashCommand[]): AvailableCommand[] { +export function getAvailableSlashCommands(commands: SlashCommand[]): AvailableCommand[] { const UNSUPPORTED_COMMANDS = [ "cost", "keybindings-help", @@ -1955,7 +2156,7 @@ function getAvailableSlashCommands(commands: SlashCommand[]): AvailableCommand[] "todos", ]; - return commands + const result = commands .map((command) => { const input = command.argumentHint ? { @@ -1975,6 +2176,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 6d9675c0..66187977 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -24,7 +24,13 @@ import { toolUpdateFromToolResult, toolUpdateFromEditToolResponse, } from "../tools.js"; -import { toAcpNotifications, promptToClaude, ClaudeAcpAgent, claudeCliPath } from "../acp-agent.js"; +import { + toAcpNotifications, + promptToClaude, + ClaudeAcpAgent, + claudeCliPath, + getAvailableSlashCommands, +} from "../acp-agent.js"; import { Pushable } from "../utils.js"; import { query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk"; import { randomUUID } from "crypto"; @@ -1351,6 +1357,8 @@ describe("stop reason propagation", () => { abortController: new AbortController(), emitRawSDKMessages: false, contextWindowSize: 200000, + idleResolvers: [], + hasRunMainPrompt: false, }; } @@ -1493,6 +1501,8 @@ describe("stop reason propagation", () => { nextPendingOrder: 0, emitRawSDKMessages: false, contextWindowSize: 200000, + idleResolvers: [], + hasRunMainPrompt: false, }; const response = await agent.prompt({ @@ -1569,6 +1579,8 @@ describe("session/close", () => { abortController: new AbortController(), emitRawSDKMessages: false, contextWindowSize: 200000, + idleResolvers: [], + hasRunMainPrompt: false, }; return agent.sessions[sessionId]!; } @@ -1664,6 +1676,8 @@ describe("getOrCreateSession param change detection", () => { abortController: new AbortController(), emitRawSDKMessages: false, contextWindowSize: 200000, + idleResolvers: [], + hasRunMainPrompt: false, }; return agent.sessions[sessionId]!; } @@ -1897,6 +1911,8 @@ describe("usage_update computation", () => { abortController: new AbortController(), emitRawSDKMessages: false, contextWindowSize: 200000, + idleResolvers: [], + hasRunMainPrompt: false, }; } @@ -2785,6 +2801,8 @@ describe("emitRawSDKMessages", () => { abortController: new AbortController(), emitRawSDKMessages, contextWindowSize: 200000, + idleResolvers: [], + hasRunMainPrompt: false, }; } @@ -2922,3 +2940,166 @@ 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: [] }, + 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); + }); +});