From c92dd1c0509c04f5f53b72612424a23a13cc1a7a Mon Sep 17 00:00:00 2001 From: Gordon Date: Mon, 14 Sep 2026 12:27:23 +0100 Subject: [PATCH 1/5] feat(ai): fork the invoking agent session server-side on request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #1519: Ask AI always starts a fresh provider session, even though the agent session that produced the plan/diff/document usually has the exploration and reasoning that led to it. A prior attempt (reverted) had the browser hand back a ParentSession on /api/ai/session, but no client ever built one and the plumbing to get there touched 29 files across the hook CLI, both editors, and five OpenCode plugin layers. The browser never needs the session id, cwd, or harness — the server already knows all three at launch. So /api/ai/session now takes a `forkOrigin: true` boolean instead, and the endpoint merges deps.originSession (server-side only) into the request's AIContext before the existing fork-or-fresh check runs. /api/ai/capabilities advertises which providers can fork it as `originFork: { agent, providerIds }`. originForkProviderIds matches registry entries against the origin's provider types by id OR name (`matchesAgentProvider`, packages/core/agents.ts) rather than id alone: a registry instance id can be custom (registered with its own instanceId), so id-only matching silently drops a provider registered that way. This is the same rule `findOriginAIProvider` already used client-side (packages/ui/utils/aiProvider.ts) to pick a default provider for an origin — extracting it into one shared predicate keeps the two in sync instead of re-implementing the match with different vocabulary on each side. ParentSession.agent is now typed as Origin (packages/core/agents.ts) instead of a bare string, removing the `as Origin` casts the reverted version needed. Pi vendors ai-context.ts/endpoints.ts verbatim into apps/pi-extension/generated, so vendor.sh also vendors agents.ts alongside them and rewrites the new `@plannotator/core/agents` import the same way it already does for ai-context. --- AGENTS.md | 14 ++-- apps/pi-extension/vendor.sh | 8 +- packages/ai/ai.test.ts | 128 ++++++++++++++++++++++++++++++++ packages/ai/endpoints.ts | 54 +++++++++++++- packages/core/agents.ts | 19 +++++ packages/core/ai-context.ts | 8 ++ packages/ui/utils/aiProvider.ts | 13 ++-- 7 files changed, 226 insertions(+), 18 deletions(-) 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/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/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( From d89aeae009bb1358e5f76d42f498981b744a4b55 Mon Sep 17 00:00:00 2001 From: Gordon Date: Mon, 14 Sep 2026 12:27:33 +0100 Subject: [PATCH 2/5] feat(server): thread the origin session into AI runtime bootstraps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createAIRuntime() and the three server bootstraps (plan, annotate, review) now accept an optional originSession, forwarded straight into createAIEndpoints' deps. It rides no bootstrap payload (/api/plan, /api/diff are untouched) — only the AI runtime sees it, so /api/ai/session and /api/ai/capabilities can act on it server-side per the previous commit. The {sessionId, cwd, agent} ParentSession object and the `originSession` doc comment both get one home instead of being restated per call site: origin-session.ts's `buildOriginSession` is the single builder apps/hook and apps/opencode-plugin will share (next two commits), and `OriginSessionOption` is the one place `ServerOptions`/`AnnotateServerOptions`/`ReviewServerOptions` document the field, via `extends` instead of copying the same four-line comment three times. The package.json `exports` map gains an `./origin-session` subpath so a Node-targeted bundle (apps/opencode-plugin's CLI entry points) can import the builder without pulling in the package root's Bun-only modules. --- packages/server/ai-runtime.ts | 4 ++- packages/server/annotate.ts | 6 ++-- packages/server/index.ts | 9 ++++-- packages/server/origin-session.ts | 48 +++++++++++++++++++++++++++++++ packages/server/package.json | 1 + packages/server/review.ts | 7 +++-- 6 files changed, 67 insertions(+), 8 deletions(-) create mode 100644 packages/server/origin-session.ts 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(); From dea1b9c4297ac4def35c7d873de3bae903dfb957 Mon Sep 17 00:00:00 2001 From: Gordon Date: Mon, 14 Sep 2026 12:27:46 +0100 Subject: [PATCH 3/5] feat(hook): resolve the invoking agent session for Ask AI forking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires originSession into the seven hook-CLI launch sites that can genuinely name their invoking session: the direct `review`/`annotate` CLI (ancestor-PID transcript resolution), `annotate-last` (the transcript the rendered message was actually read from), the three OpenCode JSON-stdin bridge subcommands (opencode-plan/opencode-review/opencode-annotate-last, which already carry a sessionId/directory pair), and the ExitPlanMode hook-event branch (the event names its session id directly). Two small helpers replace what would otherwise be seven ad-hoc reconstructions: resolveInvokingClaudeSession() for the ancestor-PID cases, and toOpenCodeOriginSession() for the three bridge subcommands. Both delegate to the shared builder (buildOriginSession, packages/server/origin-session.ts) — apps/hook is Bun-only, so it can depend on @plannotator/server directly, the same builder apps/opencode-plugin's own toOriginSession wraps (next commit). Both return null outside their one supported harness — Codex's CODEX_THREAD_ID is deliberately never captured (no provider can fork a Codex thread), and neither Gemini nor any other origin is guessed at, since only claude-code and opencode ever appear in AIEndpointDeps.originSession.agent. resolveInvokingClaudeSession() resolves cwd via the PLANNOTATOR_CWD idiom used everywhere else in this file (the original working directory before a launcher shim `cd`s), not a bare process.cwd() — claude-agent-sdk.ts forks with `cwd: parent.cwd`, so a wrong cwd here would root the forked session in the wrong directory. The direct `annotate` CLI also gains a `--session-id ` value flag, following the same indexOf/splice idiom as the existing `--browser ` flag. It closes the one named surface from #1519 ("plan review via hook, /plannotator-last, /plannotator-annotate") that the CLI-bridge fallback leg of /plannotator-annotate couldn't cover: that leg shells out to the plain `annotate` subcommand (no stdin-JSON channel the way opencode-plan/ opencode-annotate-last have), so this flag is the only way it can name the invoking OpenCode session. buildAnnotateCliArgs (apps/opencode-plugin) emits it next commit. --- apps/hook/server/index.ts | 106 +++++++++++++++++++++++++++++++++++++- 1 file changed, 104 insertions(+), 2 deletions(-) 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, From bac24e351a83ff08db2db63882feae24305554ec Mon Sep 17 00:00:00 2001 From: Gordon Date: Mon, 14 Sep 2026 12:28:01 +0100 Subject: [PATCH 4/5] feat(opencode): thread the submitting session through plan review and commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit submit_plan (V1 and V2), the native /plannotator-review, /plannotator-annotate, and /plannotator-last command handlers, and the embedded/CLI-bridge plan-review runners all now carry the OpenCode session id (and cwd) one hop further, into originSession on the server bootstrap call. toOriginSession() wraps the shared builder (buildOriginSession, packages/server/origin-session.ts) that apps/hook's own helpers use — the one place the {sessionId, cwd, agent} object is actually constructed, replacing what was otherwise the same construction repeated across the CLI bridge, the native-command handlers, and the embedded runtime. It imports the `@plannotator/server/origin-session` subpath rather than the package root: index.ts and server.ts are bundled with `--target node` (they run under Node.js-hosted OpenCode, not Bun), and the package root barrel pulls in Bun-only modules (browser.ts, repo.ts, project.ts, integrations.ts) that a Node-targeted bundle can't import — the origin-session submodule itself has no such dependency. sessionId and cwd previously traveled as separate parameters through runPlanReview, runCliPlanReview and runEmbeddedPlanReview, joined into a ParentSession only at the leaf (inside embedded.ts, and again — across the process boundary — inside the hook CLI). runPlanReview (both the V1 and V2 copies) now joins them once, itself, into `originSession`, and threads that one value down; runCliPlanReview still takes `cwd` separately since the CLI bridge also needs it to spawn the child process, but the JSON payload's sessionId/directory fields now read from originSession rather than raw fields, so the two paths can't drift. /plannotator-annotate's CLI-bridge fallback leg (used only when the embedded runtime is unavailable) previously had no way to name the invoking session — it shells out to the plain `annotate` CLI subcommand, and unlike opencode-plan/opencode-annotate-last it has no stdin-JSON channel. The existing value-flag parsing (--result-file , the global --browser ) shows this is a solved problem for the annotate CLI, so buildAnnotateCliArgs now emits the --session-id flag the previous commit added, closing the one named #1519 surface (plan review, /plannotator-last, /plannotator-annotate) that OpenCode v2's always-CLI-bridge routing couldn't reach. --- apps/opencode-plugin/cli-bridge.test.ts | 29 +++++++++++++++++++ apps/opencode-plugin/cli-bridge.ts | 18 ++++++++++-- apps/opencode-plugin/commands.test.ts | 10 +++++++ apps/opencode-plugin/commands.ts | 6 +++- apps/opencode-plugin/embedded.ts | 4 +++ apps/opencode-plugin/index.ts | 12 +++++++- apps/opencode-plugin/origin-session.test.ts | 26 +++++++++++++++++ apps/opencode-plugin/origin-session.ts | 24 +++++++++++++++ apps/opencode-plugin/server.ts | 12 +++++++- .../submit-plan-executor.test.ts | 1 + apps/opencode-plugin/submit-plan-executor.ts | 4 +++ 11 files changed, 141 insertions(+), 5 deletions(-) create mode 100644 apps/opencode-plugin/origin-session.test.ts create mode 100644 apps/opencode-plugin/origin-session.ts 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(); From 461d278aef6f9d0969b686c9ac727773b1fdc6b6 Mon Sep 17 00:00:00 2001 From: Gordon Date: Mon, 14 Sep 2026 12:28:15 +0100 Subject: [PATCH 5/5] feat(ui): add the opt-in "fork the origin session" toggle to Ask AI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toggle is shown only when /api/ai/capabilities reports an originFork and the *effective* AI provider — the resolved selection if it names a real, currently known provider, else the server's own defaultProvider, never providers[0] array order — is in its providerIds. useOriginFork() (packages/ui) owns that gate once; posting `forkOrigin: true` on /api/ai/session is the only new thing useAIChat does. Because useAIChat's session depends on useOriginFork's `forkOrigin`, the app can't compose a reset inside the gate hook itself (the same ordering problem useAIProviderConfig already documents for provider switches) — so the emitted `forkOrigin` is *derived* from the gate instead of held as independent state. Switching to a non-forking provider clears it for free, with no explicit reset and no stale "still forking" flag left armed. `toggleProps` is memoized so its identity is stable across renders that don't change the gate inputs, and `onToggle` hands the toggle's own `setWantsFork` straight through instead of behind a needless wrapper — without that, a caller's `useCallback` around `toggleProps.onToggle` (composing the reset) recomputes on every render regardless of whether anything the toggle cares about changed. OriginForkToggle (packages/ui/components/ai) is the one checkbox row, used by both DocumentAIChatPanel and the review AIConfigBar/AITab/ReviewSidebar chain, replacing what would otherwise be the same prop shape declared four times. packages/editor/App.tsx and packages/review-editor/App.tsx each wire it with the same few lines: a useOriginFork() call ahead of useAIChat, and a handleForkOriginToggle that composes the toggle with resetSession — the editor additionally withholds availability while the agent-terminal surface is active, since forking into a provider chat isn't meaningful for the origin agent's own TUI. useAIChat.forkOrigin.test.tsx and useOriginFork.test.tsx share one DOM mount harness (hookTestHarness.tsx) instead of each defining their own copy, and the former keeps only the one test that asserts real behavior — that forkOrigin: true actually reaches the wire body and no client-built ParentSession rides with it — dropping two tests that only asserted key absence on the `...(forkOrigin && { forkOrigin: true })` spread. --- .github/workflows/test.yml | 2 + packages/editor/App.tsx | 31 ++ packages/review-editor/App.tsx | 23 ++ .../review-editor/components/AIConfigBar.tsx | 268 +++++++++--------- packages/review-editor/components/AITab.tsx | 6 + .../components/ReviewSidebar.tsx | 5 + packages/review-editor/hooks/useAIChat.ts | 4 + .../ui/components/ai/DocumentAIChatPanel.tsx | 11 + .../ui/components/ai/OriginForkToggle.tsx | 25 ++ packages/ui/hooks/hookTestHarness.tsx | 54 ++++ .../ui/hooks/useAIChat.forkOrigin.test.tsx | 86 ++++++ packages/ui/hooks/useAIChat.ts | 11 +- packages/ui/hooks/useOriginFork.test.tsx | 135 +++++++++ packages/ui/hooks/useOriginFork.ts | 80 ++++++ 14 files changed, 612 insertions(+), 129 deletions(-) create mode 100644 packages/ui/components/ai/OriginForkToggle.tsx create mode 100644 packages/ui/hooks/hookTestHarness.tsx create mode 100644 packages/ui/hooks/useAIChat.forkOrigin.test.tsx create mode 100644 packages/ui/hooks/useOriginFork.test.tsx create mode 100644 packages/ui/hooks/useOriginFork.ts 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/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/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, + }; +}