mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
cb6667e991
* fix(ai): drive Codex Ask AI via app-server + answer-first review prompts (#971) Codex Ask AI previously ran via @openai/codex-sdk (codex exec), which forces approval_policy=never and breaks in enterprise-managed Codex environments that ban it (#971). Replace the transport with a long-lived 'codex app-server' process over JSON-RPC. - New provider packages/ai/providers/codex-app-server.ts (registered as 'codex-sdk' to preserve cookie/agents.ts/UI-gate); omits approvalPolicy so Codex resolves the user's + managed policy, pins read-only sandbox, and surfaces interactive approvals through the existing PermissionCard. - Delete codex-sdk.ts and drop the @openai/codex-sdk dependency (and its 6 prebuilt platform binaries); gate registration on 'which codex'. - SessionManager: additive, optional dispose?() hook to kill the spawned process on evict/remove — a no-op for Claude/OpenCode/Pi (they don't implement it). Also rework the Ask AI prompts (all providers, separate from the transport): - Every mode now instructs the agent to answer the user's message directly and not launch an unprompted review of the context. - Code review stops pasting the whole diff for git-reproducible diff types and instead tells the agent how to inspect it (git diff <base>..HEAD, three-dot for merge-base); non-git/PR/workspace types still paste. - Claude gains the Bash tool so it can run git (still gated by approvals). - The UI passes diffType/base (session) and what the user is viewing (per question) into the context. Verified: full typecheck, full test suite (101 ai tests), and a live end-to-end smoke against codex app-server. * fix(review): pin AI approval card above the input/model bar Render pending approval cards just above the input + provider/model bar in both the document chat (DocumentAIChatPanel) and code-review AI tab, instead of at the top of the scroll, so the user sees them where they act. * fix(ai): harden Codex abort/cancel + add Ask AI Stop button Addresses code-review findings on the codex app-server provider: - turn/interrupt was sent as a notification (no id) so Codex ignored it and abort never took effect. It's now a proper JSON-RPC request. - Filter turn events/approvals by turnId and reject an aborted turn's stragglers, so a stopped turn can no longer leak output into — or prematurely finish — the next turn (ask-stop-ask race). - Guard listeners by query generation and end the drain loop on the abort signal, so a superseded/stopped turn can't touch the live one and abort returns promptly instead of waiting for turn/completed. - Handle abort during startup: once the turn id is known, interrupt it instead of running it in the background. - Add a sendAndWait timeout so a stalled (alive-but-unresponsive) process errors instead of hanging forever. - Drain stderr (stdio 'ignore') to avoid a pipe-buffer deadlock. Also add a Stop button to both Ask AI surfaces (plan/annotate DocumentAIChatPanel and code-review AITab via ReviewSidebar). It replaces Send while streaming and calls the hook's abort -> /api/ai/abort -> session.abort(); the hook already exposed abort but nothing surfaced it. * feat(ai): drive Codex models + reasoning levels from model/list - Codex provider fetches model/list at startup (throwaway app-server, like Pi/OpenCode) and populates the real models plus each model's actual supportedReasoningEfforts + defaultReasoningEffort. Replaces the hardcoded model list and the static AI_REASONING_EFFORTS (which mislabeled xhigh as 'Max' and omitted minimal). - AIProviderBar + AIConfigBar now show the selected model's real efforts and hide the control when a model reports none. xhigh is shown verbatim. - Fix: the Stop button now also appears in the populated code-review chat state (a prior edit missed the second GeneralInput due to indentation). * fix(ai): scope Claude Ask AI Bash to read-only git; clear stale approval cards on Stop - Claude Ask AI no longer auto-allows bare Bash (which ran arbitrary shell with no Allow/Deny prompt). Replace it with scoped read-only git rules (Bash(git diff:*), show, log, status, rev-parse, merge-base, ls-files). git reads auto-run so the agent can inspect large diffs itself; any other command (write git, arbitrary, or injected compound) falls through to the permission card. Keeps the git-inspect approach (large diffs don't fit in the prompt) while closing the auto-exec hole. - useAIChat.abort() now drops still-undecided permission cards: abort cancels them server-side, so leaving them visible was a dead Allow/Deny. * feat(ai): code-review Ask AI shares the agent-review prompt machine, delivered as user messages Code-review Ask AI built its own diff description (gitInspectInstruction) in the system prompt from just diffType+base, which was wrong for full-stack, hide-whitespace, untracked files, and PR worktrees. Replace it with the same machine the review jobs use, delivered on the user's messages. - Server: buildCurrentAiReviewContext() reuses buildAgentReviewUserMessageForTarget (contextOnly) for the current view and ships it as aiReviewContext in every diff payload (/api/diff + switch/PR/scope). Mirrored in the Pi server. - Client: review-editor latches aiReviewContext onto each question via the pure buildReviewContextPreamble (packages/ui/utils/aiPrompt.ts) and buildDefaultPrompt — full block on the first message / when the view changes (incl. after a provider switch via the !sessionId fresh-session check), a short reminder otherwise (never re-pastes a large diff). - context.ts: delete the duplicate gitInspectInstruction; code-review system prompt is now role-only. Providers untouched (provider-agnostic user message). - Tests: machine scenario gaps (plain PR, full-stack default, PR-worktree origin/<base> + stale-main warning, untracked mention, jj-evolog, workspace lines); composition (first/reminder, command/pasted, preamble ordering). * fix(ai): agentic remote-PR context, UTF-8 stream decode, real Stop on supersede/disconnect Addresses review findings on the Ask AI prompt work: - Remote PR without a confirmed local checkout no longer gets URL-only. The agent is told it's in a PR worktree that's being prepared, to verify the PR files exist before relying on them, and to diff with git diff origin/<base>... HEAD (URL fallback). Inform + trust the agent rather than pasting. Shared machine, so review jobs get the same framing. - Decode Codex stdout with a streaming TextDecoder instead of per-chunk toString(), so multi-byte UTF-8 split across chunks no longer corrupts into U+FFFD (matches the Pi provider). - Stop now actually stops the server turn: ask() awaits /api/ai/abort when a new question supersedes a streaming one (awaiting avoids racing the new query into session_busy), and the /api/ai/query SSE stream gains a cancel handler so tab-close/navigation aborts the turn too. Both reuse the existing per-provider session.abort(); factored a shared postServerAbort helper. * fix(ai): per-model reasoning effort, shared PR-checkout readiness, Stop-then-ask race, type hole - Reasoning effort is now tracked per model (a map keyed by model) instead of one global value, so switching to a model that doesn't support the prior level (e.g. xhigh) no longer posts a stale/unsupported effort that Codex rejects. Each model keeps its own level; nothing leaks across. - Extract resolvePoolCwd into packages/shared/worktree-pool.ts (ready/pending/ absent) and use it from both servers' resolvePRLocalCwd so the readiness rule can't drift. Fix the Pi Ask AI helper to ready-check like Bun, so a warming PR checkout no longer claims 'checked out at PR head' and misdirects the diff. - Stop-then-ask no longer races into session_busy: the abort promise (from Stop or a superseding question) is stashed and the next ask() awaits it before sending. Stop still kills the turn instantly; a follow-up just waits the one abort round-trip. - Declare aiReviewContext on the initial /api/diff response type. * fix(ai): surface real Codex error messages, skip transient retries, auto-deny permission escalations Validated against codex-rs: - Codex's ErrorNotification nests the text at params.error.message (TurnError.message); we read params.message → always 'Unknown error'. Read the nested field (top-level fallback) so auth/usage-limit/stream failures show their real, actionable text. - Skip transient error notifications (willRetry=true): Codex retries on its own and the turn continues, so surfacing them flashed a spurious failure before the real answer. - Handle item/permissions/requestApproval: respond {permissions:{}, scope:'turn'} — byte-for-byte Codex's own cancel response (codex_delegate.rs) — instead of 'Unsupported request'. Ask AI is read-only, so denying the escalation lets the turn continue sandboxed rather than failing. Interactive grant deferred. * refactor(ai): share AI provider/model config in one hook; aggregate paginated model/list - Extract useAIProviderConfig (packages/ui/hooks): one home for provider/model/ reasoning-effort selection — initial state, auto-resolve on capabilities load, per-model effort (no leak across models), and persistence. Both the plan and code-review apps now call it and only compose the session reset (the hook can't own reset without a cycle through useAIChat). Plan editor adapted to the effect-based resolve (adds aiDefaultProvider state). This fixes the plan editor's stale-effort-on-model-switch bug by construction and stops the two apps' copies from drifting again. - fetchModels now follows model/list's nextCursor and aggregates every page into one list, so larger model catalogs aren't silently truncated (with a page guard against a misbehaving cursor). Skipped per discussion: legacy v1 approval methods (we're a v2 client). * feat(agents): per-agent review default; bound Codex model discovery so it can't stall the AI panel - The selected review profile is now tracked per review engine (claude/codex/ cursor/opencode) instead of one flat global value, so each agent keeps its own review default. Public hook API (reviewProfileId/setReviewProfileId) is unchanged — getter derives the current engine's value, setter writes it — so AgentsTab needs no changes. One-shot migration seeds every engine with any existing flat pick. Adds parseReviewProfileByEngine + tests. - fetchModels (Codex model discovery) now uses a short 6s timeout for its initialize + model/list RPCs instead of the 30s default. /api/ai/capabilities awaits model discovery, so an installed-but-unauthenticated codex could otherwise block the AI panel for ~30s; it now falls back to the static model list fast. Authed codex is unaffected (discovery completes in tens of ms).
249 lines
8.0 KiB
TypeScript
249 lines
8.0 KiB
TypeScript
/**
|
|
* Context builders — translate Plannotator review state into system prompts
|
|
* that give the AI session the right background for answering questions.
|
|
*
|
|
* These are provider-agnostic: any AIProvider implementation can use them
|
|
* to build the system prompt it needs.
|
|
*/
|
|
|
|
import type { AIContext } from "./types.ts";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Build a system prompt from the given context.
|
|
*
|
|
* The prompt tells the AI:
|
|
* - What role it plays (plan reviewer, code reviewer, etc.)
|
|
* - The content it should reference (plan markdown, diff patch, file)
|
|
* - Any annotations the user has already made
|
|
* - That it's operating inside Plannotator (not a general coding session)
|
|
*/
|
|
export function buildSystemPrompt(ctx: AIContext): string {
|
|
switch (ctx.mode) {
|
|
case "plan-review":
|
|
return buildPlanReviewPrompt(ctx);
|
|
case "code-review":
|
|
return buildCodeReviewPrompt();
|
|
case "annotate":
|
|
return buildAnnotatePrompt(ctx);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build a compact context summary suitable for injecting into a fork prompt.
|
|
*
|
|
* When forking from a parent session, we don't need a full system prompt
|
|
* (the parent's history already provides context). Instead, we inject a
|
|
* short "you are now in Plannotator" preamble with the relevant content.
|
|
*/
|
|
export function buildForkPreamble(ctx: AIContext): string {
|
|
const lines: string[] = [
|
|
"The user is now reviewing your work in Plannotator and has a question.",
|
|
"Answer the user's message directly and concisely based on the conversation " +
|
|
"history and the context below. Do not re-review or summarize the work unless they ask.",
|
|
"",
|
|
];
|
|
|
|
switch (ctx.mode) {
|
|
case "plan-review": {
|
|
lines.push("## Current Plan Under Review");
|
|
if (ctx.plan.version) {
|
|
const total = ctx.plan.totalVersions ? ` of ${ctx.plan.totalVersions}` : "";
|
|
lines.push(`Plan version: ${ctx.plan.version}${total}`);
|
|
}
|
|
if (ctx.plan.project) {
|
|
lines.push(`Project: ${ctx.plan.project}`);
|
|
}
|
|
lines.push("");
|
|
lines.push(truncate(ctx.plan.plan, MAX_PLAN_CHARS));
|
|
if (ctx.plan.annotations) {
|
|
lines.push("");
|
|
lines.push("## User Annotations So Far");
|
|
lines.push(ctx.plan.annotations);
|
|
}
|
|
break;
|
|
}
|
|
case "code-review": {
|
|
if (ctx.review.filePath) {
|
|
lines.push(`## Reviewing: ${ctx.review.filePath}`);
|
|
}
|
|
if (ctx.review.selectedCode) {
|
|
lines.push("");
|
|
lines.push("### Selected Code");
|
|
lines.push("```");
|
|
lines.push(ctx.review.selectedCode);
|
|
lines.push("```");
|
|
}
|
|
if (ctx.review.lineRange) {
|
|
const { start, end, side } = ctx.review.lineRange;
|
|
lines.push(`Lines ${start}-${end} (${side} side)`);
|
|
}
|
|
lines.push("");
|
|
lines.push("## Diff Patch");
|
|
lines.push("```diff");
|
|
lines.push(truncate(ctx.review.patch, MAX_DIFF_CHARS));
|
|
lines.push("```");
|
|
if (ctx.review.annotations) {
|
|
lines.push("");
|
|
lines.push("## User Annotations So Far");
|
|
lines.push(ctx.review.annotations);
|
|
}
|
|
break;
|
|
}
|
|
case "annotate": {
|
|
lines.push(`## Annotating: ${ctx.annotate.filePath}`);
|
|
if (ctx.annotate.sourceInfo) {
|
|
lines.push(`Source: ${ctx.annotate.sourceInfo}`);
|
|
}
|
|
if (ctx.annotate.renderAs) {
|
|
lines.push(`Render mode: ${ctx.annotate.renderAs}`);
|
|
}
|
|
if (ctx.annotate.sourceConverted) {
|
|
lines.push("Note: this content was converted before annotation, so source line numbers may not match the original document.");
|
|
}
|
|
lines.push("");
|
|
lines.push(truncate(ctx.annotate.content, MAX_PLAN_CHARS));
|
|
if (ctx.annotate.annotations) {
|
|
lines.push("");
|
|
lines.push("## User Annotations So Far");
|
|
lines.push(ctx.annotate.annotations);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
return lines.join("\n");
|
|
}
|
|
|
|
/**
|
|
* Build the effective prompt for a query, prepending a preamble on the first
|
|
* message. Used by providers that inject context via the prompt itself (Codex,
|
|
* Pi) rather than a separate system-prompt channel (Claude).
|
|
*/
|
|
export function buildEffectivePrompt(
|
|
userPrompt: string,
|
|
preamble: string | null,
|
|
firstQuerySent: boolean,
|
|
): string {
|
|
if (!firstQuerySent && preamble) {
|
|
return `${preamble}\n\n---\n\nUser question: ${userPrompt}`;
|
|
}
|
|
return userPrompt;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Internals
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const MAX_PLAN_CHARS = 60_000;
|
|
const MAX_DIFF_CHARS = 40_000;
|
|
|
|
/**
|
|
* Leading instruction for every Ask AI session. Ask AI is a chat assistant —
|
|
* it must respond to the user's message, not launch an unprompted review of the
|
|
* material it was given for context.
|
|
*/
|
|
const ANSWER_DIRECTLY =
|
|
"You are a helpful assistant inside Plannotator. Respond to the user's message directly and concisely. " +
|
|
"The material below is context for what the user is looking at — do NOT review, summarize, or critique it unless the user's message asks you to. " +
|
|
"Only investigate further (read files, run git) if the user's question actually requires it.";
|
|
|
|
function truncate(text: string, max: number): string {
|
|
if (text.length <= max) return text;
|
|
return `${text.slice(0, max)}\n\n... [truncated for context window]`;
|
|
}
|
|
|
|
function buildPlanReviewPrompt(
|
|
ctx: Extract<AIContext, { mode: "plan-review" }>
|
|
): string {
|
|
const sections: string[] = [
|
|
ANSWER_DIRECTLY,
|
|
"",
|
|
"The user is reviewing an implementation plan in Plannotator.",
|
|
"",
|
|
"## Plan Under Review",
|
|
];
|
|
|
|
if (ctx.plan.version) {
|
|
const total = ctx.plan.totalVersions ? ` of ${ctx.plan.totalVersions}` : "";
|
|
sections.push(`Plan version: ${ctx.plan.version}${total}`);
|
|
}
|
|
|
|
if (ctx.plan.project) {
|
|
sections.push(`Project: ${ctx.plan.project}`);
|
|
}
|
|
|
|
sections.push("");
|
|
sections.push(truncate(ctx.plan.plan, MAX_PLAN_CHARS));
|
|
|
|
if (ctx.plan.previousPlan) {
|
|
sections.push("");
|
|
sections.push("## Previous Plan Version (for reference)");
|
|
sections.push(truncate(ctx.plan.previousPlan, MAX_PLAN_CHARS / 2));
|
|
}
|
|
|
|
if (ctx.plan.annotations) {
|
|
sections.push("");
|
|
sections.push("## User Annotations");
|
|
sections.push(ctx.plan.annotations);
|
|
}
|
|
|
|
return sections.join("\n");
|
|
}
|
|
|
|
/**
|
|
* Code-review system prompt: role only. The actual changeset and how to inspect
|
|
* it are NOT in the system prompt — they ride on the user's messages, where the
|
|
* review server's shared agent-review prompt machine describes the *current*
|
|
* view (a git command to run, or the diff inline for modes git can't reproduce)
|
|
* and the client latches it on. See packages/server/review.ts
|
|
* `buildCurrentAiReviewContext` and packages/ui/utils/aiPrompt.ts.
|
|
*/
|
|
function buildCodeReviewPrompt(): string {
|
|
return [
|
|
ANSWER_DIRECTLY,
|
|
"",
|
|
"The user is reviewing a set of code changes in Plannotator. Their messages " +
|
|
"describe the changeset under review and how to inspect it — either a git " +
|
|
"command to run, or the diff inline.",
|
|
].join("\n");
|
|
}
|
|
|
|
function buildAnnotatePrompt(
|
|
ctx: Extract<AIContext, { mode: "annotate" }>
|
|
): string {
|
|
const sections: string[] = [
|
|
ANSWER_DIRECTLY,
|
|
"",
|
|
"The user is annotating a markdown document in Plannotator.",
|
|
"",
|
|
`## Document: ${ctx.annotate.filePath}`,
|
|
];
|
|
|
|
if (ctx.annotate.sourceInfo) {
|
|
sections.push(`Source: ${ctx.annotate.sourceInfo}`);
|
|
}
|
|
|
|
if (ctx.annotate.renderAs) {
|
|
sections.push(`Render mode: ${ctx.annotate.renderAs}`);
|
|
}
|
|
|
|
if (ctx.annotate.sourceConverted) {
|
|
sections.push("Note: this content was converted before annotation, so source line numbers may not match the original document.");
|
|
}
|
|
|
|
sections.push("");
|
|
sections.push(truncate(ctx.annotate.content, MAX_PLAN_CHARS));
|
|
|
|
if (ctx.annotate.annotations) {
|
|
sections.push("");
|
|
sections.push("## User Annotations");
|
|
sections.push(ctx.annotate.annotations);
|
|
}
|
|
|
|
return sections.join("\n");
|
|
}
|