Files
Michael Ramos cb6667e991 fix(ai): drive Codex Ask AI via codex app-server (#971)
* 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).
2026-06-28 09:58:09 -07:00

165 lines
5.7 KiB
TypeScript

/**
* Worktree Pool — manages a set of per-PR git worktrees for a review session.
*
* Runtime-agnostic. Uses ReviewGitRuntime for all git operations.
* Both Bun and Pi servers import this module (Pi via vendor.sh).
*
* Each PR visited during a session gets its own worktree, created on first
* access and cached for the session lifetime. Agents run in their PR's
* worktree undisturbed by PR switches.
*/
import { join } from "node:path";
import type { ReviewGitRuntime } from "./review-core";
import type { PRMetadata } from "./pr-types";
import { createWorktree, removeWorktree, fetchRef, ensureObjectAvailable } from "./worktree";
export interface PoolEntry {
path: string;
prUrl: string;
number: number;
ready: boolean;
}
export interface WorktreePoolConfig {
sessionDir: string;
repoDir: string;
isSameRepo: boolean;
}
export interface WorktreePool {
get(prUrl: string): PoolEntry | undefined;
has(prUrl: string): boolean;
resolve(prUrl: string): string | undefined;
ensure(runtime: ReviewGitRuntime, metadata: PRMetadata): Promise<PoolEntry>;
entries(): IterableIterator<PoolEntry>;
cleanup(runtime: ReviewGitRuntime): Promise<void>;
}
/** A PR checkout is ready, still warming up, or absent from the pool. */
export type PoolCwdResolution =
| { kind: "ready"; path: string }
| { kind: "pending" }
| { kind: "absent" };
/**
* Resolve a PR's checkout state from the pool. The "pending" case (entry exists
* but isn't ready) is kept distinct from "absent" so callers don't fall back to
* the launch repo for a checkout that simply hasn't finished warming up. Shared
* by both servers' resolvePRLocalCwd so the readiness rule can't drift.
*/
export function resolvePoolCwd(pool: WorktreePool, prUrl: string): PoolCwdResolution {
const entry = pool.get(prUrl);
if (entry?.ready) return { kind: "ready", path: entry.path };
if (entry) return { kind: "pending" };
return { kind: "absent" };
}
export function createWorktreePool(
config: WorktreePoolConfig,
initial?: PoolEntry,
initialPending?: Promise<PoolEntry>,
): WorktreePool {
const pool = new Map<string, PoolEntry>();
const pending = new Map<string, Promise<PoolEntry>>();
// FETCH_HEAD is shared per-repo state: a creation's PR-head fetch must not
// run while another creation (or the seeded background warmup) is between
// its own fetch and `git worktree add`. Serialize all creations through
// this chain.
let creationChain: Promise<unknown> = Promise.resolve();
if (initial) pool.set(initial.prUrl, initial);
// Seeded background warmup: the initial entry starts ready:false while the
// caller builds its checkout (fetch/clone) off the request path. ensure()
// awaits the in-flight warmup instead of starting a duplicate creation.
// On failure the entry is KEPT as ready:false — resolve() stays undefined so
// consumers never receive a path that was never created; same-repo ensure()
// can retry creation once the failed warmup is cleared from pending.
if (initial && initialPending) {
const tracked = initialPending.then(
(entry) => {
pool.set(initial.prUrl, entry);
return entry;
},
(err) => {
pending.delete(initial.prUrl);
throw err;
},
);
pending.set(initial.prUrl, tracked);
creationChain = tracked.catch(() => {});
tracked
.then(() => pending.delete(initial.prUrl))
.catch(() => {}); // warmup may complete with nobody awaiting it
}
return {
get(prUrl) { return pool.get(prUrl); },
has(prUrl) { return pool.has(prUrl); },
resolve(prUrl) {
const entry = pool.get(prUrl);
return entry?.ready ? entry.path : undefined;
},
async ensure(runtime, metadata) {
const existing = pool.get(metadata.url);
if (existing?.ready) return existing;
const inflight = pending.get(metadata.url);
if (inflight) return inflight;
if (!config.isSameRepo) {
throw new Error("Cross-repo pool cannot create worktrees for other PRs");
}
const create = async (): Promise<PoolEntry> => {
const number = metadata.platform === "github" ? metadata.number : metadata.iid;
const worktreePath = join(config.sessionDir, "pool", `pr-${number}`);
const refSpec = metadata.platform === "github"
? `refs/pull/${number}/head`
: `refs/merge-requests/${number}/head`;
await fetchRef(runtime, metadata.baseBranch, { cwd: config.repoDir });
await ensureObjectAvailable(runtime, metadata.baseSha, { cwd: config.repoDir });
await fetchRef(runtime, refSpec, { cwd: config.repoDir });
await createWorktree(runtime, {
ref: "FETCH_HEAD",
path: worktreePath,
detach: true,
cwd: config.repoDir,
});
const entry: PoolEntry = { path: worktreePath, prUrl: metadata.url, number, ready: true };
pool.set(metadata.url, entry);
return entry;
};
const promise = creationChain.then(create, create);
creationChain = promise.catch(() => {});
pending.set(metadata.url, promise);
try {
return await promise;
} finally {
pending.delete(metadata.url);
}
},
entries() { return pool.values(); },
async cleanup(runtime) {
// Wait out in-flight creations first: a warmup or queued ensure() that
// finishes after the pool is cleared would resurrect its entry and
// orphan the worktree it just built.
while (pending.size > 0) {
await Promise.all([...pending.values()].map((p) => p.catch(() => {})));
}
for (const entry of pool.values()) {
await removeWorktree(runtime, entry.path, { force: true, cwd: config.repoDir });
}
pool.clear();
},
};
}