diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eabea731f..1550f6b96 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -158,6 +158,8 @@ jobs: packages/ui/hooks/useAnnotationDraft.seam.test.tsx packages/ui/hooks/useExternalAnnotations.seam.test.tsx packages/ui/hooks/useAIChat.seam.test.tsx + packages/ui/hooks/useAIChat.forkOrigin.test.tsx + packages/ui/hooks/useOriginFork.test.tsx packages/ui/hooks/useFileBrowser.seam.test.tsx packages/ui/hooks/usePlanDiff.test.tsx packages/ui/hooks/useLinkedDoc.test.tsx diff --git a/AGENTS.md b/AGENTS.md index 688cc9eb7..7dd0fd729 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -444,6 +444,8 @@ Ask AI providers are detected independently from installed/authenticated local C Automatic resolution is session-only and never writes a preference. Explicit per-origin choices are persisted in cookies, so a user can override the automatic match for one agent without changing the default for another. +> **Origin-session forking (#1519):** only `claude-code` and `opencode` are ever live `originFork` agents — they're the two providers that declare `capabilities.fork: true` (`packages/ai/providers/claude-agent-sdk.ts`, `.../opencode-sdk.ts`; `codex-sdk` and `pi-sdk` both declare `fork: false`). `/api/ai/capabilities`'s `originFork.providerIds` is computed server-side by matching each registered provider's registry **id or type name** against this same table's provider types for `originFork.agent` (`matchesAgentProvider` in `packages/core/agents.ts`, used by `originForkProviderIds` in `packages/ai/endpoints.ts`) — instance ids can be custom, so id-only matching would silently miss one; this is the same rule the client uses to pick a default provider for an origin (`findOriginAIProvider` in `packages/ui/utils/aiProvider.ts`). The client (`useOriginFork` in `packages/ui/hooks/useOriginFork.ts`) shows the "Fork the <agent> session" toggle only when the *effective* provider id (explicit selection if it resolves to a real, current provider, else the server's `defaultProvider` — never array order) is in that list, and posts `forkOrigin: true` on `/api/ai/session` when armed. + > **Codex transport note:** the `codex-sdk` provider id is a stable identifier only — it no longer uses `@openai/codex-sdk` / `codex exec`. It drives a long-lived `codex app-server` process over JSON-RPC (`packages/ai/providers/codex-app-server.ts`), which respects the user's/enterprise-managed approval policy and supports interactive Allow/Deny approvals. The id stays `codex-sdk` to preserve saved cookie preferences, the `agents.ts` mapping, and the UI reasoning-effort gate. > **OpenCode transport note:** the `opencode-sdk` provider spawns its own `opencode serve` per process on an OS-assigned port (`port: 0`) and never attaches to a server it did not spawn (an attached server can't be cleaned up by us, and opencode's per-directory instances accumulate in it without eviction). The spawned server is closed on dispose and on process exit. Model discovery is deferred behind the provider initializer (`?activate=` from the model picker, or the first opencode session) exactly like Codex — nothing spawns at server boot, so the picker lists opencode with an empty model list until first activation. Regression-pinned by `packages/ai/providers/opencode-sdk.test.ts`. @@ -590,8 +592,8 @@ During normal plan review, an Archive sidebar tab provides the same browsing via | `/api/draft` | GET/POST/DELETE | Auto-save annotation drafts to survive server crashes | | `/api/editor-annotations` | GET | List editor annotations (VS Code only) | | `/api/editor-annotation` | POST/DELETE | Add or remove an editor annotation (VS Code only) | -| `/api/ai/capabilities` | GET | Check if AI features are available | -| `/api/ai/session` | POST | Create or fork an AI session | +| `/api/ai/capabilities` | GET | Check if AI features are available. Also reports `originFork: { agent, providerIds } \| null` (#1519): when this launch knows the agent session that invoked it, `agent` names its harness and `providerIds` are the registry ids of providers that can fork it (`capabilities.fork` AND natively own that harness — see "Ask AI Provider Defaults"); `null` when there's no known origin session | +| `/api/ai/session` | POST | Create or fork an AI session (body may set `forkOrigin: true` to fork the session named by `originFork` — for that path the server supplies the actual `ParentSession` itself, so the client never needs to construct one) | | `/api/ai/query` | POST | Send a message and stream the response (SSE) | | `/api/ai/abort` | POST | Abort the current query | | `/api/ai/permission` | POST | Respond to a permission request | @@ -624,8 +626,8 @@ During normal plan review, an Archive sidebar tab provides the same browsing via | `/api/draft` | GET/POST/DELETE | Auto-save annotation drafts to survive server crashes | | `/api/editor-annotations` | GET | List editor annotations (VS Code only) | | `/api/editor-annotation` | POST/DELETE | Add or remove an editor annotation (VS Code only) | -| `/api/ai/capabilities` | GET | Check if AI features are available | -| `/api/ai/session` | POST | Create or fork an AI session | +| `/api/ai/capabilities` | GET | Check if AI features are available. Also reports `originFork: { agent, providerIds } \| null` (#1519): when this launch knows the agent session that invoked it, `agent` names its harness and `providerIds` are the registry ids of providers that can fork it (`capabilities.fork` AND natively own that harness — see "Ask AI Provider Defaults"); `null` when there's no known origin session | +| `/api/ai/session` | POST | Create or fork an AI session (body may set `forkOrigin: true` to fork the session named by `originFork` — for that path the server supplies the actual `ParentSession` itself, so the client never needs to construct one) | | `/api/ai/query` | POST | Send a message and stream the response (SSE) | | `/api/ai/abort` | POST | Abort the current query | | `/api/ai/permission` | POST | Respond to a permission request | @@ -686,8 +688,8 @@ During normal plan review, an Archive sidebar tab provides the same browsing via | `/api/draft` | GET/POST/DELETE | Auto-save annotation drafts to survive server crashes | | `/api/annotate/client-lease` | GET (SSE) | Client lease for local direct structured gates: each open stream is one connected review surface. 404 when the capability is not advertised. | | `/api/agent-terminal/pty/` | WebSocket | Tokenized PTY bridge for the optional annotate-mode agent terminal | -| `/api/ai/capabilities` | GET | Check if AI features are available | -| `/api/ai/session` | POST | Create or fork an AI session | +| `/api/ai/capabilities` | GET | Check if AI features are available. Also reports `originFork: { agent, providerIds } \| null` (#1519): when this launch knows the agent session that invoked it, `agent` names its harness and `providerIds` are the registry ids of providers that can fork it (`capabilities.fork` AND natively own that harness — see "Ask AI Provider Defaults"); `null` when there's no known origin session | +| `/api/ai/session` | POST | Create or fork an AI session (body may set `forkOrigin: true` to fork the session named by `originFork` — for that path the server supplies the actual `ParentSession` itself, so the client never needs to construct one) | | `/api/ai/query` | POST | Send a message and stream the response (SSE) | | `/api/ai/abort` | POST | Abort the current query | | `/api/ai/permission` | POST | Respond to a permission request | diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 6c832847e..293e9d734 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -79,6 +79,8 @@ import { startPlannotatorServer, handleServerReady, + buildOriginSession, + type ParentSession, } from "@plannotator/server"; import { startReviewServer, @@ -343,6 +345,19 @@ const staticFlagIdx = args.indexOf("--static"); const staticFlag = staticFlagIdx !== -1; if (staticFlag) args.splice(staticFlagIdx, 1); +// Value flag: --session-id (annotate only) — lets a caller name the +// invoking agent session explicitly, the same way opencode-annotate-last's +// stdin JSON does. Exists for the OpenCode CLI-bridge fallback leg of +// /plannotator-annotate (buildAnnotateCliArgs), which has no stdin channel: +// it shells out to the plain `annotate` subcommand, so this flag is the only +// way that leg can offer Ask AI fork-origin (#1519). +const sessionIdIdx = args.indexOf("--session-id"); +let sessionIdFlag: string | undefined; +if (sessionIdIdx !== -1 && args[sessionIdIdx + 1]) { + sessionIdFlag = args[sessionIdIdx + 1]; + args.splice(sessionIdIdx, 2); +} + // Stdout matrix for annotate / annotate-last / copilot annotate-last. // // --hook (recommended for hooks): @@ -597,6 +612,49 @@ const detectedOrigin: Origin = process.env.OMPCODE ? "oh-my-pi" : "claude-code"; +/** + * Best-effort identity of the invoking Claude Code session, for surfaces + * launched from inside one (a slash command's `!` bang runs this CLI as a + * descendant of the agent's shell, so the ancestor-PID walk finds it). Lets + * Ask AI offer to fork that session instead of starting fresh (#1519). + * Server-side only — never crosses the wire to the browser. Only + * claude-code and opencode providers can fork a session today, so this is + * only attempted when detectedOrigin is "claude-code"; a miss returns null, + * which just means the toggle isn't offered. + * + * cwd follows the same PLANNOTATOR_CWD idiom used everywhere else in this + * file (the original working directory before a launcher shim `cd`s) rather + * than a bare `process.cwd()` — claude-agent-sdk.ts forks with `cwd: + * parent.cwd`, so a wrong cwd here roots the forked session in the wrong + * directory. + */ +function resolveInvokingClaudeSession(): ParentSession | null { + if (detectedOrigin !== "claude-code") return null; + try { + const logPath = resolveSessionLogByAncestorPids(); + if (!logPath) return null; + return buildOriginSession({ + agent: "claude-code", + sessionId: path.basename(logPath, ".jsonl"), + cwd: process.env.PLANNOTATOR_CWD || process.cwd(), + }); + } catch { + return null; + } +} + +/** + * Build the Ask AI fork-origin ParentSession for an OpenCode bridge call + * (opencode-plan/opencode-review/opencode-annotate-last), from the + * sessionId/directory pair each bridge stdin payload carries. Thin wrapper + * around the shared builder (`buildOriginSession`, packages/server) that + * also backs the OpenCode plugin's own `toOriginSession` — the one place + * the {sessionId, cwd, agent} object actually gets constructed (#1519). + */ +function toOpenCodeOriginSession(input: { sessionId?: unknown; directory?: unknown }): ParentSession | null { + return buildOriginSession({ agent: "opencode", sessionId: input.sessionId, cwd: input.directory }); +} + type OpenCodeBridgeAgent = { name: string; description?: string; @@ -1153,6 +1211,7 @@ if (args[0] === "sessions") { gitRef, error: diffError, origin: detectedOrigin, + originSession: resolveInvokingClaudeSession(), project: reviewProject, diffType: workspace ? (initialDiffType ?? workspace.diffType) : gitContext ? (initialDiffType ?? "unstaged") : undefined, gitContext, @@ -1382,6 +1441,13 @@ if (args[0] === "sessions") { markdown, filePath: absolutePath, origin: detectedOrigin, + // An explicit --session-id (the OpenCode CLI-bridge fallback leg of + // /plannotator-annotate, which has no stdin channel — see the flag's + // definition above) takes priority; otherwise fall back to the + // ancestor-PID resolution used by a direct Claude Code invocation. + originSession: sessionIdFlag + ? buildOriginSession({ agent: detectedOrigin, sessionId: sessionIdFlag, cwd: projectRoot }) + : resolveInvokingClaudeSession(), mode: liveAppResolved ? "annotate-app" : annotateMode, liveApp: liveAppResolved ? { @@ -1480,6 +1546,10 @@ if (args[0] === "sessions") { const RECENT_MESSAGES_LIMIT = 25; let lastMessage: RenderedMessage | null = null; let recentMessages: RenderedMessage[] = []; + // Claude Code path only: the transcript the message was actually read + // from (whichever candidate strategy hit), so its basename (.jsonl) + // can seed Ask AI session forking (#1519). + let claudeSessionLogPath: string | null = null; // Copilot CLI sets no env fingerprint, so detection matches ancestor pids // against session-state inuse locks (spawns ps). Only attempted when no @@ -1601,6 +1671,7 @@ if (args[0] === "sessions") { if (recent.length > 0) { recentMessages = recent; lastMessage = recent[0]; + claudeSessionLogPath = logPath; return; } } @@ -1641,10 +1712,25 @@ if (args[0] === "sessions") { ? recentMessages.map((m) => ({ messageId: m.messageId, text: m.text, timestamp: m.timestamp })) : undefined; + // The session the annotated message came from — lets Ask AI offer to fork + // it (#1519). Only claimed when the harness is genuinely Claude Code and + // the transcript that produced `annotatedMessage` was actually resolved + // (its basename is the session id); no other harness's provider can fork + // today. + const annotateLastOriginSession: ParentSession | null = + detectedOrigin === "claude-code" && claudeSessionLogPath + ? buildOriginSession({ + agent: "claude-code", + sessionId: path.basename(claudeSessionLogPath, ".jsonl"), + cwd: projectRoot, + }) + : null; + const server = await startAnnotateServer({ markdown: annotatedMessage.text, filePath: "last-message", origin: copilotDetected ? "copilot-cli" : detectedOrigin, + originSession: annotateLastOriginSession, mode: "annotate-last", sharingEnabled, shareBaseUrl, @@ -1752,7 +1838,7 @@ if (args[0] === "sessions") { // that cannot import Bun-only server modules directly. const inputJson = await Bun.stdin.text(); - const input = parseOpenCodeBridgeInput<{ plan?: unknown; timeoutSeconds?: unknown }>( + const input = parseOpenCodeBridgeInput<{ plan?: unknown; timeoutSeconds?: unknown; sessionId?: unknown; directory?: unknown }>( "opencode-plan", inputJson, ); @@ -1776,6 +1862,7 @@ if (args[0] === "sessions") { const server = await startPlannotatorServer({ plan: planContent, origin: "opencode", + originSession: toOpenCodeOriginSession(input), sharingEnabled: bridgeSharingEnabled, shareBaseUrl: bridgeShareBaseUrl, pasteApiUrl: bridgePasteApiUrl, @@ -1832,7 +1919,7 @@ if (args[0] === "sessions") { // in a host that cannot import Bun-only server modules directly. const inputJson = await Bun.stdin.text(); - const input = parseOpenCodeBridgeInput<{ arguments?: unknown; supportsApprovalNotes?: unknown }>( + const input = parseOpenCodeBridgeInput<{ arguments?: unknown; supportsApprovalNotes?: unknown; sessionId?: unknown; directory?: unknown }>( "opencode-review", inputJson, ); @@ -1964,6 +2051,7 @@ if (args[0] === "sessions") { gitRef, error: diffError, origin: "opencode", + originSession: toOpenCodeOriginSession(input), project: reviewProject, diffType: isPRMode ? undefined : userDiffType, gitContext, @@ -2031,6 +2119,8 @@ if (args[0] === "sessions") { const input = parseOpenCodeBridgeInput<{ gate?: unknown; recentMessages?: unknown; + sessionId?: unknown; + directory?: unknown; }>("opencode-annotate-last", inputJson); const recentMessages = Array.isArray(input.recentMessages) @@ -2068,6 +2158,7 @@ if (args[0] === "sessions") { markdown: lastMessage.text, filePath: "last-message", origin: "opencode", + originSession: toOpenCodeOriginSession(input), mode: "annotate-last", recentMessages: pickerMessages, sharingEnabled: bridgeSharingEnabled, @@ -2459,10 +2550,21 @@ if (args[0] === "sessions") { const planProject = (await detectProjectName()) ?? "_unknown"; + // The session that produced this plan — lets Ask AI offer to fork it + // (#1519). The hook event names it directly (no ancestor-PID resolution + // needed here), but only Claude Code's session id means anything to a + // provider: Gemini isn't a fork-capable harness, and no other origin + // reaches this hook-event branch with a genuine session id. + const planOriginSession: ParentSession | null = + !isGemini && detectedOrigin === "claude-code" + ? buildOriginSession({ agent: "claude-code", sessionId: event.session_id, cwd: event.cwd }) + : null; + // Start the plan review server const server = await startPlannotatorServer({ plan: planContent, origin: isGemini ? "gemini-cli" : detectedOrigin, + originSession: planOriginSession, permissionMode, sharingEnabled, shareBaseUrl, diff --git a/apps/opencode-plugin/cli-bridge.test.ts b/apps/opencode-plugin/cli-bridge.test.ts index c3d84c4e6..286ad9f4b 100644 --- a/apps/opencode-plugin/cli-bridge.test.ts +++ b/apps/opencode-plugin/cli-bridge.test.ts @@ -75,6 +75,35 @@ describe("OpenCode CLI bridge helpers", () => { ]); }); + test("appends --session-id when the invoking OpenCode session is known (#1519)", () => { + const parsed: Parameters[0] = { + filePath: "plan.html", + rawFilePath: "plan.html", + gate: false, + json: false, + hook: false, + renderHtml: false, + renderMarkdown: false, + noJina: false, + }; + + expect(buildAnnotateCliArgs(parsed, "session-1")).toEqual([ + "annotate", + "plan.html", + "--json", + "--session-id", + "session-1", + ]); + + // No sessionId (embedded/native paths never hit the CLI-bridge fallback + // leg, so most calls omit it) — the flag must not appear at all. + expect(buildAnnotateCliArgs(parsed)).toEqual([ + "annotate", + "plan.html", + "--json", + ]); + }); + test("requires a session before launching a gated capable annotate bridge", () => { expect(canLaunchGatedAnnotate({ gate: true }, undefined)).toBe(false); expect(canLaunchGatedAnnotate({ gate: true }, "session-1")).toBe(true); diff --git a/apps/opencode-plugin/cli-bridge.ts b/apps/opencode-plugin/cli-bridge.ts index 7a4d93cae..94483931a 100644 --- a/apps/opencode-plugin/cli-bridge.ts +++ b/apps/opencode-plugin/cli-bridge.ts @@ -16,6 +16,7 @@ import { deliverOpenCodePrompt, isOpenCodePromptDeliveryError, } from "./prompt-delivery-error"; +import type { ParentSession } from "@plannotator/server"; type LogLevel = "info" | "error"; @@ -469,12 +470,17 @@ async function runPlannotatorCli(options: RunCliOptions): Promise } } -export function buildAnnotateCliArgs(parsed: ParsedAnnotateArgs): string[] { +export function buildAnnotateCliArgs(parsed: ParsedAnnotateArgs, sessionId?: string): string[] { const args = ["annotate", parsed.rawFilePath, "--json"]; if (parsed.gate) args.push("--gate"); if (parsed.renderHtml) args.push("--render-html"); if (parsed.renderMarkdown) args.push("--markdown"); if (parsed.noJina) args.push("--no-jina"); + // Lets Ask AI offer to fork the invoking OpenCode session (#1519): the + // plain `annotate` subcommand has no stdin-JSON channel the way + // opencode-plan/opencode-annotate-last do, so this is the only way this + // CLI-bridge fallback leg of /plannotator-annotate can name it. + if (sessionId) args.push("--session-id", sessionId); return args; } @@ -488,6 +494,8 @@ export function canLaunchGatedAnnotate( export async function runCliPlanReview(input: { client: OpenCodeClient; planContent: string; + /** The OpenCode session that submitted the plan (Ask AI fork origin), already resolved by the caller. */ + originSession: ParentSession | null; cwd?: string; timeoutSeconds: number | null; abortSignal?: AbortSignal; @@ -500,6 +508,8 @@ export async function runCliPlanReview(input: { input: JSON.stringify({ plan: input.planContent, timeoutSeconds: input.timeoutSeconds, + sessionId: input.originSession?.sessionId, + directory: input.originSession?.cwd, ...buildBridgePayload(input.bridge), }), readyLabel: "plan review", @@ -659,6 +669,8 @@ export async function handleCliCommand(input: { cwd, input: JSON.stringify({ arguments: input.rawArgs, + sessionId: input.sessionId, + directory: cwd, // Fail-closed approval-notes handshake (same version-skew reasoning // as formatUserFacingCliStderrLine above: the binary and this plugin // version independently). The advert lives in the binary's review @@ -709,7 +721,7 @@ export async function handleCliCommand(input: { const result = await runPlannotatorCli({ client: input.client, - args: buildAnnotateCliArgs(parsed), + args: buildAnnotateCliArgs(parsed, input.sessionId), cwd, readyLabel: "annotation UI", bridge: input.bridge, @@ -756,6 +768,8 @@ export async function handleCliCommand(input: { input: JSON.stringify({ gate: parsed.gate, recentMessages, + sessionId: input.sessionId, + directory: cwd, ...buildBridgePayload(input.bridge), }), readyLabel: "annotation UI", diff --git a/apps/opencode-plugin/commands.test.ts b/apps/opencode-plugin/commands.test.ts index 172c0de84..65d2b27e1 100644 --- a/apps/opencode-plugin/commands.test.ts +++ b/apps/opencode-plugin/commands.test.ts @@ -118,6 +118,9 @@ describe("handleReviewCommand open state (--base / --diff-type)", () => { // The empty-config default (since-base) is base-relative, so the seed // requests it explicitly rather than promoting. expect(options.diffType).toBe("since-base"); + // Ask AI fork origin (#1519): the invoking OpenCode session, built from + // the command event's sessionID and the review's own directory. + expect(options.originSession).toEqual({ sessionId: "session-123", cwd: repoDir, agent: "opencode" }); }); test("a base that does not resolve refuses to start a session", async () => { @@ -347,6 +350,13 @@ describe("handleAnnotateLastCommand", () => { ); expect(deps.startAnnotateServer.mock.calls[0]?.[0].approvalNotesSupported).toBe(true); + // Ask AI fork origin (#1519): built from the command event's sessionID + // (no `directory` on these deps, so it falls back to process.cwd()). + expect(deps.startAnnotateServer.mock.calls[0]?.[0].originSession).toEqual({ + sessionId: "session-123", + cwd: process.cwd(), + agent: "opencode", + }); expect(outcome).toEqual({ approved: true, feedback: "Retain this caveat.", diff --git a/apps/opencode-plugin/commands.ts b/apps/opencode-plugin/commands.ts index 5952f6f35..87012e131 100644 --- a/apps/opencode-plugin/commands.ts +++ b/apps/opencode-plugin/commands.ts @@ -42,6 +42,7 @@ import { statSync } from "fs"; import path from "path"; import { resolveValidatedTargetAgent } from "./agent-switch"; import { deliverOpenCodePrompt } from "./prompt-delivery-error"; +import { toOriginSession } from "./origin-session"; /** Shared dependencies injected by the plugin */ export interface CommandDeps { @@ -234,6 +235,7 @@ export async function handleReviewCommand( gitRef, error: diffError, origin: "opencode", + originSession: toOriginSession({ sessionId, cwd: directory }), project: (await detectProjectName()) ?? undefined, diffType: isPRMode ? undefined : userDiffType, gitContext, @@ -461,6 +463,7 @@ export async function handleAnnotateCommand( markdown, filePath: absolutePath, origin: "opencode", + originSession: toOriginSession({ sessionId, cwd: directory }), mode: annotateMode, project: annotateProject, folderPath, @@ -530,7 +533,7 @@ export async function handleAnnotateLastCommand( event: any, deps: CommandDeps ): Promise<{ approved: boolean; feedback: string } | null> { - const { client, htmlContent, getSharingEnabled, getShareBaseUrl, getPasteApiUrl } = deps; + const { client, htmlContent, getSharingEnabled, getShareBaseUrl, getPasteApiUrl, directory } = deps; const startServer = deps.startAnnotateServer ?? startAnnotateServer; // @ts-ignore - Event properties contain arguments @@ -584,6 +587,7 @@ export async function handleAnnotateLastCommand( markdown: lastText, filePath: "last-message", origin: "opencode", + originSession: toOriginSession({ sessionId, cwd: directory }), mode: "annotate-last", project: lastProject, recentMessages: pickerMessages, diff --git a/apps/opencode-plugin/embedded.ts b/apps/opencode-plugin/embedded.ts index c82b932bf..49a3c9db8 100644 --- a/apps/opencode-plugin/embedded.ts +++ b/apps/opencode-plugin/embedded.ts @@ -8,10 +8,13 @@ import { getAnnotateMessageFeedbackPrompt, } from "@plannotator/shared/prompts"; import { deliverOpenCodePrompt } from "./prompt-delivery-error"; +import type { ParentSession } from "@plannotator/server"; export interface EmbeddedPlanReviewInput { client: any; planContent: string; + /** The OpenCode session that submitted the plan (Ask AI fork origin), already resolved by the caller. */ + originSession: ParentSession | null; sharingEnabled: boolean; shareBaseUrl?: string; pasteApiUrl?: string; @@ -74,6 +77,7 @@ export async function runEmbeddedPlanReview( const server = await startPlannotatorServer({ plan: input.planContent, origin: "opencode", + originSession: input.originSession, sharingEnabled: input.sharingEnabled, shareBaseUrl: input.shareBaseUrl, pasteApiUrl: input.pasteApiUrl, diff --git a/apps/opencode-plugin/index.ts b/apps/opencode-plugin/index.ts index 95b5205ff..5ef9fa5c1 100644 --- a/apps/opencode-plugin/index.ts +++ b/apps/opencode-plugin/index.ts @@ -49,6 +49,7 @@ import { resolveValidatedTargetAgent } from "./agent-switch"; import { shouldFallbackAfterEmbeddedError } from "./prompt-delivery-error"; import { executeSubmitPlan } from "./submit-plan-executor"; import { getPlanningPrompt } from "./planning-prompt"; +import { toOriginSession } from "./origin-session"; // Lazy-load HTML at first use instead of embedding in the bundle. // The two SPA files are ~20 MB combined — inlining them as string literals @@ -191,6 +192,8 @@ async function runPlanReview(input: { client: any; runtime: RuntimeMode; planContent: string; + /** The OpenCode session that submitted the plan (Ask AI fork origin). */ + sessionId?: string; sharingEnabled: boolean; shareBaseUrl?: string; pasteApiUrl?: string; @@ -205,12 +208,17 @@ async function runPlanReview(input: { throw new Error(getEmbeddedRuntimeError()); } + // Joined once, here, rather than threading sessionId/cwd separately + // through the embedded and CLI runners below (#1519). + const originSession = toOriginSession({ sessionId: input.sessionId, cwd: input.cwd }); + if (shouldUseEmbeddedRuntime(input.runtime)) { try { const embedded = await importEmbeddedRuntime(); return await embedded.runEmbeddedPlanReview({ client: input.client, planContent: input.planContent, + originSession, sharingEnabled: input.sharingEnabled, shareBaseUrl: input.shareBaseUrl, pasteApiUrl: input.pasteApiUrl, @@ -234,6 +242,7 @@ async function runPlanReview(input: { return await runCliPlanReview({ client: input.client, planContent: input.planContent, + originSession, cwd: input.cwd, timeoutSeconds: input.timeoutSeconds, abortSignal: input.abortSignal, @@ -559,10 +568,11 @@ Do NOT proceed with implementation until your plan is approved.`; directory: ctx.directory, workflowOptions, }, { - reviewPlan: async ({ planContent }) => await runPlanReview({ + reviewPlan: async ({ planContent, sessionId }) => await runPlanReview({ client: ctx.client, runtime: workflowOptions.runtime, planContent, + sessionId, sharingEnabled: await getSharingEnabled(), shareBaseUrl: getShareBaseUrl(), pasteApiUrl: getPasteApiUrl(), diff --git a/apps/opencode-plugin/origin-session.test.ts b/apps/opencode-plugin/origin-session.test.ts new file mode 100644 index 000000000..02f1b5366 --- /dev/null +++ b/apps/opencode-plugin/origin-session.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test"; +import { toOriginSession } from "./origin-session"; + +describe("toOriginSession", () => { + test("builds an opencode ParentSession when sessionId is present", () => { + expect(toOriginSession({ sessionId: "session-1", cwd: "/workspace/example" })).toEqual({ + sessionId: "session-1", + cwd: "/workspace/example", + agent: "opencode", + }); + }); + + test("falls back to process.cwd() when cwd is omitted", () => { + expect(toOriginSession({ sessionId: "session-1" })).toEqual({ + sessionId: "session-1", + cwd: process.cwd(), + agent: "opencode", + }); + }); + + test("returns null without a sessionId", () => { + expect(toOriginSession({})).toBeNull(); + expect(toOriginSession({ cwd: "/workspace/example" })).toBeNull(); + expect(toOriginSession({ sessionId: "" })).toBeNull(); + }); +}); diff --git a/apps/opencode-plugin/origin-session.ts b/apps/opencode-plugin/origin-session.ts new file mode 100644 index 000000000..4ee858a55 --- /dev/null +++ b/apps/opencode-plugin/origin-session.ts @@ -0,0 +1,24 @@ +// Imports the `./origin-session` subpath, not the `@plannotator/server` +// package root — this file is bundled into index.ts/server.ts's --target +// node build, which must not pull in the root barrel's Bun-only modules +// (browser.ts, repo.ts, project.ts, integrations.ts all `import ... from +// "bun"`). The origin-session submodule itself has no such imports. +import { buildOriginSession } from "@plannotator/server/origin-session"; +import type { ParentSession } from "@plannotator/ai"; + +/** + * Build the Ask AI fork-origin ParentSession for an OpenCode-invoked + * surface, from the (sessionId, cwd) pair every entry point already has. + * + * Thin OpenCode-specific wrapper around the shared builder + * (`buildOriginSession`, packages/server/origin-session.ts) — that's the one + * place the {sessionId, cwd, agent} object actually gets constructed; + * apps/hook/server/index.ts's Claude Code and OpenCode-bridge sites use the + * same function (#1519). + */ +export function toOriginSession(input: { + sessionId?: string; + cwd?: string; +}): ParentSession | null { + return buildOriginSession({ agent: "opencode", sessionId: input.sessionId, cwd: input.cwd }); +} diff --git a/apps/opencode-plugin/server.ts b/apps/opencode-plugin/server.ts index 3ee1912b4..82bc11454 100644 --- a/apps/opencode-plugin/server.ts +++ b/apps/opencode-plugin/server.ts @@ -24,6 +24,7 @@ import { } from "./cli-bridge"; import { switchV2SessionAgent } from "./agent-switch"; import { registerNativeCommands } from "./native-commands"; +import { toOriginSession } from "./origin-session"; import { createV2BridgeClient, formatSessionUrlNotice, @@ -224,10 +225,11 @@ const serverPlugin = { directory, workflowOptions, }, { - reviewPlan: async ({ planContent }) => await runPlanReview({ + reviewPlan: async ({ planContent, sessionId }) => await runPlanReview({ client, runtime: workflowOptions.runtime, planContent, + sessionId, sharingEnabled: bridge.sharingEnabled ?? true, shareBaseUrl: bridge.shareBaseUrl, pasteApiUrl: bridge.pasteApiUrl, @@ -351,6 +353,8 @@ async function runPlanReview(input: { client: V2Client; runtime: RuntimeMode; planContent: string; + /** The OpenCode session that submitted the plan (Ask AI fork origin). */ + sessionId?: string; sharingEnabled: boolean; shareBaseUrl?: string; pasteApiUrl?: string; @@ -363,12 +367,17 @@ async function runPlanReview(input: { throw new Error('runtime "embedded" requires a Bun-hosted OpenCode plugin runtime. Use runtime "auto" or "cli" with this OpenCode host.'); } + // Joined once, here, rather than threading sessionId/directory separately + // through the embedded and CLI runners below (#1519). + const originSession = toOriginSession({ sessionId: input.sessionId, cwd: input.directory }); + if (input.runtime !== "cli" && hasEmbeddedRuntime()) { try { const embedded = await importEmbeddedRuntime(); return await embedded.runEmbeddedPlanReview({ client: input.client, planContent: input.planContent, + originSession, sharingEnabled: input.sharingEnabled, shareBaseUrl: input.shareBaseUrl, pasteApiUrl: input.pasteApiUrl, @@ -386,6 +395,7 @@ async function runPlanReview(input: { return await runCliPlanReview({ client: input.client, planContent: input.planContent, + originSession, cwd: input.directory, timeoutSeconds: input.timeoutSeconds, abortSignal: input.abortSignal, diff --git a/apps/opencode-plugin/submit-plan-executor.test.ts b/apps/opencode-plugin/submit-plan-executor.test.ts index db2a30f4f..bd82d9fdf 100644 --- a/apps/opencode-plugin/submit-plan-executor.test.ts +++ b/apps/opencode-plugin/submit-plan-executor.test.ts @@ -47,6 +47,7 @@ describe("executeSubmitPlan", () => { expect(reviewPlan).toHaveBeenCalledWith({ planContent: "# Plan\n\nShip it", abortSignal: undefined, + sessionId: "session-1", }); }); diff --git a/apps/opencode-plugin/submit-plan-executor.ts b/apps/opencode-plugin/submit-plan-executor.ts index cfac109f8..b210e6536 100644 --- a/apps/opencode-plugin/submit-plan-executor.ts +++ b/apps/opencode-plugin/submit-plan-executor.ts @@ -42,6 +42,9 @@ export interface SubmitPlanHost { reviewPlan(input: { planContent: string; abortSignal?: AbortSignal; + /** The OpenCode session that submitted the plan — lets the review's + * Ask AI offer to fork it for full conversation history (#1519). */ + sessionId: string; }): Promise; resolveTargetAgent(input: { requestedAgent?: string; @@ -102,6 +105,7 @@ Use /plannotator-last or /plannotator-annotate for manual review, or set workflo reviewResult = await host.reviewPlan({ planContent, abortSignal: invocation.abortSignal, + sessionId: invocation.sessionId, }); } catch (error) { invocation.abortSignal?.throwIfAborted(); diff --git a/apps/pi-extension/vendor.sh b/apps/pi-extension/vendor.sh index 347a446e3..26a313399 100755 --- a/apps/pi-extension/vendor.sh +++ b/apps/pi-extension/vendor.sh @@ -113,14 +113,20 @@ done printf '// @generated — DO NOT EDIT. Source: packages/ui/components/html-viewer/bridge-script.ts\n' \ | cat - "../../packages/ui/components/html-viewer/bridge-script.ts" > "generated/bridge-script.ts" -# Vendor the moved AI context types from core into generated/ai/. +# Vendor the moved AI context types from core into generated/ai/. ai-context.ts +# types ParentSession.agent as an Origin (packages/core/agents.ts, same +# package) — vendor that alongside it so the relative import resolves; the +# generic ./x -> ./x.ts normalization pass below handles the specifier itself. printf '// @generated — DO NOT EDIT. Source: packages/core/ai-context.ts\n' \ | cat - "../../packages/core/ai-context.ts" > "generated/ai/ai-context.ts" +printf '// @generated — DO NOT EDIT. Source: packages/core/agents.ts\n' \ + | cat - "../../packages/core/agents.ts" > "generated/ai/agents.ts" for f in index types provider session-manager endpoints context base-session; do src="../../packages/ai/$f.ts" printf '// @generated — DO NOT EDIT. Source: packages/ai/%s.ts\n' "$f" | cat - "$src" \ | sed "s|from ['\"]@plannotator/core/ai-context['\"]|from './ai-context.ts'|g" \ + | sed "s|from ['\"]@plannotator/core/agents['\"]|from './agents.ts'|g" \ > "generated/ai/$f.ts" done diff --git a/packages/ai/ai.test.ts b/packages/ai/ai.test.ts index e878307d8..52bb940e1 100644 --- a/packages/ai/ai.test.ts +++ b/packages/ai/ai.test.ts @@ -795,6 +795,134 @@ describe("AI endpoints", () => { expect(createRes.status).toBe(200); }); + test("forks the server-known origin session when forkOrigin: true", async () => { + const reg = new ProviderRegistry(); + const sm = new SessionManager(); + reg.register(mockProvider("claude-agent-sdk")); + const endpoints = createAIEndpoints({ + registry: reg, + sessionManager: sm, + originSession: { sessionId: "origin-123", cwd: "/tmp/project", agent: "claude-code" }, + }); + + const res = await endpoints["/api/ai/session"]( + new Request("http://localhost/api/ai/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + context: { mode: "plan-review", plan: { plan: "# Test" } }, + forkOrigin: true, + }), + }) + ); + expect(res.status).toBe(200); + const data = (await res.json()) as { sessionId: string; parentSessionId: string | null }; + expect(data.sessionId).toMatch(/^forked-/); + expect(data.parentSessionId).toBe("origin-123"); + }); + + test("does not fork without forkOrigin, even when deps.originSession is set", async () => { + const reg = new ProviderRegistry(); + const sm = new SessionManager(); + reg.register(mockProvider("claude-agent-sdk")); + const endpoints = createAIEndpoints({ + registry: reg, + sessionManager: sm, + originSession: { sessionId: "origin-123", cwd: "/tmp/project", agent: "claude-code" }, + }); + + const res = await endpoints["/api/ai/session"]( + new Request("http://localhost/api/ai/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + context: { mode: "plan-review", plan: { plan: "# Test" } }, + }), + }) + ); + const data = (await res.json()) as { sessionId: string; parentSessionId: string | null }; + expect(data.sessionId).toMatch(/^session-/); + expect(data.parentSessionId).toBeNull(); + }); + + test("does not fork when forkOrigin is true but the resolved provider can't fork", async () => { + const reg = new ProviderRegistry(); + const sm = new SessionManager(); + reg.register({ ...mockProvider("codex-sdk"), capabilities: { fork: false, resume: true, streaming: true, tools: true } }); + const endpoints = createAIEndpoints({ + registry: reg, + sessionManager: sm, + originSession: { sessionId: "origin-123", cwd: "/tmp/project", agent: "claude-code" }, + }); + + const res = await endpoints["/api/ai/session"]( + new Request("http://localhost/api/ai/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + context: { mode: "plan-review", plan: { plan: "# Test" } }, + forkOrigin: true, + }), + }) + ); + const data = (await res.json()) as { sessionId: string; parentSessionId: string | null }; + expect(data.sessionId).toMatch(/^session-/); + expect(data.parentSessionId).toBeNull(); + }); + + test("capabilities reports originFork with the registry ids that can fork it", async () => { + const reg = new ProviderRegistry(); + const sm = new SessionManager(); + // Registered under its natural id (agent-runtime.ts never passes a custom + // instanceId) — matches production. + reg.register(mockProvider("claude-agent-sdk")); + reg.register({ ...mockProvider("codex-sdk"), capabilities: { fork: false, resume: true, streaming: true, tools: true } }); + const endpoints = createAIEndpoints({ + registry: reg, + sessionManager: sm, + originSession: { sessionId: "origin-123", cwd: "/tmp/project", agent: "claude-code" }, + }); + + const res = await endpoints["/api/ai/capabilities"]( + new Request("http://localhost/api/ai/capabilities") + ); + const data = await res.json(); + expect(data.originFork).toEqual({ agent: "claude-code", providerIds: ["claude-agent-sdk"] }); + }); + + test("capabilities matches a provider registered under a custom instance id, by provider type name", async () => { + // A registry id can be custom (a caller-chosen instanceId rather than + // the provider's own name) — originForkProviderIds must still find it by + // matching provider.name against the origin's provider types, the same + // id-or-name rule findOriginAIProvider uses client-side. Id-only + // matching (the pre-fix behavior) would silently drop this provider. + const reg = new ProviderRegistry(); + const sm = new SessionManager(); + reg.register(mockProvider("claude-agent-sdk"), "my-custom-claude-instance"); + const endpoints = createAIEndpoints({ + registry: reg, + sessionManager: sm, + originSession: { sessionId: "origin-123", cwd: "/tmp/project", agent: "claude-code" }, + }); + + const res = await endpoints["/api/ai/capabilities"]( + new Request("http://localhost/api/ai/capabilities") + ); + const data = await res.json(); + expect(data.originFork).toEqual({ agent: "claude-code", providerIds: ["my-custom-claude-instance"] }); + }); + + test("capabilities reports originFork: null without a known origin session", async () => { + const { reg, endpoints } = setup(); + reg.register(mockProvider("claude-agent-sdk")); + + const res = await endpoints["/api/ai/capabilities"]( + new Request("http://localhost/api/ai/capabilities") + ); + const data = await res.json(); + expect(data.originFork).toBeNull(); + }); + test("session creation activates only the resolved provider before createSession", async () => { const reg = new ProviderRegistry(); const sm = new SessionManager(); diff --git a/packages/ai/endpoints.ts b/packages/ai/endpoints.ts index e944047a1..36e7297fd 100644 --- a/packages/ai/endpoints.ts +++ b/packages/ai/endpoints.ts @@ -13,9 +13,10 @@ * GET /api/ai/capabilities — Check if AI features are available */ -import type { AIContext, AIMessage, CreateSessionOptions } from "./types.ts"; +import type { AIContext, AIMessage, CreateSessionOptions, ParentSession } from "./types.ts"; import type { ProviderRegistry } from "./provider.ts"; import type { SessionManager } from "./session-manager.ts"; +import { matchesAgentProvider, type Origin } from "@plannotator/core/agents"; /** Canonical paths handled by the shared AI endpoint runtime. */ export const AI_ENDPOINT_PATHS = [ @@ -54,6 +55,13 @@ export interface CreateSessionRequest { maxBudgetUsd?: number; /** Reasoning effort (Codex only). */ reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh"; + /** + * Fork the server-known origin session (the agent session that invoked + * this Plannotator surface — see `AIEndpointDeps.originSession`) instead of + * starting fresh. The client never sends a `ParentSession` itself; this + * boolean is the only origin-fork signal that crosses the wire (#1519). + */ + forkOrigin?: boolean; } export interface QueryRequest { @@ -85,6 +93,31 @@ export interface AIEndpointDeps { beforeCapabilities?: () => Promise | void; /** Optional hook to finish provider-specific lazy initialization before creating a session. */ beforeProviderSession?: (providerId: string) => Promise | void; + /** + * The agent session that invoked this Plannotator surface, when known + * (resolved server-side at launch — see `createAIRuntime`). Never sent by + * the client; a `forkOrigin: true` session request forks this instead + * (#1519). Absent/null when this surface didn't come from a live agent + * session, or the harness can't be resolved. + */ + originSession?: ParentSession | null; +} + +/** + * Registry ids of providers that can fork `agent`'s session: they declare + * `capabilities.fork` AND are the provider that natively owns that harness + * (`matchesAgentProvider`, packages/core/agents.ts — see the "Ask AI + * Provider Defaults" table in AGENTS.md). Instance ids can be custom, so + * this matches on registry id OR provider type name, the same rule the + * client uses to pick a default provider for an origin (`findOriginAIProvider` + * in packages/ui/utils/aiProvider.ts) — id-only matching would silently miss + * a custom instance id. Computed once per capabilities response. + */ +function originForkProviderIds(agent: Origin, registry: ProviderRegistry): string[] { + return registry.list().filter((id) => { + const provider = registry.get(id); + return !!provider?.capabilities.fork && matchesAgentProvider(agent, id, provider.name); + }); } const MAX_CLIENT_MAX_TURNS = 99; @@ -131,6 +164,7 @@ export function createAIEndpoints(deps: AIEndpointDeps) { getCwd, beforeCapabilities, beforeProviderSession, + originSession, } = deps; return { @@ -157,10 +191,17 @@ export function createAIEndpoints(deps: AIEndpointDeps) { models: p.models ?? [], }; }); + // Origin-fork availability (#1519): advertised only when this launch + // knows its invoking agent session AND that harness is one of the two + // origins any provider can actually fork today. + const originFork = originSession?.agent + ? { agent: originSession.agent, providerIds: originForkProviderIds(originSession.agent, registry) } + : null; return Response.json({ available: !!defaultEntry, providers: providerDetails, defaultProvider: defaultEntry?.id ?? null, + originFork, }); }, @@ -170,15 +211,22 @@ export function createAIEndpoints(deps: AIEndpointDeps) { } const body = (await req.json()) as CreateSessionRequest; - const { context, providerId, model, maxTurns, maxBudgetUsd, reasoningEffort } = body; + const { providerId, model, maxTurns, maxBudgetUsd, reasoningEffort, forkOrigin } = body; - if (!context?.mode) { + if (!body.context?.mode) { return Response.json( { error: "Missing context.mode" }, { status: 400 } ); } + // The client only ever sends the `forkOrigin` boolean — the actual + // ParentSession lives server-side (see AIEndpointDeps.originSession) + // and is never round-tripped through the browser (#1519). + const context: AIContext = forkOrigin && originSession + ? { ...body.context, parent: originSession } + : body.context; + // Resolve provider: by ID, or default const providerEntry = providerId ? { id: providerId, provider: registry.get(providerId) } diff --git a/packages/core/agents.ts b/packages/core/agents.ts index 98d471f99..c3c481de3 100644 --- a/packages/core/agents.ts +++ b/packages/core/agents.ts @@ -52,3 +52,22 @@ export function getAgentAIProviderTypes(origin: Origin | null | undefined): read } return []; } + +/** + * Whether a registered AI provider naturally matches an origin's harness. + * Provider registry instance IDs can be custom (a caller may register with + * its own `instanceId`), so both the registry id and the provider type name + * are checked against `getAgentAIProviderTypes(origin)` — id-only matching + * would silently miss a custom instance id. The one shared rule behind + * `findOriginAIProvider` (packages/ui/utils/aiProvider.ts, client-side + * default-provider matching) and `originForkProviderIds` + * (packages/ai/endpoints.ts, server-side origin-fork gating, #1519). + */ +export function matchesAgentProvider( + origin: Origin | null | undefined, + providerId: string, + providerName: string, +): boolean { + const providerTypes = getAgentAIProviderTypes(origin); + return providerTypes.includes(providerId) || providerTypes.includes(providerName); +} diff --git a/packages/core/ai-context.ts b/packages/core/ai-context.ts index a3130f72f..fef868b92 100644 --- a/packages/core/ai-context.ts +++ b/packages/core/ai-context.ts @@ -1,3 +1,5 @@ +import type { Origin } from "./agents"; + /** The surface the user is interacting with when they invoke AI. */ export type AIContextMode = "plan-review" | "code-review" | "annotate"; @@ -10,6 +12,12 @@ export interface ParentSession { sessionId: string; /** Working directory the parent session was running in. */ cwd: string; + /** + * Harness that owns the parent session. Lets a caller tell whether a given + * AI provider can actually fork this session — a Claude Code session ID is + * meaningless to the OpenCode provider and vice versa. + */ + agent?: Origin; } /** diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index a34ae55f7..a2f0d2626 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -54,6 +54,7 @@ import { getPlanSaveSettings } from '@plannotator/ui/utils/planSave'; import { type AIProviderOption } from '@plannotator/ui/utils/aiProvider'; import { useAIProviderConfig } from '@plannotator/ui/hooks/useAIProviderConfig'; import { useAIProviderActivation } from '@plannotator/ui/hooks/useAIProviderActivation'; +import { useOriginFork, type OriginForkCapability } from '@plannotator/ui/hooks/useOriginFork'; import { markLookAndFeelChoiceResolved, needsLookAndFeelAnnouncement } from '@plannotator/ui/utils/lookAndFeelAnnouncement'; import { TerminalToolsAnnouncementDialog } from '@plannotator/ui/components/TerminalToolsAnnouncementDialog'; import { @@ -676,6 +677,9 @@ const App: React.FC = () => { const [aiAvailable, setAiAvailable] = useState(false); const [aiProviders, setAiProviders] = useState; models?: Array<{ id: string; label: string; default?: boolean }> }>>([]); const [aiDefaultProvider, setAiDefaultProvider] = useState(null); + // Origin-fork availability (#1519), reported by /api/ai/capabilities — + // null when this document/plan didn't come from a live agent session. + const [originForkCapability, setOriginForkCapability] = useState(null); const { aiConfig, applyConfigChange } = useAIProviderConfig({ providers: aiProviders, defaultProvider: aiDefaultProvider, @@ -3484,6 +3488,7 @@ const App: React.FC = () => { if (!aiSessionEnabled || !isApiMode || isSharedSession) { setAiAvailable(false); setAiProviders([]); + setOriginForkCapability(null); return; } @@ -3499,15 +3504,18 @@ const App: React.FC = () => { // Provider/model is resolved by useAIProviderConfig's effect once these // states land — just record the server default for it to use. setAiDefaultProvider(data.defaultProvider ?? null); + setOriginForkCapability(data.originFork ?? null); } else { setAiAvailable(false); setAiProviders([]); + setOriginForkCapability(null); } }) .catch(() => { if (!cancelled) { setAiAvailable(false); setAiProviders([]); + setOriginForkCapability(null); } }); @@ -4744,12 +4752,22 @@ const App: React.FC = () => { versionInfo, ]); + // Ask AI fork origin (#1519): gates the "Fork the session" toggle + // against the effective provider, and derives forkOrigin from that gate so + // switching away from a forking provider clears it for free. + const originFork = useOriginFork({ + originFork: originForkCapability, + providers: aiProviders, + providerId: aiConfig.providerId, + defaultProviderId: aiDefaultProvider, + }); const aiChat = useAIChat({ context: aiContext, providerId: aiConfig.providerId, model: aiConfig.model, reasoningEffort: aiConfig.reasoningEffort, threadTitle: aiDocumentMode ? 'Document chat' : 'Plan chat', + forkOrigin: originFork.forkOrigin, }); const { messages: aiMessages, @@ -4763,6 +4781,12 @@ const App: React.FC = () => { resetThread: resetAIThread, sessionId: aiSessionId, } = aiChat; + // Fork opt-in changes what kind of session the next question creates — + // same reset discipline as a provider switch (handleAIConfigChange below). + const handleForkOriginToggle = useCallback((enabled: boolean) => { + originFork.toggleProps.onToggle(enabled); + resetAISession(); + }, [originFork.toggleProps, resetAISession]); const canUseAI = aiAvailable && aiContext !== null; const canUseAskAI = canUseAI || isAgentTerminalReady; const canUseDocumentAskAI = canUseAskAI; @@ -6095,6 +6119,13 @@ const App: React.FC = () => { aiProviders={visibleAIProviders} aiConfig={visibleAIConfig} onAIConfigChange={isAgentTerminalReady ? undefined : handleAIConfigChange} + originFork={{ + ...originFork.toggleProps, + // The agent-terminal surface is the origin agent's own TUI — forking + // it into a provider chat isn't meaningful there. + available: originFork.toggleProps.available && !isAgentTerminalReady, + onToggle: handleForkOriginToggle, + }} /> ); diff --git a/packages/review-editor/App.tsx b/packages/review-editor/App.tsx index a343e8c07..a7a6ac6e6 100644 --- a/packages/review-editor/App.tsx +++ b/packages/review-editor/App.tsx @@ -39,6 +39,7 @@ import { loadDiffFont } from '@plannotator/ui/utils/diffFonts'; import { getAgentSwitchSettings, getEffectiveAgentName } from '@plannotator/ui/utils/agentSwitch'; import { useAIProviderConfig } from '@plannotator/ui/hooks/useAIProviderConfig'; import { useAIProviderActivation } from '@plannotator/ui/hooks/useAIProviderActivation'; +import { useOriginFork, type OriginForkCapability } from '@plannotator/ui/hooks/useOriginFork'; import { LookAndFeelAnnouncementDialog } from '@plannotator/ui/components/LookAndFeelAnnouncementDialog'; import { markLookAndFeelChoiceResolved, needsLookAndFeelAnnouncement } from '@plannotator/ui/utils/lookAndFeelAnnouncement'; import { TerminalToolsAnnouncementDialog } from '@plannotator/ui/components/TerminalToolsAnnouncementDialog'; @@ -1000,6 +1001,9 @@ const ReviewApp: React.FC = () => { const [aiAvailable, setAiAvailable] = useState(false); const [aiProviders, setAiProviders] = useState; models?: Array<{ id: string; label: string; default?: boolean }> }>>([]); const [aiDefaultProvider, setAiDefaultProvider] = useState(null); + // Origin-fork availability (#1519), reported by /api/ai/capabilities — + // null when this review didn't come from a live agent session. + const [originForkCapability, setOriginForkCapability] = useState(null); const { aiConfig, applyConfigChange } = useAIProviderConfig({ providers: aiProviders, defaultProvider: aiDefaultProvider, @@ -1107,6 +1111,15 @@ const ReviewApp: React.FC = () => { markTerminalToolsAnnouncementSeen(); setTerminalToolsIntroPending(false); }, []); + // Ask AI fork origin (#1519): gates the "Fork the session" toggle + // against the effective provider, and derives forkOrigin from that gate so + // switching away from a forking provider clears it for free. + const originFork = useOriginFork({ + originFork: originForkCapability, + providers: aiProviders, + providerId: aiConfig.providerId, + defaultProviderId: aiDefaultProvider, + }); const aiChat = useAIChat({ patch: diffData?.rawPatch ?? '', diffType, @@ -1119,6 +1132,7 @@ const ReviewApp: React.FC = () => { providerId: aiConfig.providerId, model: aiConfig.model, reasoningEffort: aiConfig.reasoningEffort, + forkOrigin: originFork.forkOrigin, }); const { messages: aiMessages, @@ -1131,6 +1145,12 @@ const ReviewApp: React.FC = () => { resetSession: resetAISession, sessionId: aiSessionId, } = aiChat; + // Fork opt-in changes what kind of session the next question creates — + // same reset discipline as a provider switch (handleAIConfigChange below). + const handleForkOriginToggle = useCallback((enabled: boolean) => { + originFork.toggleProps.onToggle(enabled); + resetAISession(); + }, [originFork.toggleProps, resetAISession]); const codeNav = useCodeNav(); // The other half of the held-modifier gesture. The diff views paint @@ -1218,6 +1238,7 @@ const ReviewApp: React.FC = () => { setAiAvailable(false); setAiProviders([]); setAiDefaultProvider(null); + setOriginForkCapability(null); return; } fetch('/api/ai/capabilities') @@ -1228,6 +1249,7 @@ const ReviewApp: React.FC = () => { const providers = data.providers ?? []; setAiProviders(providers); setAiDefaultProvider(data.defaultProvider ?? null); + setOriginForkCapability(data.originFork ?? null); } }) .catch(() => {}); @@ -5392,6 +5414,7 @@ const ReviewApp: React.FC = () => { aiConfig={aiConfig} onAIConfigChange={handleAIConfigChange} hasAISession={!!aiSessionId} + originFork={{ ...originFork.toggleProps, onToggle: handleForkOriginToggle }} agentJobs={agentJobs.jobs} agentCapabilities={agentJobs.capabilities} onAgentLaunch={agentJobs.launchJob} diff --git a/packages/review-editor/components/AIConfigBar.tsx b/packages/review-editor/components/AIConfigBar.tsx index 250683d06..368727124 100644 --- a/packages/review-editor/components/AIConfigBar.tsx +++ b/packages/review-editor/components/AIConfigBar.tsx @@ -2,6 +2,8 @@ import type React from 'react'; import { useState, useEffect, useRef } from 'react'; import { getProviderMeta } from '@plannotator/ui/components/ProviderIcons'; import { type AIProviderOption } from '@plannotator/ui/utils/aiProvider'; +import { OriginForkToggle } from '@plannotator/ui/components/ai/OriginForkToggle'; +import type { OriginForkToggleProps } from '@plannotator/ui/hooks/useOriginFork'; interface AIConfigBarProps { providers: AIProviderOption[]; @@ -12,6 +14,8 @@ interface AIConfigBarProps { onModelChange: (model: string) => void; onReasoningEffortChange: (effort: string | null) => void; hasSession: boolean; + /** Opt-in origin-session forking (#1519) — see `useOriginFork`. */ + originFork?: OriginForkToggleProps; } export const AIConfigBar: React.FC = ({ @@ -23,6 +27,7 @@ export const AIConfigBar: React.FC = ({ onModelChange, onReasoningEffortChange, hasSession, + originFork, }) => { const [showSessionNote, setShowSessionNote] = useState(false); const [openMenu, setOpenMenu] = useState<'provider' | 'model' | 'effort' | null>(null); @@ -100,141 +105,41 @@ export const AIConfigBar: React.FC = ({ ); return ( -
- {/* Provider selector */} - {providers.length > 1 ? ( -
- - - {openMenu === 'provider' && ( -
- {providers.map(p => { - const m = getProviderMeta(p.name); - const ProvIcon = m.icon; - const isActive = p.id === effectiveProviderId; - return ( - - ); - })} -
- )} +
+ {originFork?.available && ( +
+
- ) : ( - - - {meta.label} - )} - - {/* Model selector */} - {models.length > 1 ? ( - <> - · +
+ {/* Provider selector */} + {providers.length > 1 ? (
- {openMenu === 'model' && ( + {openMenu === 'provider' && (
- {models.length > 8 && ( -
- setModelSearch(e.target.value)} - autoFocus - /> -
- )} -
8 ? 'ai-config-menu-scroll' : ''}> - {models - .filter(m => !modelSearch || m.label.toLowerCase().includes(modelSearch.toLowerCase())) - .map(m => { - const isActive = m.id === effectiveModel; - return ( - - ); - })} -
-
- )} -
- - ) : currentModelLabel ? ( - <> - · - {currentModelLabel} - - ) : null} - - {/* Reasoning effort — shown when the selected model reports supported efforts */} - {reasoningEfforts.length > 0 && ( - <> - · -
- - - {openMenu === 'effort' && ( -
- {reasoningEfforts.map(e => { - const isActive = e.id === activeEffort; + {providers.map(p => { + const m = getProviderMeta(p.name); + const ProvIcon = m.icon; + const isActive = p.id === effectiveProviderId; return (
)}
- - )} + ) : ( + + + {meta.label} + + )} - {/* Spacer */} -
+ {/* Model selector */} + {models.length > 1 ? ( + <> + · +
+ - {/* Session reset note */} - {showSessionNote && ( - New chat session - )} + {openMenu === 'model' && ( +
+ {models.length > 8 && ( +
+ setModelSearch(e.target.value)} + autoFocus + /> +
+ )} +
8 ? 'ai-config-menu-scroll' : ''}> + {models + .filter(m => !modelSearch || m.label.toLowerCase().includes(modelSearch.toLowerCase())) + .map(m => { + const isActive = m.id === effectiveModel; + return ( + + ); + })} +
+
+ )} +
+ + ) : currentModelLabel ? ( + <> + · + {currentModelLabel} + + ) : null} + + {/* Reasoning effort — shown when the selected model reports supported efforts */} + {reasoningEfforts.length > 0 && ( + <> + · +
+ + + {openMenu === 'effort' && ( +
+ {reasoningEfforts.map(e => { + const isActive = e.id === activeEffort; + return ( + + ); + })} +
+ )} +
+ + )} + + {/* Spacer */} +
+ + {/* Session reset note */} + {showSessionNote && ( + New chat session + )} +
); }; diff --git a/packages/review-editor/components/AITab.tsx b/packages/review-editor/components/AITab.tsx index c2ff795ab..088abbf14 100644 --- a/packages/review-editor/components/AITab.tsx +++ b/packages/review-editor/components/AITab.tsx @@ -12,6 +12,7 @@ import { AIConfigBar } from './AIConfigBar'; import { submitHint } from '@plannotator/ui/utils/platform'; import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea'; import type { AIProviderOption } from '@plannotator/ui/utils/aiProvider'; +import type { OriginForkToggleProps } from '@plannotator/ui/hooks/useOriginFork'; interface AITabProps { messages: AIChatEntry[]; @@ -29,6 +30,8 @@ interface AITabProps { aiConfig?: { providerId: string | null; model: string | null; reasoningEffort?: string | null }; onAIConfigChange?: (config: { providerId?: string | null; model?: string | null; reasoningEffort?: string | null }) => void; hasAISession?: boolean; + /** Opt-in origin-session forking (#1519) — forwarded to AIConfigBar. */ + originFork?: OriginForkToggleProps; } interface FileGroup { @@ -57,6 +60,7 @@ export const AITab: React.FC = ({ aiConfig, onAIConfigChange, hasAISession = false, + originFork, }) => { const scrollRef = useRef(null); // File chat groups default to expanded; this tracks the ones the user has @@ -178,6 +182,7 @@ export const AITab: React.FC = ({ selectedReasoningEffort={aiConfig?.reasoningEffort ?? null} onReasoningEffortChange={(effort) => onAIConfigChange?.({ reasoningEffort: effort })} hasSession={hasAISession} + originFork={originFork} /> {onAskGeneral && }
@@ -276,6 +281,7 @@ export const AITab: React.FC = ({ selectedReasoningEffort={aiConfig?.reasoningEffort ?? null} onReasoningEffortChange={(effort) => onAIConfigChange?.({ reasoningEffort: effort })} hasSession={hasAISession} + originFork={originFork} /> {/* General question input */} diff --git a/packages/review-editor/components/ReviewSidebar.tsx b/packages/review-editor/components/ReviewSidebar.tsx index 09df4387d..36d3deb86 100644 --- a/packages/review-editor/components/ReviewSidebar.tsx +++ b/packages/review-editor/components/ReviewSidebar.tsx @@ -20,6 +20,7 @@ import type { AIChatEntry, PendingPermission } from '../hooks/useAIChat'; import type { AgentJobInfo, AgentCapabilities } from '@plannotator/ui/types'; import type { DiffFile } from '../types'; import type { AIProviderOption } from '@plannotator/ui/utils/aiProvider'; +import type { OriginForkToggleProps } from '@plannotator/ui/hooks/useOriginFork'; import { copyTextToClipboard } from '@plannotator/ui/utils/clipboard'; import { artifactAnchorLabel, artifactAnnotationQuote } from '../utils/artifactAnnotations'; @@ -74,6 +75,8 @@ interface ReviewSidebarProps { aiConfig?: { providerId: string | null; model: string | null; reasoningEffort?: string | null }; onAIConfigChange?: (config: { providerId?: string | null; model?: string | null; reasoningEffort?: string | null }) => void; hasAISession?: boolean; + /** Opt-in origin-session forking (#1519) — forwarded to AITab. */ + originFork?: OriginForkToggleProps; // Agent props agentJobs?: AgentJobInfo[]; agentCapabilities?: AgentCapabilities | null; @@ -273,6 +276,7 @@ export const ReviewSidebar: React.FC = /* React.memo */({ aiConfig, onAIConfigChange, hasAISession, + originFork, agentJobs, agentCapabilities, onAgentLaunch, @@ -737,6 +741,7 @@ export const ReviewSidebar: React.FC = /* React.memo */({ aiConfig={aiConfig} onAIConfigChange={onAIConfigChange} hasAISession={hasAISession} + originFork={originFork} /> )} diff --git a/packages/review-editor/hooks/useAIChat.ts b/packages/review-editor/hooks/useAIChat.ts index 59cf0b831..9a0b2d6f9 100644 --- a/packages/review-editor/hooks/useAIChat.ts +++ b/packages/review-editor/hooks/useAIChat.ts @@ -27,6 +27,8 @@ interface UseAIChatOptions { providerId?: string | null; model?: string | null; reasoningEffort?: string | null; + /** Fork the server-known origin session instead of starting fresh (#1519). */ + forkOrigin?: boolean; } export function useAIChat({ @@ -38,6 +40,7 @@ export function useAIChat({ providerId, model, reasoningEffort, + forkOrigin, }: UseAIChatOptions) { const chat = useSharedAIChat({ context: { @@ -47,6 +50,7 @@ export function useAIChat({ providerId, model, reasoningEffort, + forkOrigin, }); // View state changes mid-session; the session context is baked once. Read the diff --git a/packages/server/ai-runtime.ts b/packages/server/ai-runtime.ts index b4f788d18..9f0c8a7d3 100644 --- a/packages/server/ai-runtime.ts +++ b/packages/server/ai-runtime.ts @@ -8,6 +8,7 @@ import { type PiSDKConfig, } from "@plannotator/ai"; import { resolveWindowsCommandShim } from "@plannotator/ai/providers/command-path"; +import type { OriginSessionOption } from "./origin-session"; export interface AIRuntime { endpoints: AIEndpoints; @@ -16,7 +17,7 @@ export interface AIRuntime { export const AI_QUERY_ENDPOINT = "/api/ai/query"; -interface CreateAIRuntimeOptions { +interface CreateAIRuntimeOptions extends OriginSessionOption { cwd?: string; getCwd?: () => string; } @@ -114,6 +115,7 @@ export async function createAIRuntime(options: CreateAIRuntimeOptions = {}): Pro registry, sessionManager, getCwd: options.getCwd, + originSession: options.originSession, beforeCapabilities: async () => { await Promise.allSettled(modelDiscovery); }, diff --git a/packages/server/annotate.ts b/packages/server/annotate.ts index a75a59558..9f134e690 100644 --- a/packages/server/annotate.ts +++ b/packages/server/annotate.ts @@ -14,6 +14,7 @@ import { isRemoteSession, getServerHostname, startBunServerOnAvailablePort, buildAdvertisedUrl } from "./remote"; import { getRepoInfo } from "./repo"; import type { Origin } from "@plannotator/shared/agents"; +import type { OriginSessionOption } from "./origin-session"; import { handleImage, handleUpload, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, handleApiNotFound, handleFavicon, handleReferenceSkills, handleReferenceSkillContent, handleSaveNotes, readDraftGenerationFromBody, readDraftGenerationFromUrl } from "./shared-handlers"; import { handleDoc, handleDocExists, handleFileBrowserFiles, handleObsidianVaults, handleObsidianFiles, handleObsidianDoc, resolveAllowedDocPath, type FolderAnnotateHistory } from "./reference-handlers"; import { closeAllFileBrowserWatchers, handleFileBrowserFilesStream } from "./reference-watch"; @@ -72,7 +73,7 @@ export { handleServerReady as handleAnnotateServerReady } from "./shared-handler // --- Types --- -export interface AnnotateServerOptions { +export interface AnnotateServerOptions extends OriginSessionOption { /** Markdown content of the file to annotate. Empty when rendering raw HTML. */ markdown: string; /** Original file path (for display purposes) */ @@ -232,6 +233,7 @@ export async function startAnnotateServer( filePath, htmlContent, origin, + originSession, mode = "annotate", folderPath, recentMessages, @@ -458,7 +460,7 @@ export async function startAnnotateServer( return legacyDurable && (archived || !hasContent); }; const externalAnnotations = createExternalAnnotationHandler("plan"); - const aiRuntime = resolveAIEnabled() ? await createAIRuntime() : null; + const aiRuntime = resolveAIEnabled() ? await createAIRuntime({ originSession }) : null; const htmlAssets = createHtmlAssetRegistry(); const agentTerminal = await createBunAgentTerminalBridge({ enabled: supportsAnnotateAgentTerminalMode(mode), diff --git a/packages/server/index.ts b/packages/server/index.ts index ba8bf86cb..6fb652644 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -57,6 +57,7 @@ import { isWSL } from "./browser"; import { AI_QUERY_ENDPOINT, createAIRuntime } from "./ai-runtime"; import { isAIEndpointPath, type AIEndpoints } from "@plannotator/ai"; import { isArchiveDocumentMutation } from "@plannotator/shared/archive-mode"; +import type { OriginSessionOption } from "./origin-session"; // Re-export utilities export { isRemoteSession, getServerPort } from "./remote"; @@ -65,10 +66,12 @@ export * from "./integrations"; export * from "./storage"; export { handleServerReady } from "./shared-handlers"; export { type VaultNode, buildFileTree } from "@plannotator/shared/reference-common"; +export type { ParentSession } from "@plannotator/ai"; +export { buildOriginSession, type OriginSessionOption } from "./origin-session"; // --- Types --- -export interface ServerOptions { +export interface ServerOptions extends OriginSessionOption { /** The plan markdown content */ plan: string; /** Origin identifier (e.g., "claude-code", "opencode") */ @@ -128,7 +131,7 @@ export interface ServerResult { export async function startPlannotatorServer( options: ServerOptions ): Promise { - const { plan, origin, htmlContent, permissionMode, sharingEnabled = true, shareBaseUrl, pasteApiUrl, onReady, mode, customPlanPath } = options; + const { plan, origin, htmlContent, permissionMode, originSession, sharingEnabled = true, shareBaseUrl, pasteApiUrl, onReady, mode, customPlanPath } = options; const isRemote = isRemoteSession(); const wslFlag = await isWSL(); @@ -152,7 +155,7 @@ export async function startPlannotatorServer( const draftKey = mode !== "archive" ? contentHash(plan) : ""; const editorAnnotations = mode !== "archive" ? createEditorAnnotationHandler() : null; const externalAnnotations = mode !== "archive" ? createExternalAnnotationHandler("plan") : null; - const aiRuntime = mode !== "archive" && resolveAIEnabled() ? await createAIRuntime() : null; + const aiRuntime = mode !== "archive" && resolveAIEnabled() ? await createAIRuntime({ originSession }) : null; const slug = mode !== "archive" ? generateSlug(plan) : ""; // Lazy cache for in-session archive browsing (plan review sidebar tab) diff --git a/packages/server/origin-session.ts b/packages/server/origin-session.ts new file mode 100644 index 000000000..78af7a6a0 --- /dev/null +++ b/packages/server/origin-session.ts @@ -0,0 +1,48 @@ +/** + * Shared construction of the Ask AI fork-origin `ParentSession` (#1519). + * + * Every launch site that can name the agent session it was invoked from + * (the hook CLI's ancestor-PID resolution, the OpenCode bridge's stdin + * payloads, the OpenCode plugin's native command/embedded-runtime paths) + * ends up with the same raw (sessionId, cwd) pair and needs the same + * {sessionId, cwd, agent} object. `buildOriginSession` is the one place + * that object gets constructed — apps/hook/server/index.ts and + * apps/opencode-plugin/origin-session.ts both delegate to it rather than + * rebuilding it themselves. + */ + +import type { ParentSession } from "@plannotator/ai"; +import type { Origin } from "@plannotator/shared/agents"; + +/** + * Build a `ParentSession` from a raw sessionId/cwd pair. Accepts + * unknown-typed input so callers reading untrusted JSON (stdin bridge + * payloads, hook-event fields) don't need their own type guards. Returns + * null when sessionId is missing/blank — callers just skip offering the + * fork toggle. + */ +export function buildOriginSession(input: { + agent: Origin; + sessionId?: unknown; + cwd?: unknown; + /** cwd to use when `cwd` is absent/blank. Defaults to `process.cwd()`. */ + fallbackCwd?: string; +}): ParentSession | null { + if (typeof input.sessionId !== "string" || !input.sessionId) return null; + const cwd = + typeof input.cwd === "string" && input.cwd + ? input.cwd + : (input.fallbackCwd ?? process.cwd()); + return { sessionId: input.sessionId, cwd, agent: input.agent }; +} + +/** + * Mixin for `ServerOptions`, `AnnotateServerOptions` and `ReviewServerOptions` + * (packages/server) — one place for the `originSession` field and its doc, + * previously repeated verbatim in all three. The agent session this surface + * was launched from, when known. Used server-side to let Ask AI fork it on + * request (#1519); never echoed to the browser. + */ +export interface OriginSessionOption { + originSession?: ParentSession | null; +} diff --git a/packages/server/package.json b/packages/server/package.json index bd838b70e..f504370b6 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -7,6 +7,7 @@ "types": "index.ts", "exports": { ".": "./index.ts", + "./origin-session": "./origin-session.ts", "./review": "./review.ts", "./annotate": "./annotate.ts", "./remote": "./remote.ts", diff --git a/packages/server/review.ts b/packages/server/review.ts index 1ea03532c..45daf2acf 100644 --- a/packages/server/review.ts +++ b/packages/server/review.ts @@ -11,6 +11,7 @@ import { isRemoteSession, getServerHostname, startBunServerOnAvailablePort, buildAdvertisedUrl } from "./remote"; import type { Origin } from "@plannotator/shared/agents"; +import type { OriginSessionOption } from "./origin-session"; import { type DiffType, type GitContext, runVcsDiff, getVcsFileContentsForDiff, getVcsDiffFingerprint, canStageFiles, stageFile, unstageFile, resolveVcsCwd, validateFilePath, getVcsContext, detectRemoteDefaultCompareTarget, resolveAvailableDiffType, vcsOwnsDiffType, vcsSupportsSnapshot, materializeVcsSnapshot, gitRuntime } from "./vcs"; import { basename } from "node:path"; import { existsSync } from "node:fs"; @@ -152,7 +153,7 @@ export { handleServerReady as handleReviewServerReady } from "./shared-handlers" // --- Types --- -export interface ReviewServerOptions { +export interface ReviewServerOptions extends OriginSessionOption { /** Raw git diff patch string */ rawPatch: string; /** Git ref used for the diff (e.g., "HEAD", "main..HEAD", "--staged") */ @@ -1700,7 +1701,9 @@ export async function startReviewServer( }); // AI provider setup (graceful — capabilities report unavailable if no provider is registered) - const aiRuntime = aiEnabled ? await createAIRuntime({ getCwd: resolveAgentCwd }) : null; + const aiRuntime = aiEnabled + ? await createAIRuntime({ getCwd: resolveAgentCwd, originSession: options.originSession }) + : null; const isRemote = isRemoteSession(); const wslFlag = await isWSL(); diff --git a/packages/ui/components/ai/DocumentAIChatPanel.tsx b/packages/ui/components/ai/DocumentAIChatPanel.tsx index bbe6ea7fa..72321dc79 100644 --- a/packages/ui/components/ai/DocumentAIChatPanel.tsx +++ b/packages/ui/components/ai/DocumentAIChatPanel.tsx @@ -1,10 +1,12 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { AIChatEntry, PendingPermission } from '../../hooks/useAIChat'; import type { AIProviderOption } from '../../utils/aiProvider'; +import type { OriginForkToggleProps } from '../../hooks/useOriginFork'; import { formatRelativeTime, renderChatMarkdown } from '../../utils/aiChatFormat'; import { OverlayScrollArea } from '../OverlayScrollArea'; import { SparklesIcon } from '../SparklesIcon'; import { AIProviderBar } from './AIProviderBar'; +import { OriginForkToggle } from './OriginForkToggle'; import { submitHint } from '../../utils/platform'; interface DocumentAIChatPanelProps { @@ -19,6 +21,8 @@ interface DocumentAIChatPanelProps { aiProviders?: AIProviderOption[]; aiConfig?: { providerId: string | null; model: string | null; reasoningEffort?: string | null }; onAIConfigChange?: (config: { providerId?: string | null; model?: string | null; reasoningEffort?: string | null }) => void; + /** Opt-in origin-session forking (#1519) — see `useOriginFork`. */ + originFork?: OriginForkToggleProps; } function truncate(text: string, max = 180): string { @@ -64,6 +68,7 @@ export const DocumentAIChatPanel: React.FC = ({ aiProviders = [], aiConfig, onAIConfigChange, + originFork, }) => { const scrollRef = useRef(null); const [generalInput, setGeneralInput] = useState(''); @@ -128,6 +133,12 @@ export const DocumentAIChatPanel: React.FC = ({
)} + {originFork?.available && ( +
+ +
+ )} + session" opt-in checkbox (#1519). Shared by the + * plan/document chat panel and the code-review chat bar — both wrap it in + * their own border/padding container to match their surrounding layout. + */ +export const OriginForkToggle: React.FC = ({ available, enabled, onToggle, agentName }) => { + if (!available) return null; + return ( + + ); +}; diff --git a/packages/ui/hooks/hookTestHarness.tsx b/packages/ui/hooks/hookTestHarness.tsx new file mode 100644 index 000000000..367c30c60 --- /dev/null +++ b/packages/ui/hooks/hookTestHarness.tsx @@ -0,0 +1,54 @@ +/** + * Shared DOM harness for hook tests that need real React lifecycle + * (mount/act/unmount) rather than a shallow renderer — e.g. asserting on a + * hook's return value across re-renders. Previously copied wholesale + * between useOriginFork.test.tsx and useAIChat.forkOrigin.test.tsx (#1519 + * review); this is the one place it's defined. + * + * Requires DOM — callers gate individual tests with `test.skipIf(!hasDom)`. + */ +import React from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { act } from 'react'; + +export const hasDom = typeof document !== 'undefined'; + +export interface MountedHook { + /** Ref the harness component writes the hook's latest return value into. */ + result: { current: TResult | null }; + root: Root; + host: HTMLDivElement; + /** Re-render the host with new element/props (e.g. after a provider switch). */ + rerender: (element: React.ReactElement) => Promise; + unmount: () => Promise; +} + +/** + * Mount `element` — typically a small harness component that stashes a + * hook's return value into `resultRef.current` — into a detached host div, + * wrapped in `act`. + */ +export async function mountHook( + resultRef: { current: TResult | null }, + element: React.ReactElement, +): Promise> { + const host = document.createElement('div'); + document.body.appendChild(host); + let root: Root; + await act(async () => { + root = createRoot(host); + root.render(element); + }); + return { + result: resultRef, + root: root!, + host, + rerender: async (next) => { + await act(async () => { root.render(next); }); + }, + unmount: async () => { + await act(async () => { root!.unmount(); }); + host.remove(); + }, + }; +} diff --git a/packages/ui/hooks/useAIChat.forkOrigin.test.tsx b/packages/ui/hooks/useAIChat.forkOrigin.test.tsx new file mode 100644 index 000000000..3fdba6458 --- /dev/null +++ b/packages/ui/hooks/useAIChat.forkOrigin.test.tsx @@ -0,0 +1,86 @@ +/** + * forkOrigin plumbing test (#1519): useAIChat's `forkOrigin` option is the + * only origin-fork signal the client ever sends — it posts `forkOrigin: true` + * as a sibling of `context` on /api/ai/session, and never builds or attaches + * a `ParentSession` itself (the server holds that, see + * `AIEndpointDeps.originSession` in packages/ai/endpoints.ts). + * + * Requires DOM — runs under bun test (preloaded via bunfig.toml). + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import React from 'react'; +import { act } from 'react'; +import * as useAIChatModule from './useAIChat'; +import type { AITransport } from './useAIChat'; +import type { AIContext } from '@plannotator/core'; +import { hasDom, mountHook } from './hookTestHarness'; + +const setAITransport = useAIChatModule.setAITransport; +const resetAITransport = useAIChatModule.resetAITransport; +const useAIChat = useAIChatModule.useAIChat; + +afterEach(() => { + resetAITransport(); + if (hasDom) document.body.innerHTML = ''; +}); + +type HookResult = ReturnType; + +function Harness({ + resultRef, + context, + forkOrigin, +}: { + resultRef: { current: HookResult | null }; + context: AIContext | null; + forkOrigin?: boolean; +}) { + resultRef.current = useAIChat({ context, forkOrigin }); + return null; +} + +const TEST_CONTEXT: AIContext = { + mode: 'plan-review', + plan: { plan: 'Test plan content' }, +}; + +function makeSseResponse(): Response { + const body = `data: {"type":"text_delta","delta":"ok"}\ndata: [DONE]\n\n`; + return new Response(body, { status: 200, headers: { 'Content-Type': 'text/event-stream' } }); +} + +function makeTransport(seenBodies: unknown[]): AITransport { + return { + session: async (body) => { + seenBodies.push(body); + return new Response(JSON.stringify({ sessionId: 'fake-session' }), { status: 200 }); + }, + query: async () => makeSseResponse(), + abort: async () => {}, + permission: () => {}, + }; +} + +describe('useAIChat forkOrigin', () => { + test.skipIf(!hasDom)('posts forkOrigin: true when armed, and never a client-built ParentSession', async () => { + const seenBodies: unknown[] = []; + setAITransport(makeTransport(seenBodies)); + + const resultRef: { current: HookResult | null } = { current: null }; + const { result, unmount } = await mountHook( + resultRef, + , + ); + await act(async () => { + await result.current!.ask({ prompt: 'why did you do it this way?' }); + }); + + expect(seenBodies).toHaveLength(1); + const body = seenBodies[0] as { forkOrigin?: boolean; context: AIContext }; + expect(body.forkOrigin).toBe(true); + // The client never builds a ParentSession — only the boolean crosses. + expect(body.context.parent).toBeUndefined(); + + await unmount(); + }); +}); diff --git a/packages/ui/hooks/useAIChat.ts b/packages/ui/hooks/useAIChat.ts index 3bd7a455e..698c68c2d 100644 --- a/packages/ui/hooks/useAIChat.ts +++ b/packages/ui/hooks/useAIChat.ts @@ -51,6 +51,13 @@ interface UseAIChatOptions { reasoningEffort?: string | null; buildPrompt?: (params: AskAIParams) => string; threadTitle?: string; + /** + * Fork the server-known origin session instead of starting fresh (#1519). + * The client never builds or sends a `ParentSession` itself — this is the + * only origin-fork signal posted to `/api/ai/session`; the server merges + * in its own `AIEndpointDeps.originSession` when this is true. + */ + forkOrigin?: boolean; } export function buildDefaultPrompt(params: AskAIParams): string { @@ -200,6 +207,7 @@ export function useAIChat({ reasoningEffort, buildPrompt = buildDefaultPrompt, threadTitle = 'Chat', + forkOrigin, }: UseAIChatOptions) { const [thread, setThread] = useState(() => createThread(threadTitle)); const [isCreatingSession, setIsCreatingSession] = useState(false); @@ -241,6 +249,7 @@ export function useAIChat({ ...(providerId && { providerId }), ...(model && { model }), ...(reasoningEffort && { reasoningEffort }), + ...(forkOrigin && { forkOrigin: true }), }, signal); if (!res.ok) { @@ -260,7 +269,7 @@ export function useAIChat({ setIsCreatingSession(false); } } - }, [context, model, providerId, reasoningEffort, setSessionId]); + }, [context, forkOrigin, model, providerId, reasoningEffort, setSessionId]); // Tell the server to stop the current session's in-flight turn, and resolve // once it has. Used by the Stop button and when a new question supersedes a diff --git a/packages/ui/hooks/useOriginFork.test.tsx b/packages/ui/hooks/useOriginFork.test.tsx new file mode 100644 index 000000000..48c5a30c7 --- /dev/null +++ b/packages/ui/hooks/useOriginFork.test.tsx @@ -0,0 +1,135 @@ +/** + * useOriginFork gate test (#1519). + * + * Contract under test: + * - The toggle is available only when originFork is non-null AND the + * *effective* provider id (resolved selection, else the server default — + * never array/registry order) is in originFork.providerIds. + * - forkOrigin is derived from that gate, not held as independent state: + * switching to a non-forking provider clears it even without an explicit + * reset. + * + * Requires DOM — runs under bun test (preloaded via bunfig.toml). + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import React from 'react'; +import { act } from 'react'; +import { useOriginFork, type OriginForkCapability } from './useOriginFork'; +import { hasDom, mountHook } from './hookTestHarness'; +import type { AIProviderOption } from '../utils/aiProvider'; + +afterEach(() => { + if (hasDom) document.body.innerHTML = ''; +}); + +type HookResult = ReturnType; + +function Harness({ + resultRef, + originFork, + providers, + providerId, + defaultProviderId, +}: { + resultRef: { current: HookResult | null }; + originFork: OriginForkCapability | null; + providers: AIProviderOption[]; + providerId: string | null; + defaultProviderId: string | null; +}) { + resultRef.current = useOriginFork({ originFork, providers, providerId, defaultProviderId }); + return null; +} + +const PROVIDERS: AIProviderOption[] = [ + { id: 'codex-sdk', name: 'codex-sdk' }, + { id: 'claude-agent-sdk', name: 'claude-agent-sdk' }, +]; + +async function mount(props: { + originFork: OriginForkCapability | null; + providers: AIProviderOption[]; + providerId: string | null; + defaultProviderId: string | null; +}) { + const resultRef: { current: HookResult | null } = { current: null }; + return mountHook(resultRef, ); +} + +describe('useOriginFork', () => { + test.skipIf(!hasDom)('unavailable when there is no origin-fork capability', async () => { + const { result } = await mount({ + originFork: null, + providers: PROVIDERS, + providerId: 'claude-agent-sdk', + defaultProviderId: null, + }); + expect(result.current!.toggleProps.available).toBe(false); + expect(result.current!.forkOrigin).toBe(false); + }); + + test.skipIf(!hasDom)('available when the explicitly selected provider can fork the origin session', async () => { + const { result } = await mount({ + originFork: { agent: 'claude-code', providerIds: ['claude-agent-sdk'] }, + providers: PROVIDERS, + providerId: 'claude-agent-sdk', + defaultProviderId: 'codex-sdk', + }); + expect(result.current!.toggleProps.available).toBe(true); + expect(result.current!.toggleProps.agentName).toBe('Claude Code'); + }); + + test.skipIf(!hasDom)('unavailable when the selected provider cannot fork it', async () => { + const { result } = await mount({ + originFork: { agent: 'claude-code', providerIds: ['claude-agent-sdk'] }, + providers: PROVIDERS, + providerId: 'codex-sdk', + defaultProviderId: 'codex-sdk', + }); + expect(result.current!.toggleProps.available).toBe(false); + }); + + test.skipIf(!hasDom)('falls back to the server default, not provider array order, when the selection is unknown', async () => { + // providerId names a provider not present in `providers` (e.g. a stale + // saved preference) — must resolve via defaultProviderId, not PROVIDERS[0]. + const { result } = await mount({ + originFork: { agent: 'claude-code', providerIds: ['claude-agent-sdk'] }, + providers: PROVIDERS, + providerId: 'unknown-provider', + defaultProviderId: 'claude-agent-sdk', + }); + expect(result.current!.toggleProps.available).toBe(true); + }); + + test.skipIf(!hasDom)('forkOrigin is derived: enabling then switching to a non-forking provider clears it without a reset', async () => { + const { result, rerender, unmount } = await mount({ + originFork: { agent: 'claude-code', providerIds: ['claude-agent-sdk'] }, + providers: PROVIDERS, + providerId: 'claude-agent-sdk', + defaultProviderId: 'claude-agent-sdk', + }); + + await act(async () => { + result.current!.toggleProps.onToggle(true); + }); + expect(result.current!.forkOrigin).toBe(true); + + // Re-render with the provider switched away from a forking one — no call + // into the hook's toggle handler, just a prop change (mirrors an app + // switching `providerId` after a picker selection). + await rerender( + , + ); + + expect(result.current!.toggleProps.available).toBe(false); + expect(result.current!.forkOrigin).toBe(false); + + await unmount(); + }); +}); diff --git a/packages/ui/hooks/useOriginFork.ts b/packages/ui/hooks/useOriginFork.ts new file mode 100644 index 000000000..a9ec9b508 --- /dev/null +++ b/packages/ui/hooks/useOriginFork.ts @@ -0,0 +1,80 @@ +import { useMemo, useState } from 'react'; +import { getAgentName, type Origin } from '@plannotator/core/agents'; +import type { AIProviderOption } from '../utils/aiProvider'; + +/** + * Origin-fork availability, as reported by `/api/ai/capabilities` + * (`originFork`). `agent` is the harness that owns the invoking session; + * `providerIds` are the registry ids of providers that can actually fork it + * — computed server-side (see `packages/ai/endpoints.ts`), never re-derived + * on the client. + */ +export interface OriginForkCapability { + agent: Origin; + providerIds: string[]; +} + +/** Shared prop shape for the "Fork the session" checkbox row. */ +export interface OriginForkToggleProps { + /** Whether the effective provider can fork the origin session right now. */ + available: boolean; + enabled: boolean; + onToggle: (enabled: boolean) => void; + /** Display name of the origin harness, e.g. "Claude Code". */ + agentName: string; +} + +/** + * Gates and owns the opt-in "fork the invoking agent session" toggle + * (issue #1519). + * + * The toggle is shown only when the server reports an origin session AND the + * *effective* AI provider — the resolved selection if it's a real, currently + * known provider, else the server's own default, never array order — is one + * that can actually fork it (`originFork.providerIds`). + * + * Switching to a non-forking provider must not leave a stale `true` armed + * silently answering questions fresh, so the emitted `forkOrigin` is + * *derived* from the availability gate rather than held as independent + * state: flipping the provider away from a forking one clears it for free, + * no extra effect required. + */ +export function useOriginFork(options: { + originFork: OriginForkCapability | null; + providers: AIProviderOption[]; + providerId: string | null; + defaultProviderId: string | null; +}): { forkOrigin: boolean; toggleProps: OriginForkToggleProps } { + const [wantsFork, setWantsFork] = useState(false); + const { originFork, providers, providerId, defaultProviderId } = options; + + const effectiveProviderId = + providerId && providers.some((p) => p.id === providerId) ? providerId : (defaultProviderId ?? null); + + const available = + !!originFork && !!effectiveProviderId && originFork.providerIds.includes(effectiveProviderId); + + // This hook does not reset the AI session on toggle — same cycle as + // useAIProviderConfig: the session (useAIChat) depends on `forkOrigin` + // from this hook, so the reset has to be composed by the caller after both + // exist, not owned here. Callers wrap `toggleProps.onToggle` with a call + // to resetSession, the same way apps already compose provider-switch + // resets — `setWantsFork` (a stable dispatch function) is handed straight + // through rather than behind a needless wrapper. + // + // `toggleProps` is memoized so its identity is stable across renders that + // don't change `available`/`wantsFork`/`originFork` — a caller's own + // `useCallback` wrapping `toggleProps.onToggle` (or a memoized consumer + // downstream) would otherwise recompute on every render regardless. + const toggleProps = useMemo(() => ({ + available, + enabled: wantsFork, + onToggle: setWantsFork, + agentName: originFork ? getAgentName(originFork.agent) : '', + }), [available, wantsFork, originFork]); + + return { + forkOrigin: wantsFork && available, + toggleProps, + }; +} diff --git a/packages/ui/utils/aiProvider.ts b/packages/ui/utils/aiProvider.ts index dbfacc16f..8907f65cd 100644 --- a/packages/ui/utils/aiProvider.ts +++ b/packages/ui/utils/aiProvider.ts @@ -7,7 +7,7 @@ */ import { storage } from './storage'; -import { AGENT_CONFIG, getAgentAIProviderTypes, type Origin } from '@plannotator/core/agents'; +import { AGENT_CONFIG, getAgentAIProviderTypes, matchesAgentProvider, type Origin } from '@plannotator/core/agents'; const PROVIDER_KEY = 'plannotator-ai-provider'; const MODELS_KEY = 'plannotator-ai-models'; @@ -115,18 +115,15 @@ export function savePreferredModel(providerId: string, modelId: string): void { /** * Find the first available provider that naturally matches the current origin. - * Instance IDs can be custom, so we match both the registry ID and provider type. + * Instance IDs can be custom, so we match both the registry ID and provider type + * (`matchesAgentProvider`, packages/core/agents.ts — the same rule + * `originForkProviderIds` uses server-side, packages/ai/endpoints.ts, #1519). */ export function findOriginAIProvider( providers: AIProviderOption[], origin: Origin | null | undefined, ): AIProviderOption | null { - const providerTypes = getAgentAIProviderTypes(origin); - for (const providerType of providerTypes) { - const provider = providers.find(p => p.id === providerType || p.name === providerType); - if (provider) return provider; - } - return null; + return providers.find(p => matchesAgentProvider(origin, p.id, p.name)) ?? null; } export function resolveAIModelForProvider(