diff --git a/products/desktop/apps/web/src/web-host-router.ts b/products/desktop/apps/web/src/web-host-router.ts index 5cb4f23021e2..8c4702c29bd9 100644 --- a/products/desktop/apps/web/src/web-host-router.ts +++ b/products/desktop/apps/web/src/web-host-router.ts @@ -152,6 +152,13 @@ const agentStubRouter = router({ auth.getState().currentProjectId ?? undefined, ); }), + // Unreachable in practice: the UI gates "/btw" on sessionSupportsSideQuestion, + // which is false for cloud sessions. Present so SessionTrpc stays satisfied. + sideQuestion: publicProcedure + .input(z.object({ sessionId: z.string(), question: z.string() })) + .mutation(() => { + throw new Error("Side questions require a local session"); + }), // Model/mode/effort options for the task-input preview + cloud run creation // (a cloud run requires a model). Real: fetched from the CORS-open PostHog LLM // gateway, same logic the desktop main process runs (see web-agent-config.ts). diff --git a/products/desktop/packages/agent/src/acp-extensions.ts b/products/desktop/packages/agent/src/acp-extensions.ts index 0b4c7c40807f..224a455294c4 100644 --- a/products/desktop/packages/agent/src/acp-extensions.ts +++ b/products/desktop/packages/agent/src/acp-extensions.ts @@ -128,6 +128,13 @@ export const POSTHOG_METHODS = { * completed so the caller can safely send the next prompt. */ REFRESH_SESSION: "_posthog/refresh_session", + + /** + * One-shot side question ("/btw"): forks the live session's transcript into + * a single-turn, tool-less query and returns `{ answer }` without touching + * the main conversation. Payload: `{ question: string }`. + */ + SIDE_QUESTION: "_posthog/side_question", } as const; type PosthogNotification = diff --git a/products/desktop/packages/agent/src/adapters/claude/claude-agent.side-question.test.ts b/products/desktop/packages/agent/src/adapters/claude/claude-agent.side-question.test.ts new file mode 100644 index 000000000000..3ac77abbf7ef --- /dev/null +++ b/products/desktop/packages/agent/src/adapters/claude/claude-agent.side-question.test.ts @@ -0,0 +1,331 @@ +import { + type AgentSideConnection, + RequestError, +} from "@agentclientprotocol/sdk"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { POSTHOG_METHODS } from "../../acp-extensions"; +import { Pushable } from "../../utils/streams"; + +type SdkMessage = + | { type: "assistant"; message: { content: unknown[] } } + | { type: "result"; subtype: string }; + +/** Messages the next one-shot query() will yield when iterated. */ +let nextQueryMessages: SdkMessage[] = []; +/** When set, the next query's iterator blocks on this promise before yielding. */ +let nextQueryGate: Promise | null = null; + +const lastQueryCall: { + prompt?: unknown; + options?: Record; +} = {}; + +vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ + query: vi.fn( + (params: { prompt: unknown; options: Record }) => { + lastQueryCall.prompt = params.prompt; + lastQueryCall.options = params.options; + const messages = nextQueryMessages; + const gate = nextQueryGate; + return { + close: vi.fn(), + [Symbol.asyncIterator]: async function* () { + if (gate) await gate; + yield* messages; + }, + }; + }, + ), +})); + +// Import after the mocks so ClaudeAcpAgent resolves the mocked SDK +const { ClaudeAcpAgent } = await import("./claude-agent"); +type Agent = InstanceType; + +function makeAgent(): Agent { + const client = { + sessionUpdate: vi.fn().mockResolvedValue(undefined), + extNotification: vi.fn().mockResolvedValue(undefined), + } as unknown as AgentSideConnection; + return new ClaudeAcpAgent(client); +} + +function installFakeSession( + agent: Agent, + sessionId: string, + overrides: Partial<{ modelId: string; sdkSessionId: string }> = {}, +) { + const abortController = new AbortController(); + const input = new Pushable(); + const oldQuery = { close: vi.fn() }; + const hooks = { PreToolUse: [] }; + + const session = { + query: oldQuery, + // At creation the SDK id matches the ACP id; `/clear` later swaps only + // this one, leaving `agent.sessionId` stable. + sdkSessionId: overrides.sdkSessionId ?? sessionId, + queryOptions: { + sessionId, + cwd: "/tmp/repo", + model: "claude-sonnet-4-6", + mcpServers: { + posthog: { type: "http", url: "https://example" }, + }, + settingSources: ["user", "project", "local"], + plugins: [{ type: "local", path: "./repo-plugin" }], + agents: { reviewer: { description: "d", prompt: "p" } }, + abortController, + hooks, + }, + input, + modelId: overrides.modelId, + cwd: "/tmp/repo", + } as unknown as Parameters[0]; + + (agent as unknown as { session: unknown }).session = session; + (agent as unknown as { sessionId: string }).sessionId = sessionId; + + return { session, oldQuery, input, abortController }; +} + +function assistantText(text: string): SdkMessage { + return { type: "assistant", message: { content: [{ type: "text", text }] } }; +} + +describe("ClaudeAcpAgent.extMethod side_question", () => { + beforeEach(() => { + nextQueryMessages = []; + nextQueryGate = null; + lastQueryCall.prompt = undefined; + lastQueryCall.options = undefined; + }); + + it.each<[string, Record]>([ + ["missing question", {}], + ["non-string question", { question: 42 }], + ["blank question", { question: " " }], + ])("rejects %s with an invalid-params error", async (_label, params) => { + const agent = makeAgent(); + installFakeSession(agent, "s-1"); + + await expect( + agent.extMethod(POSTHOG_METHODS.SIDE_QUESTION, params), + ).rejects.toThrow(/non-empty question/); + }); + + it("forks a one-shot tool-less query and returns the answer", async () => { + const agent = makeAgent(); + const { session, oldQuery, input, abortController } = installFakeSession( + agent, + "s-2", + ); + nextQueryMessages = [ + assistantText("The function "), + assistantText("parses JSONL."), + { type: "result", subtype: "success" }, + ]; + + const result = await agent.extMethod(POSTHOG_METHODS.SIDE_QUESTION, { + question: "what does it do?", + }); + + expect(result).toEqual({ answer: "The function parses JSONL." }); + + // The prompt is the wrapped question, sent as a self-terminating string. + expect(lastQueryCall.prompt).toContain(""); + expect(lastQueryCall.prompt).toContain("what does it do?"); + + // One-shot fork off the live transcript, toolset removed. + const options = lastQueryCall.options ?? {}; + expect(options.resume).toBe("s-2"); + expect(options.forkSession).toBe(true); + expect(options.maxTurns).toBe(1); + expect(options.tools).toEqual([]); + expect(options.allowedTools).toEqual([]); + expect(options.mcpServers).toEqual({}); + expect(options.sessionId).toBeUndefined(); + expect(options.hooks).toBeUndefined(); + + // Nothing the repo controls runs for a side question: no filesystem + // settings (and so no hooks they declare), no plugins, no agents, and no + // MCP servers merged back in from `.mcp.json` or agent frontmatter. + expect(options.settingSources).toEqual([]); + expect(options.plugins).toEqual([]); + expect(options.agents).toEqual({}); + expect(options.strictMcpConfig).toBe(true); + // Fresh controller: aborting the one-shot must not poison the session. + expect(options.abortController).not.toBe(abortController); + + // canUseTool hard-denies anything that slips past the empty toolset. + const canUseTool = options.canUseTool as () => Promise<{ + behavior: string; + }>; + await expect(canUseTool()).resolves.toMatchObject({ behavior: "deny" }); + + // The live session is untouched. + const live = session as { + query: unknown; + input: unknown; + queryOptions: { sessionId: string }; + }; + expect(live.query).toBe(oldQuery); + expect(live.input).toBe(input); + expect(live.queryOptions.sessionId).toBe("s-2"); + expect((agent as unknown as { sessionId: string }).sessionId).toBe("s-2"); + }); + + it("forks the current SDK session id, not the stable ACP id, after /clear swaps it", async () => { + const agent = makeAgent(); + // /clear keeps the ACP id stable but points sdkSessionId at the fresh + // session and deletes the pre-clear transcript. The fork must resume the + // fresh id, or it resumes a retired/deleted transcript. + installFakeSession(agent, "acp-1", { sdkSessionId: "sdk-after-clear" }); + nextQueryMessages = [ + assistantText("ok"), + { type: "result", subtype: "success" }, + ]; + + await agent.extMethod(POSTHOG_METHODS.SIDE_QUESTION, { question: "hm?" }); + + expect(lastQueryCall.options?.resume).toBe("sdk-after-clear"); + }); + + it("answers on the live session model, not the creation-time option", async () => { + const agent = makeAgent(); + installFakeSession(agent, "s-3", { modelId: "claude-opus-4-8" }); + nextQueryMessages = [ + assistantText("yes"), + { type: "result", subtype: "success" }, + ]; + + await agent.extMethod(POSTHOG_METHODS.SIDE_QUESTION, { question: "hm?" }); + + expect(lastQueryCall.options?.model).toContain("opus"); + }); + + it("surfaces an error result subtype as a RequestError", async () => { + const agent = makeAgent(); + installFakeSession(agent, "s-4"); + nextQueryMessages = [{ type: "result", subtype: "error_max_turns" }]; + + const promise = agent.extMethod(POSTHOG_METHODS.SIDE_QUESTION, { + question: "hm?", + }); + await expect(promise).rejects.toBeInstanceOf(RequestError); + await expect(promise).rejects.toThrow(/error_max_turns/); + }); + + it("rejects when the fork produces no answer text", async () => { + const agent = makeAgent(); + installFakeSession(agent, "s-5"); + nextQueryMessages = [{ type: "result", subtype: "success" }]; + + await expect( + agent.extMethod(POSTHOG_METHODS.SIDE_QUESTION, { question: "hm?" }), + ).rejects.toThrow(/no answer/); + }); + + it("answers a newer side question and aborts the one it supersedes", async () => { + const agent = makeAgent(); + installFakeSession(agent, "s-6"); + + let release: () => void = () => {}; + nextQueryGate = new Promise((resolve) => { + release = resolve; + }); + nextQueryMessages = [ + assistantText("first"), + { type: "result", subtype: "success" }, + ]; + + const first = agent.extMethod(POSTHOG_METHODS.SIDE_QUESTION, { + question: "first?", + }); + await vi.waitFor(() => + expect(lastQueryCall.options?.abortController).toBeDefined(), + ); + const firstAbort = lastQueryCall.options + ?.abortController as AbortController; + + nextQueryGate = null; + nextQueryMessages = [ + assistantText("second"), + { type: "result", subtype: "success" }, + ]; + await expect( + agent.extMethod(POSTHOG_METHODS.SIDE_QUESTION, { question: "second?" }), + ).resolves.toEqual({ answer: "second" }); + + expect(firstAbort.signal.aborted).toBe(true); + + release(); + await first.catch(() => {}); + }); + + it("strips the live session's outputFormat so the answer stays plain text", async () => { + const agent = makeAgent(); + const { session } = installFakeSession(agent, "s-struct"); + // A structured task run stores a json_schema outputFormat on the live + // session; inheriting it forces the answer into that unrelated shape. + ( + session as unknown as { queryOptions: Record } + ).queryOptions.outputFormat = { + type: "json_schema", + schema: { type: "object" }, + }; + nextQueryMessages = [ + assistantText("plain answer"), + { type: "result", subtype: "success" }, + ]; + + const result = await agent.extMethod(POSTHOG_METHODS.SIDE_QUESTION, { + question: "what changed?", + }); + + expect(result).toEqual({ answer: "plain answer" }); + expect(lastQueryCall.options?.outputFormat).toBeUndefined(); + }); + + it("aborts an in-flight side question when the session closes", async () => { + const agent = makeAgent(); + installFakeSession(agent, "s-8"); + + let release: () => void = () => {}; + nextQueryGate = new Promise((resolve) => { + release = resolve; + }); + nextQueryMessages = [ + assistantText("answer"), + { type: "result", subtype: "success" }, + ]; + + const inflight = agent.extMethod(POSTHOG_METHODS.SIDE_QUESTION, { + question: "still going?", + }); + await vi.waitFor(() => + expect(lastQueryCall.options?.abortController).toBeDefined(), + ); + const forkAbort = lastQueryCall.options?.abortController as AbortController; + + await agent.closeSession(); + + expect(forkAbort.signal.aborted).toBe(true); + + release(); + await inflight.catch(() => {}); + }); + + it("wraps SDK failures (e.g. nothing to resume) in a clear RequestError", async () => { + const agent = makeAgent(); + installFakeSession(agent, "s-7"); + nextQueryGate = Promise.reject(new Error("No conversation found")); + nextQueryGate.catch(() => {}); + + const promise = agent.extMethod(POSTHOG_METHODS.SIDE_QUESTION, { + question: "too early?", + }); + await expect(promise).rejects.toBeInstanceOf(RequestError); + await expect(promise).rejects.toThrow(/No conversation found/); + }); +}); diff --git a/products/desktop/packages/agent/src/adapters/claude/claude-agent.ts b/products/desktop/packages/agent/src/adapters/claude/claude-agent.ts index bfad072938d0..80c5742ad052 100644 --- a/products/desktop/packages/agent/src/adapters/claude/claude-agent.ts +++ b/products/desktop/packages/agent/src/adapters/claude/claude-agent.ts @@ -146,6 +146,11 @@ import { toSdkEffort, } from "./session/options"; import { SettingsManager } from "./session/settings"; +import { + buildSideQuestionPrompt, + collectSideQuestionAnswer, + SIDE_QUESTION_TIMEOUT_MS, +} from "./side-question"; import { CODE_EXECUTION_MODES, type CodeExecutionMode, @@ -351,6 +356,11 @@ export class ClaudeAcpAgent extends BaseAcpAgent { private options?: ClaudeAcpAgentOptions; private enrichment?: Enrichment; private enrichedReadCache: EnrichedReadCache = new Map(); + /** + * The in-flight side question's controller, so a newer question can abort it. + * Bounds concurrent forks off the transcript to one. + */ + private sideQuestionAbort: AbortController | null = null; constructor(client: AgentSideConnection, options?: ClaudeAcpAgentOptions) { super(client); @@ -368,6 +378,11 @@ export class ClaudeAcpAgent extends BaseAcpAgent { override async closeSession(): Promise { try { + // A /btw fork runs on its own controller that the base close path never + // touches, so without this an in-flight side question keeps streaming + // (and burning tokens) until its own timeout fires after the session is + // gone. + this.sideQuestionAbort?.abort(); await super.closeSession(); } finally { this.enrichment?.dispose(); @@ -409,6 +424,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { // retire, so the clear has to look unavailable rather than silently // not take. conversationClear: true, + sideQuestion: true, }, claudeCode: { promptQueueing: true, @@ -1649,10 +1665,24 @@ export class ClaudeAcpAgent extends BaseAcpAgent { method: string, params: Record, ): Promise> { - if (!isMethod(method, POSTHOG_METHODS.REFRESH_SESSION)) { - throw RequestError.methodNotFound(method); + if (isMethod(method, POSTHOG_METHODS.SIDE_QUESTION)) { + if (typeof params.question !== "string" || !params.question.trim()) { + throw new RequestError( + -32602, + "side_question requires a non-empty question", + ); + } + return await this.answerSideQuestion(params.question); } + if (isMethod(method, POSTHOG_METHODS.REFRESH_SESSION)) { + return await this.handleRefreshSession(params); + } + throw RequestError.methodNotFound(method); + } + private async handleRefreshSession( + params: Record, + ): Promise> { // Trust boundary: refresh is only safe when the caller is trusted infra // (e.g. the sandbox agent-server). Do not route this method from // untrusted clients — parseMcpServers does no URL/command validation. @@ -1967,6 +1997,110 @@ export class ClaudeAcpAgent extends BaseAcpAgent { return { stopReason: "end_turn" }; } + /** + * Answers a "/btw" side question by forking the live session's transcript + * into a one-shot, tool-less, single-turn query. Strictly non-mutating: + * the fork gets its own SDK session id and AbortController, and nothing on + * `this.session` is touched, so the main conversation (including an + * in-flight turn) never sees the exchange. + * + * A newer question supersedes an in-flight one: the card shows only the + * latest, so answering a question it has already replaced burns tokens on + * a result the stale-answer guard would discard anyway. + */ + private async answerSideQuestion( + question: string, + ): Promise<{ answer: string }> { + this.sideQuestionAbort?.abort(); + + const abortController = new AbortController(); + this.sideQuestionAbort = abortController; + try { + // Drop `sessionId` (identity comes from `resume`), `hooks` (they close + // over live-session caches and task state), and `outputFormat` (a + // structured task run stores a json_schema here that would force the + // plain-text answer into the task's unrelated shape). + const { + sessionId: _sessionId, + hooks: _hooks, + outputFormat: _outputFormat, + ...rest + } = this.session.queryOptions; + const options: Options = { + ...rest, + // Fork the current SDK session, not the stable ACP id. `/clear` swaps + // `sdkSessionId` to a fresh session and deletes the pre-clear + // transcript, so `this.sessionId` would resume a retired one. + resume: this.session.sdkSessionId, + forkSession: true, + maxTurns: 1, + // Belt and braces: remove the toolset entirely and deny anything + // that slips through; the prompt wrapper also says "no tools". + tools: [], + allowedTools: [], + canUseTool: async () => ({ + behavior: "deny", + message: "Tools are unavailable while answering a side question.", + interrupt: false, + }), + // Never reuse in-process MCP instances ("Already connected to a + // transport"); the fork has no tools, so it needs no servers. + mcpServers: {}, + // `mcpServers: {}` alone only drops the servers passed in code — the + // CLI would still merge `.mcp.json`, user settings, and plugin/agent + // frontmatter servers back in. + strictMcpConfig: true, + // A side question is an isolated read of the transcript, so nothing + // the repo can write should get to run for it. `settingSources: []` + // keeps `.claude/settings*.json` (and the hooks they declare) off the + // fork, and plugins and agents ship their own hooks and skills. + settingSources: [], + plugins: [], + agents: {}, + abortController, + // `rest.model` is the creation-time value; the user may have + // switched models since, so answer on the live session model. + ...(this.session.modelId && { + model: toSdkModelId(this.session.modelId), + }), + }; + + const oneShot = query({ + prompt: buildSideQuestionPrompt(question), + options, + }); + + const answer = await withTimeout( + collectSideQuestionAnswer(oneShot), + SIDE_QUESTION_TIMEOUT_MS, + ); + + if (answer.result === "timeout") { + throw new RequestError( + -32603, + `Side question timed out after ${SIDE_QUESTION_TIMEOUT_MS}ms`, + ); + } + if (!answer.value) { + throw new RequestError(-32603, "Side question produced no answer"); + } + return { answer: answer.value }; + } catch (error) { + if (error instanceof RequestError) throw error; + // A brand-new session has no transcript on disk yet, so `resume` has + // nothing to fork; surface that case clearly. + throw new RequestError( + -32603, + `Side question failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } finally { + abortController.abort(); + if (this.sideQuestionAbort === abortController) { + this.sideQuestionAbort = null; + } + } + } + private async refreshSession( mcpServers: Record, ): Promise { diff --git a/products/desktop/packages/agent/src/adapters/claude/side-question.test.ts b/products/desktop/packages/agent/src/adapters/claude/side-question.test.ts new file mode 100644 index 000000000000..73f375a302d7 --- /dev/null +++ b/products/desktop/packages/agent/src/adapters/claude/side-question.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { buildSideQuestionPrompt } from "./side-question"; + +describe("buildSideQuestionPrompt", () => { + it("wraps the question in the side-question constraints", () => { + const prompt = buildSideQuestionPrompt("what does this function do?"); + + expect(prompt).toMatch( + /^[\s\S]*<\/system-reminder>\n\nwhat does this function do\?$/, + ); + expect(prompt).toContain("no tools available"); + expect(prompt).toContain("one-off response"); + expect(prompt).toContain("Never promise actions"); + }); +}); diff --git a/products/desktop/packages/agent/src/adapters/claude/side-question.ts b/products/desktop/packages/agent/src/adapters/claude/side-question.ts new file mode 100644 index 000000000000..2b9cad759335 --- /dev/null +++ b/products/desktop/packages/agent/src/adapters/claude/side-question.ts @@ -0,0 +1,58 @@ +/** + * Helpers for the one-shot "/btw" side question. Session-independent only — + * the forked query mechanics live in ClaudeAcpAgent.answerSideQuestion. + */ + +import { RequestError } from "@agentclientprotocol/sdk"; +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; + +export const SIDE_QUESTION_TIMEOUT_MS = 120_000; + +/** + * Drains the one-shot fork's message stream and returns the assistant's + * answer text. Throws when the turn ends in an error result. + */ +export async function collectSideQuestionAnswer( + messages: AsyncIterable, +): Promise { + const chunks: string[] = []; + for await (const message of messages) { + if (message.type === "assistant") { + for (const block of message.message.content) { + if (block.type === "text") { + chunks.push(block.text); + } + } + } else if (message.type === "result") { + if (message.subtype !== "success") { + throw new RequestError( + -32603, + `Side question failed: ${message.subtype}`, + ); + } + break; + } + } + return chunks.join("").trim(); +} + +/** + * Wraps the user's question in the constraints the fork must obey: no tools, + * single one-off response, answer only from what the conversation already + * contains. Mirrors the prompt shape Claude Code uses for its /btw command. + */ +export function buildSideQuestionPrompt(question: string): string { + return [ + "", + "The user is asking a quick side question. This is a one-off response", + "outside the main conversation; nothing you say here will be visible in", + "later turns. You have no tools available: you cannot read files, run", + "commands, search, or take any action. Answer concisely using only the", + "knowledge already present in the conversation. Never promise actions", + '(do not say things like "Let me try..." or offer to investigate);', + "if you do not know the answer, say so plainly.", + "", + "", + question, + ].join("\n"); +} diff --git a/products/desktop/packages/agent/src/index.ts b/products/desktop/packages/agent/src/index.ts index dd4e6081783c..4acb307033b7 100644 --- a/products/desktop/packages/agent/src/index.ts +++ b/products/desktop/packages/agent/src/index.ts @@ -1,4 +1,8 @@ -export { isNotification, POSTHOG_NOTIFICATIONS } from "./acp-extensions"; +export { + isNotification, + POSTHOG_METHODS, + POSTHOG_NOTIFICATIONS, +} from "./acp-extensions"; export { getMcpToolMetadata, isMcpToolReadOnly, diff --git a/products/desktop/packages/agent/src/server/agent-server.ts b/products/desktop/packages/agent/src/server/agent-server.ts index 7b98e9b6019b..74a9c3627fbe 100644 --- a/products/desktop/packages/agent/src/server/agent-server.ts +++ b/products/desktop/packages/agent/src/server/agent-server.ts @@ -1560,6 +1560,21 @@ export class AgentServer { ); } + case POSTHOG_METHODS.SIDE_QUESTION: + case "side_question": { + const question = params.question as string; + + this.logger.debug("Side question requested"); + + // Returned as the command result rather than emitted as a session + // update: a side question is ephemeral, so it must not reach the + // event stream or the persisted transcript. + return await this.session.clientConnection.extMethod( + POSTHOG_METHODS.SIDE_QUESTION, + { sessionId: this.session.acpSessionId, question }, + ); + } + case POSTHOG_NOTIFICATIONS.PERMISSION_RESPONSE: case "permission_response": { const requestId = params.requestId as string; diff --git a/products/desktop/packages/agent/src/server/schemas.test.ts b/products/desktop/packages/agent/src/server/schemas.test.ts index a31eef90a1b5..0e65f36d737e 100644 --- a/products/desktop/packages/agent/src/server/schemas.test.ts +++ b/products/desktop/packages/agent/src/server/schemas.test.ts @@ -164,6 +164,24 @@ describe("validateCommandParams", () => { expect(result.success).toBe(false); }); + // A command reaches executeCommand only if it has a schema registered here, + // so a missing entry rejects it as "Unknown method" however the dispatch + // switch is written. + it.each(["side_question", "posthog/side_question"])( + "accepts a valid %s", + (method) => { + const result = validateCommandParams(method, { + question: "what does this repo do?", + }); + + expect(result.success).toBe(true); + }, + ); + + it("rejects side_question without a question", () => { + expect(validateCommandParams("side_question", {}).success).toBe(false); + }); + it("accepts valid permission_response", () => { const result = validateCommandParams("permission_response", { requestId: "abc-123", diff --git a/products/desktop/packages/agent/src/server/schemas.ts b/products/desktop/packages/agent/src/server/schemas.ts index d494d39a83b6..f5ba0c6d7e1c 100644 --- a/products/desktop/packages/agent/src/server/schemas.ts +++ b/products/desktop/packages/agent/src/server/schemas.ts @@ -99,6 +99,13 @@ export const refreshSessionParamsSchema = z.object({ mcpServers: mcpServersSchema, }); +export const sideQuestionParamsSchema = z.object({ + question: z.string().min(1, "question is required"), + // The adapter resolves the ACP session itself; accepted for symmetry with + // the other session-scoped commands. + sessionId: z.string().optional(), +}); + /** * Names of desktop-only local MCP servers designated for relaying into this * run (docs/CLOUD-MCP-RELAY.md). Names only — the sandbox never learns the @@ -143,6 +150,8 @@ export const commandParamsSchemas = { mcp_response: mcpResponseParamsSchema, "posthog/mcp_response": mcpResponseParamsSchema, "_posthog/mcp_response": mcpResponseParamsSchema, + side_question: sideQuestionParamsSchema, + "posthog/side_question": sideQuestionParamsSchema, } as const; export type CommandMethod = keyof typeof commandParamsSchemas; diff --git a/products/desktop/packages/agent/src/utils/common.ts b/products/desktop/packages/agent/src/utils/common.ts index 867f6b2f61b7..36646bc454e8 100644 --- a/products/desktop/packages/agent/src/utils/common.ts +++ b/products/desktop/packages/agent/src/utils/common.ts @@ -9,14 +9,19 @@ export async function withTimeout( operation: Promise, timeoutMs: number, ): Promise<{ result: "success"; value: T } | { result: "timeout" }> { - const timeoutPromise = new Promise<{ result: "timeout" }>((resolve) => - setTimeout(() => resolve({ result: "timeout" }), timeoutMs), - ); + let timer: ReturnType | undefined; + const timeoutPromise = new Promise<{ result: "timeout" }>((resolve) => { + timer = setTimeout(() => resolve({ result: "timeout" }), timeoutMs); + }); const operationPromise = operation.then((value) => ({ result: "success" as const, value, })); - return Promise.race([operationPromise, timeoutPromise]); + try { + return await Promise.race([operationPromise, timeoutPromise]); + } finally { + clearTimeout(timer); + } } /** diff --git a/products/desktop/packages/core/src/cloud-task/schemas.ts b/products/desktop/packages/core/src/cloud-task/schemas.ts index 908c213168da..560bb4a3babb 100644 --- a/products/desktop/packages/core/src/cloud-task/schemas.ts +++ b/products/desktop/packages/core/src/cloud-task/schemas.ts @@ -73,6 +73,7 @@ export const sendCommandInput = z.object({ "pi/rpc", "queue_get", "queue_clear", + "side_question", ]), params: z.record(z.string(), z.unknown()).optional(), }); diff --git a/products/desktop/packages/core/src/sessions/sessionService.ts b/products/desktop/packages/core/src/sessions/sessionService.ts index 7b2331f05ebc..7930cd052d23 100644 --- a/products/desktop/packages/core/src/sessions/sessionService.ts +++ b/products/desktop/packages/core/src/sessions/sessionService.ts @@ -47,6 +47,7 @@ import { type StoredLogEntry, sendableQueuePrefixLength, sessionSupportsNativeSteer, + sessionSupportsSideQuestion, type TaskRunArtifact, type TaskRunStatus, TRANSCRIPT_TAIL_WINDOW, @@ -61,6 +62,7 @@ import { isTerminalStatus, type Task, } from "@posthog/shared/domain-types"; +import type { SendCommandOutput } from "../cloud-task/schemas"; import type { CommentTarget } from "../comments/anchors"; import type { AgentSessionNotification } from "../notification/agentSessionNotifications"; import { extractPostHogObjectReferences } from "../posthog-objects/references"; @@ -317,6 +319,7 @@ export interface SessionTrpc { reconnect: TrpcMutation; cancel: TrpcMutation; prompt: TrpcMutation; + sideQuestion: TrpcMutation; cancelPrompt: TrpcMutation; cancelPermission: TrpcMutation; respondToPermission: TrpcMutation; @@ -1672,9 +1675,14 @@ export function isPermissionRequestAlreadySurfaced( ); } -/** The steering capability on a loosely-typed agent start/reconnect result. */ -function readSteering(result: unknown): string | undefined { - return (result as { steering?: string } | undefined)?.steering; +/** The negotiated capabilities on a loosely-typed agent start/reconnect result. */ +function readCapabilities(result: unknown): { + steering?: string; + sideQuestion?: boolean; +} { + const { steering, sideQuestion } = + (result as { steering?: string; sideQuestion?: boolean } | undefined) ?? {}; + return { steering, sideQuestion }; } // Live streaming emits agent_message_chunk; SessionLogWriter coalesces a chunk @@ -2328,7 +2336,7 @@ export class SessionService { this.d.store.updateSession(taskRunId, { status: "connected", configOptions, - steering: readSteering(result), + ...readCapabilities(result), }); // Persist the merged config options @@ -2685,7 +2693,7 @@ export class SessionService { | SessionConfigOption[] | undefined; session.configOptions = configOptions; - session.steering = readSteering(result); + Object.assign(session, readCapabilities(result)); // Persist the config options if (configOptions) { @@ -4251,6 +4259,62 @@ export class SessionService { }); } + /** + * Ask a one-shot "/btw" side question: a single-turn, tool-less query forked + * off the live transcript. The exchange never enters the conversation, so + * this bypasses the queue/steer machinery entirely and is valid both + * mid-turn and idle. + */ + async askSideQuestion(taskId: string, question: string): Promise { + if (!this.d.getIsOnline()) { + throw new Error( + "No internet connection. Please check your connection and try again.", + ); + } + + const session = this.d.store.getSessionByTaskId(taskId); + if (!session) throw new Error("No active session for task"); + if (!sessionSupportsSideQuestion(session)) { + throw new Error("Side questions aren't supported for this session yet."); + } + if (session.status !== "connected") { + throw new Error("Session is not ready. Try again once it's connected."); + } + + if (session.isCloud) { + return this.askCloudSideQuestion(session, question); + } + + const result = (await this.d.trpc.agent.sideQuestion.mutate({ + sessionId: session.taskRunId, + question, + })) as { answer: string }; + return result.answer; + } + + /** + * Cloud variant: the fork runs in the sandbox, so the question goes over the + * command channel. The answer comes back as the command's result rather than + * on the event stream, which is what keeps it out of the transcript. + */ + private async askCloudSideQuestion( + session: AgentSession, + question: string, + ): Promise { + const result = await this.sendCloudCommand(session, "side_question", { + question, + }); + if (!result.success) { + throw new Error(result.error ?? "Side question failed"); + } + + const answer = (result.result as { answer?: unknown } | undefined)?.answer; + if (typeof answer !== "string" || !answer) { + throw new Error("Side question produced no answer"); + } + return answer; + } + /** * Send the next queued message as its own prompt. * Called internally when a turn completes and there are queued messages. @@ -5441,14 +5505,14 @@ export class SessionService { */ private async sendCloudCommand( session: AgentSession, - method: "permission_response" | "set_config_option", + method: "permission_response" | "set_config_option" | "side_question", params: Record, - ): Promise { + ): Promise { const auth = await this.getCloudCommandAuth(); if (!auth) { throw new Error("No cloud auth credentials available"); } - await this.d.trpc.cloudTask.sendCommand.mutate({ + return await this.d.trpc.cloudTask.sendCommand.mutate({ taskId: session.taskId, runId: session.taskRunId, apiHost: auth.apiHost, diff --git a/products/desktop/packages/core/src/sessions/sessionServiceSideQuestion.test.ts b/products/desktop/packages/core/src/sessions/sessionServiceSideQuestion.test.ts new file mode 100644 index 000000000000..3e6b8e9c290a --- /dev/null +++ b/products/desktop/packages/core/src/sessions/sessionServiceSideQuestion.test.ts @@ -0,0 +1,140 @@ +import type { AgentSession } from "@posthog/shared"; +import { describe, expect, it, vi } from "vitest"; +import { SessionService, type SessionServiceDeps } from "./sessionService"; + +function makeSession(overrides: Partial = {}): AgentSession { + return { + taskRunId: "run-1", + taskId: "task-1", + taskTitle: "Test task", + channel: "", + events: [], + startedAt: 1, + status: "connected", + isPromptPending: false, + isCompacting: false, + promptStartedAt: null, + pendingPermissions: new Map(), + pausedDurationMs: 0, + messageQueue: [], + optimisticItems: [], + adapter: "claude", + ...overrides, + } as AgentSession; +} + +function createHarness(session: AgentSession | undefined, online = true) { + const sideQuestionMutate = vi.fn().mockResolvedValue({ answer: "42" }); + const sendCommandMutate = vi + .fn() + .mockResolvedValue({ success: true, result: { answer: "42" } }); + const deps = { + store: { + getSessionByTaskId: () => session, + }, + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + getIsOnline: () => online, + fetchAuthState: async () => ({ + cloudRegion: "us", + currentProjectId: 7, + }), + trpc: { + agent: { + sideQuestion: { mutate: sideQuestionMutate }, + onSessionIdleKilled: { + subscribe: () => ({ unsubscribe: vi.fn() }), + }, + }, + cloudTask: { + sendCommand: { mutate: sendCommandMutate }, + }, + }, + } as unknown as SessionServiceDeps; + + return { + service: new SessionService(deps), + sideQuestionMutate, + sendCommandMutate, + }; +} + +describe("SessionService.askSideQuestion", () => { + it("sends the question keyed by taskRunId and returns the answer", async () => { + const { service, sideQuestionMutate } = createHarness(makeSession()); + + await expect(service.askSideQuestion("task-1", "why?")).resolves.toBe("42"); + expect(sideQuestionMutate).toHaveBeenCalledWith({ + sessionId: "run-1", + question: "why?", + }); + }); + + it("rejects while offline", async () => { + const { service, sideQuestionMutate } = createHarness(makeSession(), false); + + await expect(service.askSideQuestion("task-1", "why?")).rejects.toThrow( + /No internet connection/, + ); + expect(sideQuestionMutate).not.toHaveBeenCalled(); + }); + + it("rejects when no session exists for the task", async () => { + const { service } = createHarness(undefined); + + await expect(service.askSideQuestion("task-1", "why?")).rejects.toThrow( + /No active session/, + ); + }); + + it("rejects a codex session as unsupported", async () => { + const { service, sideQuestionMutate } = createHarness( + makeSession({ adapter: "codex" }), + ); + + await expect(service.askSideQuestion("task-1", "why?")).rejects.toThrow( + /aren't supported/, + ); + expect(sideQuestionMutate).not.toHaveBeenCalled(); + }); + + // Cloud sessions aren't in workspace-server's session map, so routing them + // down the local path would fail at runtime. + it("routes a cloud session over the command channel, not the local path", async () => { + const { service, sideQuestionMutate, sendCommandMutate } = createHarness( + makeSession({ isCloud: true }), + ); + + await expect(service.askSideQuestion("task-1", "why?")).resolves.toBe("42"); + expect(sideQuestionMutate).not.toHaveBeenCalled(); + expect(sendCommandMutate).toHaveBeenCalledWith( + expect.objectContaining({ + runId: "run-1", + method: "side_question", + params: { question: "why?" }, + }), + ); + }); + + it.each<[string, Record]>([ + ["the command fails", { success: false, error: "sandbox is gone" }], + ["the sandbox returns no answer", { success: true, result: {} }], + ])("rejects a cloud side question when %s", async (_label, response) => { + const { service, sendCommandMutate } = createHarness( + makeSession({ isCloud: true }), + ); + sendCommandMutate.mockResolvedValue(response); + + await expect(service.askSideQuestion("task-1", "why?")).rejects.toThrow(); + }); + + it("rejects when the session is not connected", async () => { + const { service, sideQuestionMutate } = createHarness( + makeSession({ status: "connecting" }), + ); + + await expect(service.askSideQuestion("task-1", "why?")).rejects.toThrow( + /not ready/, + ); + expect(sideQuestionMutate).not.toHaveBeenCalled(); + }); +}); diff --git a/products/desktop/packages/host-router/src/routers/agent.router.ts b/products/desktop/packages/host-router/src/routers/agent.router.ts index 4c85c6158c3d..9d8a43f16432 100644 --- a/products/desktop/packages/host-router/src/routers/agent.router.ts +++ b/products/desktop/packages/host-router/src/routers/agent.router.ts @@ -24,6 +24,8 @@ import { rtkStatusOutput, sessionResponseSchema, setConfigOptionInput, + sideQuestionInput, + sideQuestionOutput, startSessionInput, subscribeSessionInput, } from "@posthog/workspace-server/services/agent/schemas"; @@ -51,6 +53,15 @@ export const agentRouter = router({ }), ), + sideQuestion: publicProcedure + .input(sideQuestionInput) + .output(sideQuestionOutput) + .mutation(({ ctx, input }) => + ctx.container + .get(AGENT_SERVICE) + .sideQuestion(input.sessionId, input.question), + ), + cancel: publicProcedure .input(cancelSessionInput) .mutation(({ ctx, input }) => diff --git a/products/desktop/packages/shared/src/index.ts b/products/desktop/packages/shared/src/index.ts index e2e84b46adee..a98fa893bd2f 100644 --- a/products/desktop/packages/shared/src/index.ts +++ b/products/desktop/packages/shared/src/index.ts @@ -337,6 +337,7 @@ export { type SessionStatus, sendableQueuePrefixLength, sessionSupportsNativeSteer, + sessionSupportsSideQuestion, TRANSCRIPT_TAIL_WINDOW, } from "./sessions"; export type { diff --git a/products/desktop/packages/shared/src/sessions.test.ts b/products/desktop/packages/shared/src/sessions.test.ts index 8706578afe1e..ed677e42a4be 100644 --- a/products/desktop/packages/shared/src/sessions.test.ts +++ b/products/desktop/packages/shared/src/sessions.test.ts @@ -4,6 +4,7 @@ import { type AgentSession, resolveBypassRevertMode, sessionSupportsNativeSteer, + sessionSupportsSideQuestion, } from "./sessions"; function modeOption( @@ -115,3 +116,46 @@ describe("sessionSupportsNativeSteer", () => { expect(sessionSupportsNativeSteer(session)).toBe(expected); }); }); + +describe("sessionSupportsSideQuestion", () => { + type Case = Pick; + + it.each<[string, Case, boolean]>([ + [ + "claude advertises the capability", + { isCloud: false, sideQuestion: true, adapter: "claude" }, + true, + ], + // Fallback: pre-capability start paths leave sideQuestion unset; local + // claude is assumed capable, matching the steer fallback. + [ + "claude with no capability (fallback)", + { isCloud: false, sideQuestion: undefined, adapter: "claude" }, + true, + ], + [ + "codex with no capability (no fallback)", + { isCloud: false, sideQuestion: undefined, adapter: "codex" }, + false, + ], + [ + "claude explicitly without the capability", + { isCloud: false, sideQuestion: false, adapter: "claude" }, + false, + ], + // Cloud runs never negotiate the capability, so the adapter decides: the + // sandbox runs the same agent build and forks its own transcript. + [ + "cloud claude with no capability", + { isCloud: true, sideQuestion: undefined, adapter: "claude" }, + true, + ], + [ + "cloud codex with no capability", + { isCloud: true, sideQuestion: undefined, adapter: "codex" }, + false, + ], + ])("%s", (_label, session, expected) => { + expect(sessionSupportsSideQuestion(session)).toBe(expected); + }); +}); diff --git a/products/desktop/packages/shared/src/sessions.ts b/products/desktop/packages/shared/src/sessions.ts index 2626e0202e16..afe621c23109 100644 --- a/products/desktop/packages/shared/src/sessions.ts +++ b/products/desktop/packages/shared/src/sessions.ts @@ -96,6 +96,12 @@ export interface AgentSession { * record a conversation-cleared boundary they would ignore on resume. */ conversationClear?: boolean; + /** + * Adapter's negotiated side-question capability (`_meta.posthog.sideQuestion` + * from initialize). True means the adapter can answer a one-shot "/btw" + * question forked off the live transcript without touching the conversation. + */ + sideQuestion?: boolean; pendingPermissions: Map; pausedDurationMs: number; messageQueue: QueuedMessage[]; @@ -278,3 +284,22 @@ export function sessionSupportsNativeSteer( if (session.isCloud) return false; return session.steering == null && session.adapter === "claude"; } + +/** + * Whether the session can answer a one-shot "/btw" side question (a + * single-turn, tool-less query forked off the live transcript). Decided by the + * adapter's negotiated `sideQuestion` capability where one was negotiated. + * + * Cloud runs never complete the ACP `initialize` handshake with this client, so + * the capability is inferred from the adapter instead: the sandbox runs the same + * `@posthog/agent` build, and the fork happens there against its own transcript. + * A sandbox predating the `side_question` command rejects it, which surfaces as + * an error on the card rather than a missing command. + */ +export function sessionSupportsSideQuestion( + session: Pick, +): boolean { + if (session.sideQuestion === true) return true; + if (session.sideQuestion === false) return false; + return session.adapter === "claude"; +} diff --git a/products/desktop/packages/ui/src/features/message-editor/commands.test.ts b/products/desktop/packages/ui/src/features/message-editor/commands.test.ts index 420de06c2f79..c1cccb24e30c 100644 --- a/products/desktop/packages/ui/src/features/message-editor/commands.test.ts +++ b/products/desktop/packages/ui/src/features/message-editor/commands.test.ts @@ -1,7 +1,15 @@ -import { describe, expect, it } from "vitest"; -import { rewriteLocalSkillCommandPrompt } from "./commands"; +import { describe, expect, it, vi } from "vitest"; +import { + rewriteLocalSkillCommandPrompt, + tryExecuteCodeCommand, +} from "./commands"; import type { EditorAvailableCommand } from "./types"; +const toastError = vi.hoisted(() => vi.fn()); +vi.mock("@posthog/ui/primitives/toast", () => ({ + toast: { error: toastError, success: vi.fn() }, +})); + const commands: EditorAvailableCommand[] = [ { name: "local-test-skill", @@ -38,3 +46,67 @@ describe("message editor commands", () => { ).toBe(null); }); }); + +describe("/btw command", () => { + const baseContext = { + taskId: "task-1", + repoPath: "/tmp/repo", + session: null, + taskRun: null, + }; + + it("routes the question to askSideQuestion and reports handled", async () => { + toastError.mockClear(); + const askSideQuestion = vi.fn().mockReturnValue(true); + const handled = await tryExecuteCodeCommand("/btw what changed here?", { + ...baseContext, + askSideQuestion, + }); + expect(handled).toBe(true); + expect(askSideQuestion).toHaveBeenCalledWith("what changed here?"); + expect(toastError).not.toHaveBeenCalled(); + }); + + it("toasts when a side question is already pending", async () => { + toastError.mockClear(); + const askSideQuestion = vi.fn().mockReturnValue(false); + const handled = await tryExecuteCodeCommand("/btw and another thing?", { + ...baseContext, + askSideQuestion, + }); + expect(handled).toBe(true); + expect(toastError).toHaveBeenCalledWith( + "Wait for your last side question to finish first", + ); + }); + + it("toasts on an empty question without calling askSideQuestion", async () => { + toastError.mockClear(); + const askSideQuestion = vi.fn(); + const handled = await tryExecuteCodeCommand("/btw", { + ...baseContext, + askSideQuestion, + }); + expect(handled).toBe(true); + expect(askSideQuestion).not.toHaveBeenCalled(); + expect(toastError).toHaveBeenCalledWith("Add a question after /btw"); + }); + + it("toasts when the session doesn't support side questions", async () => { + toastError.mockClear(); + const handled = await tryExecuteCodeCommand("/btw is this supported?", { + ...baseContext, + askSideQuestion: undefined, + }); + expect(handled).toBe(true); + expect(toastError).toHaveBeenCalledWith( + "Side questions aren't supported for this session yet.", + ); + }); + + it("leaves non-command text unhandled", async () => { + expect(await tryExecuteCodeCommand("btw not a command", baseContext)).toBe( + false, + ); + }); +}); diff --git a/products/desktop/packages/ui/src/features/message-editor/commands.ts b/products/desktop/packages/ui/src/features/message-editor/commands.ts index 8fb6956755a6..c665cfab45e8 100644 --- a/products/desktop/packages/ui/src/features/message-editor/commands.ts +++ b/products/desktop/packages/ui/src/features/message-editor/commands.ts @@ -26,6 +26,9 @@ interface CommandContext { events: unknown[]; } | null; taskRun: { id?: string; log_url?: string } | null; + /** Fires a "/btw" side question. Absent when the session doesn't support them. */ + /** Returns false when a side question is already pending for this run. */ + askSideQuestion?: (question: string) => boolean; } export interface CodeCommandInsertContext { @@ -104,8 +107,32 @@ const addDirCommand: CodeCommand = { }, }; +export const BTW_COMMAND_NAME = "btw"; + +const btwCommand: CodeCommand = { + name: BTW_COMMAND_NAME, + description: + "Ask a quick side question without interrupting the conversation", + input: { hint: "your question" }, + execute(args, ctx) { + const question = args?.trim(); + if (!question) { + toast.error("Add a question after /btw"); + return; + } + if (!ctx.askSideQuestion) { + toast.error("Side questions aren't supported for this session yet."); + return; + } + if (!ctx.askSideQuestion(question)) { + toast.error("Wait for your last side question to finish first"); + } + }, +}; + const commands: CodeCommand[] = [ addDirCommand, + btwCommand, makeFeedbackCommand("good", "good", "Positive"), makeFeedbackCommand("bad", "bad", "Negative"), makeFeedbackCommand("feedback", "general", "General"), diff --git a/products/desktop/packages/ui/src/features/message-editor/suggestions/getSuggestions.test.ts b/products/desktop/packages/ui/src/features/message-editor/suggestions/getSuggestions.test.ts index 0d0a7181ebf7..e91726432dc1 100644 --- a/products/desktop/packages/ui/src/features/message-editor/suggestions/getSuggestions.test.ts +++ b/products/desktop/packages/ui/src/features/message-editor/suggestions/getSuggestions.test.ts @@ -154,6 +154,26 @@ const SCENARIOS: Scenario[] = [ sessionCommands: [{ name: "agent-cmd", description: "From agent" }], expectContains: ["agent-cmd", "fallback-skill"], }, + { + name: "claude session offers /btw", + contextTaskId: TASK_ID, + adapter: "claude", + sessionCommands: [], + expectContains: ["btw"], + }, + { + name: "codex session hides /btw (side questions unsupported)", + contextTaskId: TASK_ID, + adapter: "codex", + sessionCommands: [], + expectContains: ["good"], + expectNotContains: ["btw"], + }, + { + name: "new-task composer with no session hides /btw", + expectContains: ["good"], + expectNotContains: ["btw"], + }, ]; describe("getCommandSuggestions", () => { diff --git a/products/desktop/packages/ui/src/features/message-editor/suggestions/getSuggestions.ts b/products/desktop/packages/ui/src/features/message-editor/suggestions/getSuggestions.ts index 8274a0ecdebc..798fb63879af 100644 --- a/products/desktop/packages/ui/src/features/message-editor/suggestions/getSuggestions.ts +++ b/products/desktop/packages/ui/src/features/message-editor/suggestions/getSuggestions.ts @@ -9,12 +9,13 @@ import { shapeCommandSuggestions, shapeFileSuggestions, } from "@posthog/core/message-editor/suggestions"; +import { sessionSupportsSideQuestion } from "@posthog/shared"; import { getAvailableCommandsForTask, useSessionStore, } from "@posthog/ui/features/sessions/sessionStore"; import { fetchRepoFiles, searchFiles } from "../../repo-files/useRepoFiles"; -import { CODE_COMMANDS } from "../commands"; +import { BTW_COMMAND_NAME, CODE_COMMANDS } from "../commands"; import { useDraftStore } from "../draftStore"; import { searchGithubRefs } from "../hostApi"; import type { @@ -25,13 +26,19 @@ import type { function getTaskCommandContext(taskId: string | undefined): { adapter: string | undefined; + supportsSideQuestion: boolean; commands: ReturnType; } { - if (!taskId) return { adapter: undefined, commands: null }; + if (!taskId) + return { adapter: undefined, supportsSideQuestion: false, commands: null }; const state = useSessionStore.getState(); const taskRunId = state.taskIdIndex[taskId]; + const session = taskRunId ? state.sessions[taskRunId] : undefined; return { - adapter: taskRunId ? state.sessions[taskRunId]?.adapter : undefined, + adapter: session?.adapter, + supportsSideQuestion: session + ? sessionSupportsSideQuestion(session) + : false, commands: getAvailableCommandsForTask(taskId), }; } @@ -104,7 +111,11 @@ export function getCommandSuggestions( // commands, so keep merging the trpc-fetched skills fallback for GPT tasks. // `null` means "agent hasn't reported yet"; an empty array means "agent // reported empty". - const { adapter, commands: sessionCommands } = getTaskCommandContext(taskId); + const { + adapter, + supportsSideQuestion, + commands: sessionCommands, + } = getTaskCommandContext(taskId); const draftCommands = store.commands[sessionId] ?? []; const localDraftCommands = draftCommands.filter((cmd) => cmd.localSkill); const agentCommands = @@ -113,7 +124,13 @@ export function getCommandSuggestions( : adapter === "codex" ? mergeCommands(sessionCommands, draftCommands) : [...sessionCommands, ...localDraftCommands]; - const commands = mergeCommands(CODE_COMMANDS, agentCommands); + // Only offer /btw where the session can actually answer a side question; + // otherwise picking it from the menu dead-ends in an error toast. The + // runtime guard in the command still stands for users who type /btw by hand. + const codeCommands = supportsSideQuestion + ? CODE_COMMANDS + : CODE_COMMANDS.filter((cmd) => cmd.name !== BTW_COMMAND_NAME); + const commands = mergeCommands(codeCommands, agentCommands); const filtered = searchCommands(commands, query); return shapeCommandSuggestions(filtered); diff --git a/products/desktop/packages/ui/src/features/sessions/components/SessionView.tsx b/products/desktop/packages/ui/src/features/sessions/components/SessionView.tsx index ac988c273e36..2ab22f9950bb 100644 --- a/products/desktop/packages/ui/src/features/sessions/components/SessionView.tsx +++ b/products/desktop/packages/ui/src/features/sessions/components/SessionView.tsx @@ -42,6 +42,7 @@ import { QueuedMessagesDock } from "@posthog/ui/features/sessions/components/Que import { ReasoningLevelSelector } from "@posthog/ui/features/sessions/components/ReasoningLevelSelector"; import { RawLogsView } from "@posthog/ui/features/sessions/components/raw-logs/RawLogsView"; import { SessionInitializingView } from "@posthog/ui/features/sessions/components/SessionInitializingView"; +import { SideQuestionCard } from "@posthog/ui/features/sessions/components/SideQuestionCard"; import { SteerQueueToggle } from "@posthog/ui/features/sessions/components/SteerQueueToggle"; import { isSubmittedContentUnchanged, @@ -408,6 +409,7 @@ export function SessionView({ (s) => !!s?.editingQueuedId, ); const cancelQueuedEdit = useCancelQueuedMessageEdit(taskId); + const activeTaskRunId = useSessionSelector(taskId, (s) => s?.taskRunId); const [isDraggingFile, setIsDraggingFile] = useState(false); const promptRecallRef = useRef(null); @@ -740,6 +742,12 @@ export function SessionView({ }`} > + {taskId && ( + + )} {taskId && } { + beforeEach(() => { + useSideQuestionStore.setState({ byTaskId: {} }); + }); + + it("renders nothing when there is no entry for the task", () => { + const { container } = render( + + + , + ); + expect(container.firstElementChild).toBeEmptyDOMElement(); + }); + + it("shows a spinner while the question is pending", () => { + useSideQuestionStore.setState({ + byTaskId: { + "task-1": { + id: "q-1", + question: "what changed?", + taskRunId: "run-1", + status: "pending", + }, + }, + }); + + render( + + + , + ); + + expect(screen.getByText("what changed?")).toBeInTheDocument(); + expect(screen.getByText("Answering…")).toBeInTheDocument(); + }); + + it("renders the answer once done", () => { + useSideQuestionStore.setState({ + byTaskId: { + "task-1": { + id: "q-1", + question: "what changed?", + taskRunId: "run-1", + status: "done", + answer: "The function parses JSONL.", + }, + }, + }); + + render( + + + , + ); + + expect(screen.getByText("The function parses JSONL.")).toBeInTheDocument(); + }); + + it("renders the error message on failure", () => { + useSideQuestionStore.setState({ + byTaskId: { + "task-1": { + id: "q-1", + question: "what changed?", + taskRunId: "run-1", + status: "error", + error: "Side question timed out", + }, + }, + }); + + render( + + + , + ); + + expect(screen.getByText("Side question timed out")).toBeInTheDocument(); + }); + + it("renders nothing when the entry belongs to a prior run", () => { + useSideQuestionStore.setState({ + byTaskId: { + "task-1": { + id: "q-1", + question: "what changed?", + taskRunId: "run-1", + status: "done", + answer: "The function parses JSONL.", + }, + }, + }); + + const { container } = render( + + + , + ); + + expect(container.firstElementChild).toBeEmptyDOMElement(); + }); + + it("dismisses the entry when the dismiss button is clicked", () => { + useSideQuestionStore.setState({ + byTaskId: { + "task-1": { + id: "q-1", + question: "what changed?", + taskRunId: "run-1", + status: "pending", + }, + }, + }); + + render( + + + , + ); + + fireEvent.click(screen.getByLabelText("Dismiss side question")); + + expect(useSideQuestionStore.getState().byTaskId["task-1"]).toBeUndefined(); + }); +}); diff --git a/products/desktop/packages/ui/src/features/sessions/components/SideQuestionCard.tsx b/products/desktop/packages/ui/src/features/sessions/components/SideQuestionCard.tsx new file mode 100644 index 000000000000..5d2386796695 --- /dev/null +++ b/products/desktop/packages/ui/src/features/sessions/components/SideQuestionCard.tsx @@ -0,0 +1,63 @@ +import { ChatCircleDots, Spinner, X } from "@phosphor-icons/react"; +import { useSideQuestionStore } from "@posthog/ui/features/sessions/sideQuestionStore"; +import { Box, Flex, IconButton, Text, Tooltip } from "@radix-ui/themes"; +import { MarkdownRenderer } from "../../editor/components/MarkdownRenderer"; + +interface SideQuestionCardProps { + taskId: string; + /** The task's current run. An entry asked against a prior run is hidden. */ + taskRunId?: string; +} + +/** + * Ephemeral "/btw" side-question card pinned above the composer. The exchange + * lives only in view state — it is never part of the session transcript — so + * dismissing it leaves no trace. + */ +export function SideQuestionCard({ taskId, taskRunId }: SideQuestionCardProps) { + const entry = useSideQuestionStore((s) => s.byTaskId[taskId]); + const dismiss = useSideQuestionStore((s) => s.dismiss); + + if (!entry || entry.taskRunId !== taskRunId) return null; + + return ( + + + + + {entry.question} + + + dismiss(taskId)} + > + + + + + + {entry.status === "pending" && ( + + + Answering… + + )} + {entry.status === "done" && ( + + + + )} + {entry.status === "error" && ( + {entry.error} + )} + + + ); +} diff --git a/products/desktop/packages/ui/src/features/sessions/hooks/useSessionCallbacks.test.tsx b/products/desktop/packages/ui/src/features/sessions/hooks/useSessionCallbacks.test.tsx index 1bb918f22469..d37841693542 100644 --- a/products/desktop/packages/ui/src/features/sessions/hooks/useSessionCallbacks.test.tsx +++ b/products/desktop/packages/ui/src/features/sessions/hooks/useSessionCallbacks.test.tsx @@ -48,9 +48,13 @@ vi.mock("@posthog/ui/features/sessions/hooks/useMessagingMode", () => ({ useMessagingMode: () => messagingMode.value, })); -// No code command / skill rewrite; the raw text is used as the prompt. +// No code command / skill rewrite by default; the raw text is used as the +// prompt. Kept as a spy so tests can inspect the CommandContext it received. +const tryExecuteCodeCommand = vi.hoisted(() => + vi.fn(async (_text: string, _ctx: Record) => false), +); vi.mock("@posthog/ui/features/message-editor/commands", () => ({ - tryExecuteCodeCommand: async () => false, + tryExecuteCodeCommand, rewriteLocalSkillCommandPrompt: () => null, resolveLocalSkillPrompt: async (text: string) => text, })); @@ -103,6 +107,27 @@ function renderCallbacks(session?: AgentSession) { ); } +function makeSession(overrides: Partial): AgentSession { + return { + taskRunId: "run-1", + taskId: TASK, + taskTitle: "Test", + channel: "agent-event:run-1", + events: [], + startedAt: 0, + status: "connected", + isPromptPending: false, + isCompacting: false, + promptStartedAt: null, + pendingPermissions: new Map(), + pausedDurationMs: 0, + messageQueue: [], + optimisticItems: [], + adapter: "claude", + ...overrides, + } as AgentSession; +} + describe("useSessionCallbacks.handleSendPrompt while editing a queued message", () => { beforeEach(() => { vi.clearAllMocks(); @@ -269,3 +294,43 @@ describe("useSessionCallbacks.handleCancelPrompt", () => { }); }); }); + +describe("useSessionCallbacks.handleSendPrompt askSideQuestion gating", () => { + beforeEach(() => { + vi.clearAllMocks(); + sessionState.editingQueuedId = undefined; + sessionState.messageQueue = []; + }); + + it("passes a callable askSideQuestion for a session that supports it", async () => { + const { result } = renderHook(() => + useSessionCallbacks({ + taskId: TASK, + task, + session: makeSession({ isCloud: false, sideQuestion: true }), + repoPath: "/repo", + }), + ); + await result.current.handleSendPrompt("/btw what changed?"); + + const ctx = tryExecuteCodeCommand.mock.calls.at(-1)?.[1]; + expect(ctx).toBeDefined(); + expect(ctx?.askSideQuestion).toBeInstanceOf(Function); + }); + + it("passes undefined askSideQuestion for a session that does not support it", async () => { + const { result } = renderHook(() => + useSessionCallbacks({ + taskId: TASK, + task, + session: makeSession({ sideQuestion: false }), + repoPath: "/repo", + }), + ); + await result.current.handleSendPrompt("/btw what changed?"); + + const ctx = tryExecuteCodeCommand.mock.calls.at(-1)?.[1]; + expect(ctx).toBeDefined(); + expect(ctx?.askSideQuestion).toBeUndefined(); + }); +}); diff --git a/products/desktop/packages/ui/src/features/sessions/hooks/useSessionCallbacks.ts b/products/desktop/packages/ui/src/features/sessions/hooks/useSessionCallbacks.ts index f4b29a3e90ba..c081e0b96b7d 100644 --- a/products/desktop/packages/ui/src/features/sessions/hooks/useSessionCallbacks.ts +++ b/products/desktop/packages/ui/src/features/sessions/hooks/useSessionCallbacks.ts @@ -13,6 +13,7 @@ import { } from "@posthog/core/sessions/sessionService"; import { useService } from "@posthog/di/react"; import { useHostTRPCClient } from "@posthog/host-router/react"; +import { sessionSupportsSideQuestion } from "@posthog/shared"; import type { Task } from "@posthog/shared/domain-types"; import { resolveLocalSkillPrompt, @@ -25,6 +26,7 @@ import { type AgentSession, sessionStoreSetters, } from "@posthog/ui/features/sessions/sessionStore"; +import { fireSideQuestion } from "@posthog/ui/features/sessions/sideQuestionStore"; import { useTaskViewed } from "@posthog/ui/features/sidebar/useTaskViewed"; import { SHELL_CLIENT, @@ -76,6 +78,16 @@ export function useSessionCallbacks({ } : null, taskRun: task.latest_run ?? null, + askSideQuestion: + currentSession && sessionSupportsSideQuestion(currentSession) + ? (question) => + fireSideQuestion( + sessionService, + taskId, + currentSession.taskRunId, + question, + ) + : undefined, }); if (handled) return true; diff --git a/products/desktop/packages/ui/src/features/sessions/sideQuestionStore.test.ts b/products/desktop/packages/ui/src/features/sessions/sideQuestionStore.test.ts new file mode 100644 index 000000000000..96190b424196 --- /dev/null +++ b/products/desktop/packages/ui/src/features/sessions/sideQuestionStore.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { useSideQuestionStore } from "./sideQuestionStore"; + +/** `ask` returns null when one is already pending; these cases expect it to take. */ +function askId(taskId: string, taskRunId: string, question: string): string { + const id = useSideQuestionStore.getState().ask(taskId, taskRunId, question); + if (!id) throw new Error(`ask unexpectedly refused: ${question}`); + return id; +} + +describe("sideQuestionStore", () => { + beforeEach(() => { + useSideQuestionStore.setState({ byTaskId: {} }); + }); + + it("does not repopulate a dismissed entry with a late answer", () => { + const { dismiss, resolve } = useSideQuestionStore.getState(); + + const id = askId("task-1", "run-1", "what changed?"); + dismiss("task-1"); + resolve("task-1", "run-1", id, "the late answer"); + + expect(useSideQuestionStore.getState().byTaskId["task-1"]).toBeUndefined(); + }); + + it("refuses a second question while the first is still pending", () => { + const { ask } = useSideQuestionStore.getState(); + + const firstId = askId("task-1", "run-1", "first question?"); + expect(ask("task-1", "run-1", "second question?")).toBeNull(); + + const entry = useSideQuestionStore.getState().byTaskId["task-1"]; + expect(entry?.id).toBe(firstId); + expect(entry?.question).toBe("first question?"); + }); + + it.each([ + [ + "answered", + (id: string) => + useSideQuestionStore + .getState() + .resolve("task-1", "run-1", id, "the answer"), + ], + [ + "failed", + (id: string) => + useSideQuestionStore.getState().fail("task-1", "run-1", id, "it broke"), + ], + ])("allows a new question once the previous one has %s", (_label, settle) => { + const firstId = askId("task-1", "run-1", "first question?"); + settle(firstId); + + expect( + useSideQuestionStore.getState().ask("task-1", "run-1", "second?"), + ).not.toBeNull(); + }); + + it("does not let a stale answer clobber a question asked after a dismiss", () => { + const { dismiss, resolve } = useSideQuestionStore.getState(); + + const firstId = askId("task-1", "run-1", "first question?"); + dismiss("task-1"); + const secondId = askId("task-1", "run-1", "second question?"); + resolve("task-1", "run-1", firstId, "answer to the first"); + + const entry = useSideQuestionStore.getState().byTaskId["task-1"]; + expect(entry?.id).toBe(secondId); + expect(entry?.status).toBe("pending"); + }); + + it("does not settle an answer against a run the task has since moved on from", () => { + const { resolve } = useSideQuestionStore.getState(); + + const id = askId("task-1", "run-1", "what changed?"); + resolve("task-1", "run-2", id, "answer from the old run"); + + const entry = useSideQuestionStore.getState().byTaskId["task-1"]; + expect(entry?.status).toBe("pending"); + }); +}); diff --git a/products/desktop/packages/ui/src/features/sessions/sideQuestionStore.ts b/products/desktop/packages/ui/src/features/sessions/sideQuestionStore.ts new file mode 100644 index 000000000000..4128cb6e733e --- /dev/null +++ b/products/desktop/packages/ui/src/features/sessions/sideQuestionStore.ts @@ -0,0 +1,125 @@ +import type { SessionService } from "@posthog/core/sessions/sessionService"; +import { getErrorMessage } from "@posthog/shared"; +import { logger } from "@posthog/ui/shell/logger"; +import { create } from "zustand"; + +const log = logger.scope("side-question"); + +export type SideQuestionEntry = { + id: string; + question: string; + /** The run this question was asked against — a card must not outlive it. */ + taskRunId: string; +} & ( + | { status: "pending" } + | { status: "done"; answer: string } + | { status: "error"; error: string } +); + +interface SideQuestionState { + /** Latest side question per task. Ephemeral: never persisted, never part of session history. */ + byTaskId: Record; + /** Returns null when one is already pending — the card holds a single question. */ + ask: (taskId: string, taskRunId: string, question: string) => string | null; + resolve: ( + taskId: string, + taskRunId: string, + id: string, + answer: string, + ) => void; + fail: (taskId: string, taskRunId: string, id: string, error: string) => void; + dismiss: (taskId: string) => void; +} + +/** + * Settles the entry only if it is still the one the caller created for the + * same run — a re-asked or dismissed question, or a run that reconnected or + * restarted in the meantime, must not receive a stale answer. + */ +function settle( + state: SideQuestionState, + taskId: string, + taskRunId: string, + id: string, + outcome: + | { status: "done"; answer: string } + | { status: "error"; error: string }, +): Partial | SideQuestionState { + const entry = state.byTaskId[taskId]; + if (entry?.id !== id || entry.taskRunId !== taskRunId) return state; + return { + byTaskId: { + ...state.byTaskId, + [taskId]: { + id: entry.id, + question: entry.question, + taskRunId, + ...outcome, + }, + }, + }; +} + +export const useSideQuestionStore = create()((set, get) => ({ + byTaskId: {}, + ask: (taskId, taskRunId, question) => { + const pending = get().byTaskId[taskId]; + if (pending?.status === "pending" && pending.taskRunId === taskRunId) { + return null; + } + const id = crypto.randomUUID(); + set((state) => ({ + byTaskId: { + ...state.byTaskId, + [taskId]: { id, question, taskRunId, status: "pending" }, + }, + })); + return id; + }, + resolve: (taskId, taskRunId, id, answer) => + set((state) => + settle(state, taskId, taskRunId, id, { status: "done", answer }), + ), + fail: (taskId, taskRunId, id, error) => + set((state) => + settle(state, taskId, taskRunId, id, { status: "error", error }), + ), + dismiss: (taskId) => + set((state) => { + const { [taskId]: _dismissed, ...rest } = state.byTaskId; + return { byTaskId: rest }; + }), +})); + +/** + * Fire-and-forget "/btw" saga: record the pending entry, send the question, + * and settle the entry with the answer or error. Fire-and-forget so the + * composer clears immediately; the side question runs beside the + * conversation, mid-turn or idle. + * + * Returns false when one is already pending, so the caller can say so rather + * than replace a question whose answer is still on its way. + */ +export function fireSideQuestion( + sessionService: Pick, + taskId: string, + taskRunId: string, + question: string, +): boolean { + const { ask, resolve, fail } = useSideQuestionStore.getState(); + const id = ask(taskId, taskRunId, question); + if (!id) return false; + sessionService + .askSideQuestion(taskId, question) + .then((answer) => resolve(taskId, taskRunId, id, answer)) + .catch((error) => { + fail( + taskId, + taskRunId, + id, + getErrorMessage(error) || "Side question failed", + ); + log.error("Side question failed", error); + }); + return true; +} diff --git a/products/desktop/packages/workspace-server/src/services/agent/agent.test.ts b/products/desktop/packages/workspace-server/src/services/agent/agent.test.ts index 73b8fe93a052..4035712a4f72 100644 --- a/products/desktop/packages/workspace-server/src/services/agent/agent.test.ts +++ b/products/desktop/packages/workspace-server/src/services/agent/agent.test.ts @@ -110,6 +110,10 @@ vi.mock("@agentclientprotocol/sdk", () => ({ vi.mock("@posthog/agent", () => ({ isMcpToolReadOnly: vi.fn(() => false), + POSTHOG_METHODS: { + SIDE_QUESTION: "_posthog/side_question", + REFRESH_SESSION: "_posthog/refresh_session", + }, })); vi.mock("@posthog/agent/posthog-api", () => ({ @@ -856,6 +860,7 @@ describe("AgentService", () => { config: {}, promptPending: false, inFlightMcpToolCalls: new Map(), + pendingSideQuestions: 0, mcpToolApprovals: {}, toolInstallations: {}, ...overrides, @@ -994,6 +999,31 @@ describe("AgentService", () => { ); }); + it("reschedules when pendingSideQuestions is non-zero at timeout", () => { + injectSession(service, "run-1", { pendingSideQuestions: 1 }); + service.recordActivity("run-1"); + + vi.advanceTimersByTime(15 * 60 * 1000); + + expect(service.emit).not.toHaveBeenCalledWith( + "session-idle-killed", + expect.anything(), + ); + expect(getIdleTimeouts(service).has("run-1")).toBe(true); + }); + + it("kills session when pendingSideQuestions is zero", () => { + injectSession(service, "run-1", { pendingSideQuestions: 0 }); + service.recordActivity("run-1"); + + vi.advanceTimersByTime(15 * 60 * 1000); + + expect(service.emit).toHaveBeenCalledWith( + "session-idle-killed", + expect.objectContaining({ taskRunId: "run-1" }), + ); + }); + it("checkIdleDeadlines does not kill non-expired sessions", () => { injectSession(service, "run-1"); service.recordActivity("run-1"); @@ -1012,6 +1042,123 @@ describe("AgentService", () => { }); }); + describe("sideQuestion", () => { + function injectSession( + svc: AgentService, + taskRunId: string, + overrides: Record = {}, + ) { + const sessions = (svc as unknown as { sessions: Map }) + .sessions; + sessions.set(taskRunId, { + taskRunId, + taskId: `task-for-${taskRunId}`, + repoPath: "/mock/repo", + agent: { cleanup: vi.fn().mockResolvedValue(undefined) }, + clientSideConnection: { extMethod: vi.fn() }, + channel: `ch-${taskRunId}`, + createdAt: Date.now(), + lastActivityAt: 0, + config: { sessionId: "agent-session-1" }, + promptPending: false, + inFlightMcpToolCalls: new Map(), + pendingSideQuestions: 0, + mcpToolApprovals: {}, + toolInstallations: {}, + ...overrides, + }); + } + + it("throws when no session exists for the given id", async () => { + await expect(service.sideQuestion("unknown-run", "why?")).rejects.toThrow( + "Session not found: unknown-run", + ); + }); + + it("forwards the question to the adapter and returns the parsed answer", async () => { + const extMethod = vi.fn().mockResolvedValue({ answer: "42" }); + injectSession(service, "run-1", { + clientSideConnection: { extMethod }, + }); + + const result = await service.sideQuestion("run-1", "why?"); + + expect(result).toEqual({ answer: "42" }); + expect(extMethod).toHaveBeenCalledWith("_posthog/side_question", { + sessionId: "agent-session-1", + question: "why?", + }); + }); + + it("records activity on the session", async () => { + const extMethod = vi.fn().mockResolvedValue({ answer: "42" }); + injectSession(service, "run-1", { + clientSideConnection: { extMethod }, + lastActivityAt: 0, + }); + const recordActivitySpy = vi.spyOn(service, "recordActivity"); + + await service.sideQuestion("run-1", "why?"); + + expect(recordActivitySpy).toHaveBeenCalledWith("run-1"); + const sessions = ( + service as unknown as { + sessions: Map; + } + ).sessions; + expect(sessions.get("run-1")?.lastActivityAt).toBeGreaterThan(0); + }); + + it("throws when the adapter returns a malformed result", async () => { + const extMethod = vi.fn().mockResolvedValue({ notAnAnswer: true }); + injectSession(service, "run-1", { + clientSideConnection: { extMethod }, + }); + + await expect(service.sideQuestion("run-1", "why?")).rejects.toThrow(); + }); + + it("tracks pendingSideQuestions while the extension call is in flight", async () => { + let resolveExtMethod: (value: { answer: string }) => void = () => {}; + const extMethod = vi.fn( + () => + new Promise((resolve) => { + resolveExtMethod = resolve; + }), + ); + injectSession(service, "run-1", { clientSideConnection: { extMethod } }); + const sessions = ( + service as unknown as { + sessions: Map; + } + ).sessions; + + const promise = service.sideQuestion("run-1", "why?"); + expect(sessions.get("run-1")?.pendingSideQuestions).toBe(1); + + resolveExtMethod({ answer: "42" }); + await promise; + + expect(sessions.get("run-1")?.pendingSideQuestions).toBe(0); + }); + + it("decrements pendingSideQuestions when the extension call fails", async () => { + const extMethod = vi.fn().mockRejectedValue(new Error("boom")); + injectSession(service, "run-1", { clientSideConnection: { extMethod } }); + const sessions = ( + service as unknown as { + sessions: Map; + } + ).sessions; + + await expect(service.sideQuestion("run-1", "why?")).rejects.toThrow( + "boom", + ); + + expect(sessions.get("run-1")?.pendingSideQuestions).toBe(0); + }); + }); + describe("channel system prompt repository isolation", () => { const credentials = { apiHost: "https://app.posthog.com", projectId: 1 }; diff --git a/products/desktop/packages/workspace-server/src/services/agent/agent.ts b/products/desktop/packages/workspace-server/src/services/agent/agent.ts index 5b9280793aa3..d108c4621f4c 100644 --- a/products/desktop/packages/workspace-server/src/services/agent/agent.ts +++ b/products/desktop/packages/workspace-server/src/services/agent/agent.ts @@ -16,6 +16,7 @@ import { detectRtkBinary, isMcpToolReadOnly, isNotification, + POSTHOG_METHODS, POSTHOG_NOTIFICATIONS, } from "@posthog/agent"; import type { McpToolApprovals } from "@posthog/agent/adapters/claude/mcp/tool-metadata"; @@ -113,7 +114,9 @@ import { type ReconnectSessionInput, type RtkStatus, type SessionResponse, + type SideQuestionOutput, type StartSessionInput, + sideQuestionOutput, } from "./schemas"; export type { InterruptReason }; @@ -302,14 +305,24 @@ interface SessionConfig { bedrockGatewayVariant?: BedrockGatewayVariant; } -/** Pull the adapter's `agentCapabilities._meta.posthog.steering` from initialize. */ -function extractSteeringCapability(init: unknown): string | undefined { - const steering = ( +/** Pull the adapter's negotiated `agentCapabilities._meta.posthog` capabilities from initialize. */ +function extractPosthogCapabilities(init: unknown): { + steering?: string; + sideQuestion?: boolean; +} { + const posthog = ( init as { - agentCapabilities?: { _meta?: { posthog?: { steering?: unknown } } }; + agentCapabilities?: { _meta?: { posthog?: Record } }; } - )?.agentCapabilities?._meta?.posthog?.steering; - return typeof steering === "string" ? steering : undefined; + )?.agentCapabilities?._meta?.posthog; + return { + steering: + typeof posthog?.steering === "string" ? posthog.steering : undefined, + sideQuestion: + typeof posthog?.sideQuestion === "boolean" + ? posthog.sideQuestion + : undefined, + }; } /** A streaming turn emits many events a second; warn once a minute, not per event. */ @@ -331,8 +344,12 @@ interface ManagedSession { configOptions?: SessionConfigOption[]; /** Adapter's negotiated steering capability from initialize (`_meta.posthog.steering`). */ steering?: string; + /** Adapter's negotiated side-question capability from initialize (`_meta.posthog.sideQuestion`). */ + sideQuestion?: boolean; /** Tracks in-flight MCP tool calls (toolCallId → toolKey) for cancellation */ inFlightMcpToolCalls: Map; + /** Count of "/btw" side questions awaiting a response, so the idle timer does not reap the session mid-answer. */ + pendingSideQuestions: number; /** MCP tool approval states fetched at session start */ mcpToolApprovals: McpToolApprovals; /** Maps tool keys to their installation for backend approval updates */ @@ -589,7 +606,11 @@ export class AgentService extends TypedEventEmitter { private killIdleSession(taskRunId: string): void { const session = this.sessions.get(taskRunId); if (!session) return; - if (session.promptPending || session.inFlightMcpToolCalls.size > 0) { + if ( + session.promptPending || + session.inFlightMcpToolCalls.size > 0 || + session.pendingSideQuestions > 0 + ) { this.recordActivity(taskRunId); return; } @@ -948,10 +969,11 @@ If a repository is required, call \`list_repos\` to find it, then use \`clone_re }, }); // The adapter advertises whether mid-turn steering folds natively into the - // running turn (`steering: "native"`) vs needs cancel+resend. Surface it so - // the host gates steer-vs-resend on the negotiated capability, not on a - // hardcoded adapter name (codex-acp advertises "interrupt-resend"). - const steering = extractSteeringCapability(initResult); + // running turn (`steering: "native"`) vs needs cancel+resend, and whether + // it can answer one-shot "/btw" side questions. Surface both so the host + // gates on the negotiated capabilities, not on a hardcoded adapter name + // (codex-acp advertises "interrupt-resend"). + const { steering, sideQuestion } = extractPosthogCapabilities(initResult); const { servers: mcpServers, @@ -1198,7 +1220,9 @@ If a repository is required, call \`list_repos\` to find it, then use \`clone_re promptPending: false, configOptions, steering, + sideQuestion, inFlightMcpToolCalls: new Map(), + pendingSideQuestions: 0, mcpToolApprovals: toolApprovals, toolInstallations, evaluatedPrUrls: new Set(), @@ -1464,6 +1488,42 @@ If a repository is required, call \`list_repos\` to find it, then use \`clone_re } } + /** + * Answers a one-shot "/btw" side question via the adapter's SIDE_QUESTION + * extension method. Never touches promptPending and never becomes part of + * the conversation; it does count as activity (resets the idle-kill timer), + * the same way refreshSession does. The exchange runs beside the + * conversation (ACP JSON-RPC multiplexes, so this works mid-turn). + * + * `pendingSideQuestions` keeps the session alive if the idle timer fires + * while the extension call is still awaited — otherwise `killIdleSession` + * would see no pending prompt and no in-flight tool call and clean up the + * session out from under this request. + */ + async sideQuestion( + sessionId: string, + question: string, + ): Promise { + const session = this.sessions.get(sessionId); + if (!session) { + throw new Error(`Session not found: ${sessionId}`); + } + + session.lastActivityAt = Date.now(); + this.recordActivity(sessionId); + session.pendingSideQuestions++; + + try { + const result = await session.clientSideConnection.extMethod( + POSTHOG_METHODS.SIDE_QUESTION, + { sessionId: getAgentSessionId(session), question }, + ); + return sideQuestionOutput.parse(result); + } finally { + session.pendingSideQuestions--; + } + } + async cancelSession(sessionId: string): Promise { const session = this.sessions.get(sessionId); if (!session) return false; @@ -2225,6 +2285,7 @@ For git operations while detached: channel: session.channel, configOptions: session.configOptions, steering: session.steering, + sideQuestion: session.sideQuestion, }; } diff --git a/products/desktop/packages/workspace-server/src/services/agent/schemas.ts b/products/desktop/packages/workspace-server/src/services/agent/schemas.ts index 14d1cd3ba438..230227a592a1 100644 --- a/products/desktop/packages/workspace-server/src/services/agent/schemas.ts +++ b/products/desktop/packages/workspace-server/src/services/agent/schemas.ts @@ -178,6 +178,10 @@ export const sessionResponseSchema = z.object({ // running turn; "interrupt-resend" (legacy) or absent means the host must // cancel + resend instead. Drives the host's steer-vs-resend decision. steering: z.string().optional(), + // The adapter's negotiated side-question capability from initialize + // (`_meta.posthog.sideQuestion`): true means the adapter can answer a + // one-shot "/btw" question forked off the live transcript. + sideQuestion: z.boolean().optional(), }); export type SessionResponse = z.infer; @@ -208,6 +212,20 @@ export const promptOutput = z.object({ export type PromptOutput = z.infer; +// Side question ("/btw") input/output +export const sideQuestionInput = z.object({ + sessionId: z.string(), + question: z.string().min(1), +}); + +export type SideQuestionInput = z.infer; + +export const sideQuestionOutput = z.object({ + answer: z.string(), +}); + +export type SideQuestionOutput = z.infer; + // Cancel session input export const cancelSessionInput = z.object({ sessionId: z.string(),