diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82e5e6a24..a82dbc0fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -307,7 +307,12 @@ jobs: - name: Test workspace packages and repo scripts (TS) env: OMP_TEST_CONCURRENCY: "4" - run: bun run ci:test:ts:workspace + run: | + bun run ci:test:ts:workspace + # Not `test:scripts`: scripts/musl-release.test.ts fails on main + # (its install.sh smoke-check executes a fake binary), so running + # the whole group here would red this job on an unrelated break. + bun test scripts/release.test.ts test_coding_agent_singleton: name: Test coding-agent singleton/global-state (TS) diff --git a/crates/pi-natives/src/fd.rs b/crates/pi-natives/src/fd.rs index b4c262e41..4272e94e9 100644 --- a/crates/pi-natives/src/fd.rs +++ b/crates/pi-natives/src/fd.rs @@ -3,7 +3,7 @@ //! Searches for files and directories whose paths match a query string via //! subsequence scoring. Uses `pi-walker` for directory traversal and caching. -use std::path::Path; +use std::{cmp::Ordering, collections::BinaryHeap, path::Path}; use napi::bindgen_prelude::*; use napi_derive::napi; @@ -159,6 +159,96 @@ fn path_depth(path: &str) -> usize { path.trim_end_matches('/').matches('/').count() } +/// A scored match carrying its precomputed depth, ordered worst-first. +/// +/// The ordering is the exact inverse of the final result comparator (score +/// descending, then `path_depth` ascending, then `path` ascending), so the +/// greatest element of a `BinaryHeap` is the candidate that must be +/// evicted first, and `into_sorted_vec` yields the final best-first order. +struct RankedMatch { + depth: usize, + entry: FuzzyFindMatch, +} + +impl RankedMatch { + fn new(entry: FuzzyFindMatch) -> Self { + let depth = path_depth(&entry.path); + Self { depth, entry } + } +} + +impl Ord for RankedMatch { + fn cmp(&self, other: &Self) -> Ordering { + other + .entry + .score + .cmp(&self.entry.score) + .then_with(|| self.depth.cmp(&other.depth)) + .then_with(|| self.entry.path.cmp(&other.entry.path)) + } +} + +impl PartialOrd for RankedMatch { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl PartialEq for RankedMatch { + fn eq(&self, other: &Self) -> bool { + self.cmp(other) == Ordering::Equal + } +} + +impl Eq for RankedMatch {} + +/// Bounded collector retaining at most `capacity` best matches while counting +/// every hit, so `totalMatches` stays exact even when it exceeds `maxResults`. +struct TopMatches { + capacity: usize, + total: u64, + heap: BinaryHeap, +} + +impl TopMatches { + fn new(capacity: usize) -> Self { + Self { capacity, total: 0, heap: BinaryHeap::with_capacity(capacity.min(256)) } + } + + fn push(&mut self, entry: FuzzyFindMatch) { + self.total = self.total.saturating_add(1); + if self.capacity == 0 { + return; + } + let candidate = RankedMatch::new(entry); + if self.heap.len() < self.capacity { + self.heap.push(candidate); + return; + } + // The root is the worst retained candidate; replace it only when the new + // candidate outranks it under the final comparator. + if self.heap.peek().is_some_and(|worst| candidate < *worst) { + self.heap.pop(); + self.heap.push(candidate); + } + } + + /// Exact number of scoring hits, clamped to the `u32` wire type. + fn total_matches(&self) -> u32 { + crate::utils::clamp_u32(self.total) + } + + /// Retained matches ordered by score descending, then shallower paths, then + /// path ascending. + fn into_sorted_matches(self) -> Vec { + self.heap + .into_sorted_vec() + .into_iter() + .map(|ranked| ranked.entry) + .collect() + } +} + struct FuzzyFindConfig { query: String, path: String, @@ -168,14 +258,18 @@ struct FuzzyFindConfig { cache: Option, } -fn score_entries( - entries: &[iofs::GlobMatch], +fn score_entries( + entries: I, query_lower: &str, normalized_query: &str, query_chars: &[char], + max_results: usize, ct: &task::CancelToken, -) -> Result> { - let mut scored = Vec::with_capacity(entries.len().min(256)); +) -> Result +where + I: IntoIterator, +{ + let mut scored = TopMatches::new(max_results); for entry in entries { ct.heartbeat()?; if entry.file_type == iofs::FileType::Symlink { @@ -189,7 +283,7 @@ fn score_entries( continue; } - let mut path = entry.path.clone(); + let mut path = entry.path; if is_directory { path.push('/'); } @@ -229,21 +323,17 @@ fn fuzzy_find_sync(config: FuzzyFindConfig, ct: task::CancelToken) -> Result = outcome - .entries - .into_iter() - .map(iofs::GlobMatch::from) - .collect(); - let mut scored = score_entries(&entries, &query_lower, &normalized_query, &query_chars, &ct)?; + let scored = score_entries( + outcome.entries.into_iter().map(iofs::GlobMatch::from), + &query_lower, + &normalized_query, + &query_chars, + max_results, + &ct, + )?; - scored.sort_by(|a, b| { - b.score - .cmp(&a.score) - .then_with(|| path_depth(&a.path).cmp(&path_depth(&b.path))) - .then_with(|| a.path.cmp(&b.path)) - }); - let total_matches = crate::utils::clamp_u32(scored.len() as u64); - let matches = scored.into_iter().take(max_results).collect(); + let total_matches = scored.total_matches(); + let matches = scored.into_sorted_matches(); Ok(FuzzyFindResult { matches, total_matches }) } @@ -381,4 +471,152 @@ mod tests { "expected cwd-root scripts/ to rank first, got {paths:?}" ); } + + #[cfg(unix)] + #[test] + fn fuzzy_find_reports_exact_total_beyond_max_results() { + let root = TempDirGuard::new(); + for index in 0..12 { + fs::write(root.path().join(format!("needle-{index}.txt")), "needle\n") + .expect("write fixture file"); + } + + let result = fuzzy_find_sync( + FuzzyFindConfig { + query: "needle".to_string(), + path: root.path().to_string_lossy().into_owned(), + hidden: Some(true), + gitignore: Some(false), + max_results: Some(3), + cache: Some(false), + }, + task::CancelToken::default(), + ) + .expect("fuzzy find succeeds"); + + assert_eq!(result.matches.len(), 3, "retained matches must honor maxResults"); + assert_eq!(result.total_matches, 12, "total must count every hit, not the retained ones"); + let paths: Vec<&str> = result + .matches + .iter() + .map(|entry| entry.path.as_str()) + .collect(); + assert_eq!( + paths, + vec!["needle-0.txt", "needle-1.txt", "needle-10.txt"], + "bounded retention must keep the same order as the full sort" + ); + } + + #[test] + fn bounded_retention_matches_reference_ordering_and_total() { + use super::{FuzzyFindMatch, TopMatches, path_depth}; + + // Score ties across depths and directories are the cases where a bounded + // heap can diverge from the full sort, so cover them explicitly. + let candidates = [ + ("packages/ai/scripts/", true, 130u32), + ("scripts/", true, 130), + (".omp/skills/opt/scripts/", true, 130), + ("src/scripts.ts", false, 120), + ("src/deep/nested/scripts.ts", false, 120), + ("a/scripts.ts", false, 120), + ("notes/script-notes.md", false, 80), + ("z.txt", false, 51), + ]; + + let mut reference: Vec<(u32, usize, String)> = candidates + .iter() + .map(|(path, _, score)| (*score, path_depth(path), (*path).to_string())) + .collect(); + reference.sort_by(|a, b| { + b.0.cmp(&a.0) + .then_with(|| a.1.cmp(&b.1)) + .then_with(|| a.2.cmp(&b.2)) + }); + + for max_results in 1..=candidates.len() + 2 { + let mut bounded = TopMatches::new(max_results); + for (path, is_directory, score) in candidates { + bounded.push(FuzzyFindMatch { path: path.to_string(), is_directory, score }); + } + let total = bounded.total_matches(); + let bounded_paths: Vec = bounded + .into_sorted_matches() + .into_iter() + .map(|entry| entry.path) + .collect(); + let expected_paths: Vec = reference + .iter() + .take(max_results) + .map(|(_, _, path)| path.clone()) + .collect(); + + assert_eq!(total, candidates.len() as u32, "total must count every pushed hit"); + assert_eq!( + bounded_paths, expected_paths, + "bounded order must match the full sort for max_results={max_results}" + ); + } + } + + #[test] + fn bounded_retention_matches_full_sort_on_large_corpus() { + use super::{FuzzyFindMatch, TopMatches, path_depth}; + + const CANDIDATE_COUNT: usize = 100_000; + const MAX_RESULTS: usize = 128; + + let mut reference = Vec::with_capacity(CANDIDATE_COUNT); + let mut bounded = TopMatches::new(MAX_RESULTS); + for index in 0..CANDIDATE_COUNT { + let depth = index % 7; + let path = format!("{}{index:06}-item.txt", "nested/".repeat(depth)); + let score = 50 + (index % 83) as u32; + reference.push((score, path_depth(&path), path.clone())); + bounded.push(FuzzyFindMatch { path, is_directory: false, score }); + assert!( + bounded.heap.len() <= MAX_RESULTS, + "retention exceeded maxResults after candidate {index}" + ); + } + assert_eq!(reference.len(), CANDIDATE_COUNT); + assert_eq!(bounded.heap.len(), MAX_RESULTS); + assert_eq!(bounded.total_matches(), CANDIDATE_COUNT as u32); + + reference.sort_by(|a, b| { + b.0.cmp(&a.0) + .then_with(|| a.1.cmp(&b.1)) + .then_with(|| a.2.cmp(&b.2)) + }); + let expected: Vec = reference + .into_iter() + .take(MAX_RESULTS) + .map(|(_, _, path)| path) + .collect(); + let actual: Vec = bounded + .into_sorted_matches() + .into_iter() + .map(|entry| entry.path) + .collect(); + + assert_eq!(actual, expected, "bounded top-K must match the complete baseline sort"); + } + + #[test] + fn bounded_retention_counts_hits_with_zero_capacity() { + use super::{FuzzyFindMatch, TopMatches}; + + let mut bounded = TopMatches::new(0); + for index in 0..5 { + bounded.push(FuzzyFindMatch { + path: format!("file-{index}.txt"), + is_directory: false, + score: 10, + }); + } + + assert_eq!(bounded.total_matches(), 5); + assert!(bounded.into_sorted_matches().is_empty()); + } } diff --git a/docs/bash-tool-runtime.md b/docs/bash-tool-runtime.md index a1f970e54..f10f0bc3d 100644 --- a/docs/bash-tool-runtime.md +++ b/docs/bash-tool-runtime.md @@ -37,7 +37,7 @@ The bash tool has the `exec` approval tier. `bash.patterns` rules can explicitly ## 2) Optional interception (blocked-command path) -If `bashInterceptor.enabled` is true, `BashTool` loads rules from settings (`getBashInterceptorRules()`) and runs `checkBashInterception()` against the command — checking both the original and the cwd-normalized form (after a leading `cd … &&` is extracted) when they differ. Rule syntax is unchanged: each rule checks the complete input first, then raw flat command fragments separated by unquoted/unescaped `&&`, `||`, `;`, `|`, `&`, or newlines, then those fragments with leading `NAME=value` assignments removed. +If `bashInterceptor.enabled` is true, `BashTool` loads rules from settings (`getBashInterceptorRules()`) and runs `checkBashInterception()` against the command — checking both the original and the cwd-normalized form (after a leading `cd … &&` is extracted) when they differ. Rule syntax is unchanged: each rule checks the complete input first, then raw flat command fragments separated by unquoted/unescaped `&&`, `||`, `;`, `|`, `|&`, `&`, or newlines, then those fragments with leading `NAME=value` assignments removed. Fragments that receive piped stdin from `|` or `|&` are excluded from the fragment candidates, including across blank/comment continuation lines, because a stdin-consuming stage cannot be replaced by a path-based dedicated tool. Interception behavior: diff --git a/docs/mcp-config.md b/docs/mcp-config.md index c76b4badb..806d41118 100644 --- a/docs/mcp-config.md +++ b/docs/mcp-config.md @@ -39,7 +39,7 @@ OMP also translates these current tool-native sources: - VS Code: project-only `.vscode/mcp.json` using `mcp.servers` - installed Claude marketplace plugins and OMP extension packages that declare MCP servers -For translated providers with both scopes, a same-named user entry is encountered before its project entry. OMP-native config is the exception: its project entry precedes its active-profile user entry. Cross-provider priority is listed in [Discovery and precedence](#discovery-and-precedence). +For Claude Code, Codex, Gemini CLI, Cursor, and Windsurf, the project entry is encountered before its same-named user entry — matching OMP-native config, whose project entry precedes its active-profile user entry — so a project `enabled: false` suppresses a same-named user server. OpenCode currently encounters the user entry first. Cross-provider priority is listed in [Discovery and precedence](#discovery-and-precedence). ### Profiles diff --git a/docs/python-repl.md b/docs/python-repl.md index e5b940739..2fac77bbb 100644 --- a/docs/python-repl.md +++ b/docs/python-repl.md @@ -146,7 +146,7 @@ The backend settings `eval.py` / `eval.js` default to `true`; `eval.rb` / `eval. The tool's session-scoped schema lists only enabled runtimes. If Python preflight fails while another runtime is enabled, `eval` remains available for that runtime and a `py` call reports a Python-backend availability error with enabled alternatives. -Python prelude helpers include `agent(prompt, *, agent="task", model=None, label=None, schema=None, schema_mode=None, isolated=None, apply=None, merge=None, handle=False)`. It synchronously calls the host bridge and returns final text, or parsed data when `schema` is supplied. `schema_mode` selects permissive or strict structured-output handling; the isolation/apply/merge flags control task worktree behavior. With `handle=True`, it returns a DAG node dict (`{"text", "output", "handle", "id", "agent"}`) whose handle is the recoverable `agent://` URI; parsed output is also stored under `"data"` when available. +Python prelude helpers include `agent(prompt, *, agent="task", label=None, schema=None, schema_mode=None, isolated=None, apply=None, merge=None, handle=False)`. It synchronously calls the host bridge and returns final text, or parsed data when `schema` is supplied. `schema_mode` selects permissive or strict structured-output handling; the isolation/apply/merge flags control task worktree behavior. With `handle=True`, it returns a DAG node dict (`{"text", "output", "handle", "id", "agent"}`) whose handle is the recoverable `agent://` URI; parsed output is also stored under `"data"` when available. ## Execution flow and cancellation/timeout diff --git a/docs/tools/bash.md b/docs/tools/bash.md index 6ef043b69..fb4340381 100644 --- a/docs/tools/bash.md +++ b/docs/tools/bash.md @@ -111,7 +111,7 @@ git add file && git commit -m "message" GIT_AUTHOR_NAME=Dev git commit -m "message" ``` -An anchored rule such as `^\s*git\s+commit\b` can therefore match the `git commit` command in both examples. Quoted, escaped, and commented text is not treated as a command. Heredocs, parameter expansion, command substitution, backticks, grouping, and malformed quoting retain only the complete-command check; the interceptor deliberately does not attempt to become a full shell parser. +An anchored rule such as `^\s*git\s+commit\b` can therefore match the `git commit` command in both examples. A stage that consumes another command's stdout through an unquoted `|` or `|&` (for example `grep x` in `printf 'x\n' | grep x`) is **not** treated as an interception candidate: it reads piped stdin, which the path-based dedicated tools cannot supply, so only a standalone or first-stage command is matched. Blank and comment-only continuation lines after the pipe preserve that context. Quoted, escaped, and commented text is not treated as a command. Heredocs, parameter expansion, command substitution, backticks, grouping, and malformed quoting retain only the complete-command check; the interceptor deliberately does not attempt to become a full shell parser. ### Interaction and selection guide @@ -127,7 +127,7 @@ Choose the setting by the desired outcome: 1. `BashTool.execute()` in `packages/coding-agent/src/tools/bash.ts` reads `command`, validates `env`, and defaults `timeout` to `300`. 2. If `cwd` is absent, it rewrites a leading `cd && ...` into the structured `cwd` field and strips that prefix from `command`. 3. If `async: true` is requested while `async.enabled` is off, it throws `ToolError` before any execution. -4. If `bashInterceptor.enabled` is on, `checkBashInterception()` runs against both the original command and the `cd`-stripped command. For each form, configured regexes still check the complete input first, then each flat command separated by unquoted/unescaped `&&`, `||`, `;`, `|`, `&`, or newlines, followed by versions of those fragments without leading `NAME=value` assignments. A matching enabled rule throws before URL expansion or execution. +4. If `bashInterceptor.enabled` is on, `checkBashInterception()` runs against both the original command and the `cd`-stripped command. For each form, configured regexes still check the complete input first, then each flat command separated by unquoted/unescaped `&&`, `||`, `;`, `|`, `|&`, `&`, or newlines (excluding stages that consume piped stdin from `|` or `|&`, including across blank/comment continuations), followed by versions of those fragments without leading `NAME=value` assignments. A matching enabled rule throws before URL expansion or execution. 5. `expandInternalUrls()` rewrites supported internal URLs inside `command`, each `env` value, and protocol-looking `cwd` values. Command replacements are shell-escaped; `env` and `cwd` replacements use raw filesystem/string values because they are not interpolated into shell text. 6. `resolveToCwd()` resolves `cwd` against `session.cwd`; `fs.stat()` verifies that the target exists and is a directory. 7. `timeout: 0` disables the deadline. Otherwise `clampTimeout("bash", requestedTimeoutSec, tools.maxTimeout)` applies a positive global ceiling (when configured), then `TOOL_TIMEOUTS.bash` (`min: 1`, `max: 3600`). When clamped, `#buildCompletedResult()` / `#buildBackgroundStartResult()` append a notice line. diff --git a/docs/tools/eval.md b/docs/tools/eval.md index 26347a1e5..5c4760a1f 100644 --- a/docs/tools/eval.md +++ b/docs/tools/eval.md @@ -149,9 +149,9 @@ A stateless, tool-free one-shot model call: Runs one subagent through `runStructuredSubagent(...)`: -- JS supports the preferred `await agent(prompt, { agent?, model?, label?, schema?, schemaMode?, isolated?, apply?, merge?, handle? })`; legacy positional slots are still implemented. +- JS supports the preferred `await agent(prompt, { agent?, label?, schema?, schemaMode?, isolated?, apply?, merge?, handle? })`; legacy positional slots are still implemented. - Python/Ruby/Julia use keyword arguments (`schema_mode` outside JS). -- `agent` defaults from the current spawn policy. `model` may pin a selector/fallback chain. `schema` overrides agent/session schemas; `schemaMode`/`schema_mode` chooses `permissive` or `strict`. +- `agent` defaults from the current spawn policy; the selected agent's frontmatter model and settings always apply (there is no per-call model override — `model` is not accepted). `schema` overrides agent/session schemas; `schemaMode`/`schema_mode` chooses `permissive` or `strict`. - `isolated` requests isolation. `apply` controls whether captured changes are integrated; `merge=false` selects patch mode while the normal setting controls branch mode. - `handle=true` returns `{ text, output, handle, id, agent }`, optional parsed `data`, and isolation metadata instead of only output/data. - Eval subagents are one-shot (`keepAlive=false`), are unregistered/disposed after completion, and **do not share the caller's eval executor** (`shareEvalSession=false`). Their code mutations therefore do not appear in the caller's retained VM/kernel. diff --git a/package.json b/package.json index e763563f7..c3f0dc7a2 100644 --- a/package.json +++ b/package.json @@ -117,7 +117,7 @@ "build:native": "bun --cwd=packages/natives run build", "test": "bun scripts/ci-test-ts.ts local", "test:ts": "bun scripts/ci-test-ts.ts local-ts", - "test:scripts": "bun test scripts/ci-release-build-binaries.test.ts scripts/musl-release.test.ts scripts/ci-release-publish.test.ts", + "test:scripts": "bun test scripts/ci-release-build-binaries.test.ts scripts/musl-release.test.ts scripts/ci-release-publish.test.ts scripts/release.test.ts", "test:rs": "bun scripts/run-rs-task.ts test:rs", "check": "bun run --parallel check:ts check:rs", "check:ts": "bun run check:tools && bun run --workspaces --if-present check", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 844d6f388..de1366ec0 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Preserved queued steering and follow-up messages when a continuation is cancelled before or during pre-dequeue hooks, and propagated the caller's cancellation signal through every continuation model-call loop. + ## [17.2.6] - 2026-08-03 ### Fixed diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index fbce2f756..9cd8b1671 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -1016,7 +1016,7 @@ async function runLoopBody( // Skip when the run is already externally aborted — dequeuing would strand // the messages in a run that is about to die. try { - pendingMessages = signal?.aborted ? [] : (await config.getSteeringMessages?.()) || []; + pendingMessages = signal?.aborted ? [] : (await config.getSteeringMessages?.(signal)) || []; } catch (error) { stream.push({ type: "turn_start" }); emitInputMessages(stream, messagesToEmit); @@ -1075,7 +1075,7 @@ async function runLoopBody( let gateResult: AgentPreModelCallResult; try { if (config.syncContextBeforeModelCall) { - await config.syncContextBeforeModelCall(currentContext); + await config.syncContextBeforeModelCall(currentContext, signal); } if (!directiveResolvedForTurn) { @@ -1421,7 +1421,7 @@ async function runLoopBody( // instantly aborts — message lands in history, agent never responds. The // mid-batch interrupt poll only peeks (hasSteeringMessages), so the queue // still owns every message until this dequeue. - const steering = signal?.aborted ? [] : (await config.getSteeringMessages?.()) || []; + const steering = signal?.aborted ? [] : (await config.getSteeringMessages?.(signal)) || []; if (hasMoreToolCalls) { // Mid-work: fold any non-interrupting asides into the next turn alongside steering. const asides = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.()); @@ -1450,9 +1450,9 @@ async function runLoopBody( // Re-poll steering too: a steer can land between the stop-boundary dequeue // above and this yield point (e.g. queued while onBeforeYield ran). Without // this poll it would strand in the queue until the next manual prompt. - const lateSteering = signal?.aborted ? [] : (await config.getSteeringMessages?.()) || []; + const lateSteering = signal?.aborted ? [] : (await config.getSteeringMessages?.(signal)) || []; const asideMessages = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.()); - const followUpMessages = signal?.aborted ? [] : (await config.getFollowUpMessages?.()) || []; + const followUpMessages = signal?.aborted ? [] : (await config.getFollowUpMessages?.(signal)) || []; if (lateSteering.length > 0 || asideMessages.length > 0 || followUpMessages.length > 0) { // Set as pending so the inner loop processes them before stopping. pendingMessages = [...lateSteering, ...asideMessages, ...followUpMessages]; diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 8a17735c0..c2e779a7e 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -426,6 +426,8 @@ export class Agent { #asideMessageProvider?: () => AsideMessage[] | Promise; #telemetry?: AgentLoopConfig["telemetry"]; #appendOnlyContext?: AppendOnlyContextManager; + #beforeQueuedMessageDequeueHooks = new Set<(signal?: AbortSignal) => Promise | void>(); + #beforeModelCallHooks = new Set<(signal?: AbortSignal) => Promise | void>(); /** Buffered Cursor tool results with text length at time of call (for correct ordering) */ #cursorToolResultBuffer: CursorToolResultEntry[] = []; @@ -784,6 +786,40 @@ export class Agent { return () => this.#listeners.delete(fn); } + /** Register an independently removable hook that runs before queued messages are consumed. */ + addBeforeQueuedMessageDequeueHook(hook: (signal?: AbortSignal) => Promise | void): () => void { + const registration = (signal?: AbortSignal) => hook(signal); + this.#beforeQueuedMessageDequeueHooks.add(registration); + return () => this.#beforeQueuedMessageDequeueHooks.delete(registration); + } + + /** Register an independently removable hook that runs immediately before each model call. */ + addBeforeModelCallHook(hook: (signal?: AbortSignal) => Promise | void): () => void { + const registration = (signal?: AbortSignal) => hook(signal); + this.#beforeModelCallHooks.add(registration); + return () => this.#beforeModelCallHooks.delete(registration); + } + + async #runBeforeModelCallHooks(signal?: AbortSignal): Promise { + for (const hook of this.#beforeModelCallHooks) await hook(signal); + } + + async #runBeforeQueuedMessageDequeueHooks(signal?: AbortSignal): Promise { + for (const hook of this.#beforeQueuedMessageDequeueHooks) await hook(signal); + } + + async #dequeueSteeringMessagesAfterHooks(signal?: AbortSignal): Promise { + if (signal?.aborted || this.#steeringQueue.length === 0) return []; + await this.#runBeforeQueuedMessageDequeueHooks(signal); + return signal?.aborted ? [] : this.#dequeueSteeringMessages(); + } + + async #dequeueFollowUpMessagesAfterHooks(signal?: AbortSignal): Promise { + if (signal?.aborted || this.#followUpQueue.length === 0) return []; + await this.#runBeforeQueuedMessageDequeueHooks(signal); + return signal?.aborted ? [] : this.#dequeueFollowUpMessages(); + } + setProviderResponseInterceptor(fn: SimpleStreamOptions["onResponse"] | undefined): void { this.#onResponse = fn; } @@ -1137,48 +1173,90 @@ export class Agent { /** * Continue from current context (used for retries and resuming queued messages). */ - async continue() { + #continuationDequeueSignal(signal?: AbortSignal): AbortSignal | undefined { + const signals: AbortSignal[] = []; + if (this.#abortController) signals.push(this.#abortController.signal); + if (signal) signals.push(signal); + if (this.#deadline !== undefined) { + const delay = this.#deadline - Date.now(); + if (delay <= 0) { + const controller = new AbortController(); + controller.abort(new DOMException("Deadline exceeded", "TimeoutError")); + signals.push(controller.signal); + } else { + signals.push(AbortSignal.timeout(delay)); + } + } + if (signals.length === 0) return undefined; + return signals.length === 1 ? signals[0] : AbortSignal.any(signals); + } + + async continue(signal?: AbortSignal) { if (this.#state.isStreaming) { throw new AgentBusyError(); } - const messages = this.#state.messages; - if (messages.length === 0) { - // An empty transcript has nothing to resume, but a queued steer/follow-up - // must still be delivered as the opening turn — mirroring the assistant-tail - // branch below. Throwing here leaves the message undeliverable, and idle-drain - // callers (AgentSession#scheduleQueuedMessageDrain) re-arm continue() on every - // microtask because hasQueuedMessages() never clears, spinning an unbounded - // allocation loop until OOM (issue #6344). - const queuedSteering = this.#dequeueSteeringMessages(); - if (queuedSteering.length > 0) { - await this.#runLoop(queuedSteering, { skipInitialSteeringPoll: true }); - return; + const { promise, resolve } = Promise.withResolvers(); + this.#runningPrompt = promise; + this.#resolveRunningPrompt = resolve; + const continuationAbortController = new AbortController(); + this.#abortController = continuationAbortController; + this.#state.isStreaming = true; + this.#state.streamMessage = null; + this.#state.error = undefined; + + try { + const dequeueSignal = this.#continuationDequeueSignal(signal); + const messages = this.#state.messages; + if (messages.length === 0) { + // An empty transcript has nothing to resume, but a queued steer/follow-up + // must still be delivered as the opening turn — mirroring the assistant-tail + // branch below. Throwing here leaves the message undeliverable, and idle-drain + // callers (AgentSession#scheduleQueuedMessageDrain) re-arm continue() on every + // microtask because hasQueuedMessages() never clears, spinning an unbounded + // allocation loop until OOM (issue #6344). + const queuedSteering = await this.#dequeueSteeringMessagesAfterHooks(dequeueSignal); + if (queuedSteering.length > 0) { + await this.#runLoop(queuedSteering, { skipInitialSteeringPoll: true }, signal, true); + return; + } + const queuedFollowUp = await this.#dequeueFollowUpMessagesAfterHooks(dequeueSignal); + if (queuedFollowUp.length > 0) { + await this.#runLoop(queuedFollowUp, undefined, signal, true); + return; + } + throw new Error("No messages to continue from"); } - const queuedFollowUp = this.#dequeueFollowUpMessages(); - if (queuedFollowUp.length > 0) { - await this.#runLoop(queuedFollowUp); - return; + if (messages[messages.length - 1].role === "assistant") { + const queuedSteering = await this.#dequeueSteeringMessagesAfterHooks(dequeueSignal); + if (queuedSteering.length > 0) { + await this.#runLoop(queuedSteering, { skipInitialSteeringPoll: true }, signal, true); + return; + } + + const queuedFollowUp = await this.#dequeueFollowUpMessagesAfterHooks(dequeueSignal); + if (queuedFollowUp.length > 0) { + await this.#runLoop(queuedFollowUp, undefined, signal, true); + return; + } + + throw new Error("Cannot continue from message role: assistant"); + } + + await this.#runLoop(undefined, undefined, signal, true); + } finally { + resolve(); + if (this.#abortController === continuationAbortController) { + this.#state.isStreaming = false; + this.#state.streamMessage = null; + this.#state.pendingToolCalls.clear(); + this.#abortController = undefined; + if (this.#runningPrompt === promise) { + this.#runningPrompt = undefined; + this.#resolveRunningPrompt = undefined; + } } - throw new Error("No messages to continue from"); } - if (messages[messages.length - 1].role === "assistant") { - const queuedSteering = this.#dequeueSteeringMessages(); - if (queuedSteering.length > 0) { - await this.#runLoop(queuedSteering, { skipInitialSteeringPoll: true }); - return; - } - - const queuedFollowUp = this.#dequeueFollowUpMessages(); - if (queuedFollowUp.length > 0) { - await this.#runLoop(queuedFollowUp); - return; - } - - throw new Error("Cannot continue from message role: assistant"); - } - - await this.#runLoop(undefined); } /** @@ -1186,17 +1264,29 @@ export class Agent { * If messages are provided, starts a new conversation turn with those messages. * Otherwise, continues from existing context. */ - async #runLoop(messages?: AgentMessage[], options?: AgentPromptOptions & { skipInitialSteeringPoll?: boolean }) { + async #runLoop( + messages?: AgentMessage[], + options?: AgentPromptOptions & { skipInitialSteeringPoll?: boolean }, + continuationSignal?: AbortSignal, + runStateClaimed = false, + ) { const model = this.#state.model; if (!model) throw new Error("No model configured"); let skipInitialSteeringPoll = options?.skipInitialSteeringPoll === true; using _ = new EventLoopKeepalive(); - const { promise, resolve } = Promise.withResolvers(); - this.#runningPrompt = promise; - this.#resolveRunningPrompt = resolve; - - this.#abortController = new AbortController(); + if (!runStateClaimed) { + const { promise, resolve } = Promise.withResolvers(); + this.#runningPrompt = promise; + this.#resolveRunningPrompt = resolve; + this.#abortController = new AbortController(); + } + const resolveRun = this.#resolveRunningPrompt; + const loopAbortController = this.#abortController; + if (!loopAbortController) throw new Error("Agent run state was not initialized"); + const loopSignal = continuationSignal + ? AbortSignal.any([loopAbortController.signal, continuationSignal]) + : loopAbortController.signal; this.#state.isStreaming = true; this.#state.streamMessage = null; this.#state.error = undefined; @@ -1315,7 +1405,8 @@ export class Agent { onSseEvent: this.#onSseEvent, getApiKey: this.getApiKey, getToolContext: this.#getToolContext, - syncContextBeforeModelCall: async context => { + syncContextBeforeModelCall: async (context, signal) => { + await this.#runBeforeModelCallHooks(signal); if (this.#listeners.size > 0) { await Bun.sleep(0); } @@ -1362,12 +1453,12 @@ export class Agent { getReasoning: () => this.#state.thinkingLevel, getDisableReasoning: () => this.#state.disableReasoning, getServiceTier: this.#serviceTierResolver, - getSteeringMessages: async () => { + getSteeringMessages: async signal => { if (skipInitialSteeringPoll) { skipInitialSteeringPoll = false; return []; } - return this.#dequeueSteeringMessages(); + return this.#dequeueSteeringMessagesAfterHooks(signal); }, hasSteeringMessages: () => { if (this.#steeringQueue.length === 0) { @@ -1392,7 +1483,7 @@ export class Agent { }, waitForSteeringMessages: signal => this.#waitForSteeringMessages(signal), hasIrcInterrupts: this.hasIrcInterrupts, - getFollowUpMessages: async () => this.#dequeueFollowUpMessages(), + getFollowUpMessages: signal => this.#dequeueFollowUpMessagesAfterHooks(signal), getAsideMessages: async () => (await this.#asideMessageProvider?.()) ?? [], onBeforeYield: () => this.#onBeforeYield?.(), telemetry: this.#telemetry, @@ -1404,8 +1495,8 @@ export class Agent { try { const stream = messages - ? agentLoop(messages, context, config, this.#abortController.signal, this.streamFn) - : agentLoopContinue(context, config, this.#abortController.signal, this.streamFn); + ? agentLoop(messages, context, config, loopSignal, this.streamFn) + : agentLoopContinue(context, config, loopSignal, this.streamFn); for await (const event of stream) { if (event.type === "turn_start") turnOpen = true; @@ -1472,15 +1563,15 @@ export class Agent { if (!onlyEmpty) { this.appendMessage(partial); } else { - if (this.#abortController?.signal.aborted) { + if (loopSignal.aborted) { throw new Error("Request was aborted"); } } } } catch (err) { - const stoppedForAbort = this.#abortController?.signal.aborted === true; + const stoppedForAbort = loopSignal.aborted; const errorMessage = stoppedForAbort - ? abortReasonText(this.#abortController?.signal) + ? abortReasonText(loopSignal) : err instanceof Error ? err.message : String(err); @@ -1582,13 +1673,15 @@ export class Agent { this.#emit({ type: "agent_end", messages: [errorMsg] }); } } finally { - this.#state.isStreaming = false; - this.#state.streamMessage = null; - this.#state.pendingToolCalls.clear(); - this.#abortController = undefined; - this.#resolveRunningPrompt?.(); - this.#runningPrompt = undefined; - this.#resolveRunningPrompt = undefined; + resolveRun?.(); + if (this.#abortController === loopAbortController) { + this.#state.isStreaming = false; + this.#state.streamMessage = null; + this.#state.pendingToolCalls.clear(); + this.#abortController = undefined; + this.#runningPrompt = undefined; + this.#resolveRunningPrompt = undefined; + } } } diff --git a/packages/agent/src/compaction/errors.ts b/packages/agent/src/compaction/errors.ts index ed34484dd..6a44a86f5 100644 --- a/packages/agent/src/compaction/errors.ts +++ b/packages/agent/src/compaction/errors.ts @@ -11,7 +11,7 @@ */ export class CompactionCancelledError extends Error { - readonly name = "CompactionCancelledError" as const; + override readonly name = "CompactionCancelledError" as const; constructor(message = "Compaction cancelled") { super(message); @@ -27,7 +27,7 @@ export class CompactionCancelledError extends Error { * ordinary summarization errors and must not fall through to another provider. */ export class NativeCompactionError extends Error { - readonly name = "NativeCompactionError" as const; + override readonly name = "NativeCompactionError" as const; constructor(cause: unknown) { super(cause instanceof Error ? cause.message : String(cause), { cause }); diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index de90a681e..198c9a4a3 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -240,7 +240,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions { * mid-batch interrupt poll uses {@link hasSteeringMessages} instead and * never consumes the queue. */ - getSteeringMessages?: () => Promise; + getSteeringMessages?: (signal?: AbortSignal) => Promise; /** * Peeks whether steering messages are queued, without consuming them. @@ -285,7 +285,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions { * If messages are returned, they're added to the context and the agent * continues with another turn. */ - getFollowUpMessages?: () => Promise; + getFollowUpMessages?: (signal?: AbortSignal) => Promise; /** * Returns non-interrupting "aside" messages to inject at a step boundary. * @@ -319,7 +319,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions { * Mutate the agent context here; use `beforeModelCall` to inspect the * provider-bound context. */ - syncContextBeforeModelCall?: (context: AgentContext) => void | Promise; + syncContextBeforeModelCall?: (context: AgentContext, signal?: AbortSignal) => void | Promise; /** * Asked after the complete provider context has been built, including diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index a887758c8..1f4604efe 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { Agent, type AgentEvent, type AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; +import { Agent, AgentBusyError, type AgentEvent, type AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import { type SimpleStreamOptions, type ToolResultMessage, z } from "@oh-my-pi/pi-ai"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; import { kCursorExecResolved } from "@oh-my-pi/pi-ai/utils/block-symbols"; @@ -240,6 +240,213 @@ describe("Agent", () => { } }); + it("removes duplicate queued-message hooks independently", async () => { + const mock = createMockModel({ responses: [{ content: ["first"] }, { content: ["second"] }] }); + const agent = new Agent({ streamFn: mock.stream }); + agent.replaceMessages([createAssistantMessage([{ type: "text", text: "ready" }])]); + let calls = 0; + const signals: Array = []; + const hook = (signal?: AbortSignal) => { + calls++; + signals.push(signal); + }; + const removeFirst = agent.addBeforeQueuedMessageDequeueHook(hook); + const removeSecond = agent.addBeforeQueuedMessageDequeueHook(hook); + + const controller = new AbortController(); + removeFirst(); + agent.followUp({ role: "user", content: "first turn", timestamp: Date.now() }); + await agent.continue(controller.signal); + expect(calls).toBe(1); + expect(signals).toEqual([controller.signal]); + + removeSecond(); + agent.followUp({ role: "user", content: "second turn", timestamp: Date.now() }); + await agent.continue(); + expect(calls).toBe(1); + }); + + it("continue() leaves queued messages owned when its signal is already aborted", async () => { + const agent = new Agent(); + agent.replaceMessages([createAssistantMessage([{ type: "text", text: "ready" }])]); + agent.followUp({ role: "user", content: "stay queued", timestamp: Date.now() }); + const controller = new AbortController(); + controller.abort(); + + await expect(agent.continue(controller.signal)).rejects.toThrow("Cannot continue from message role: assistant"); + expect(agent.peekFollowUpQueue()).toHaveLength(1); + }); + it("keeps follow-up ownership when the deadline expires during a dequeue hook", async () => { + const mock = createMockModel({ responses: [{ content: ["done"] }] }); + const agent = new Agent({ streamFn: mock.stream, deadline: Date.now() + 25 }); + let hookSignal: AbortSignal | undefined; + agent.addBeforeQueuedMessageDequeueHook(async signal => { + if (!signal) throw new Error("Expected the active loop signal"); + hookSignal = signal; + if (signal.aborted) return; + const { promise, resolve } = Promise.withResolvers(); + signal.addEventListener("abort", () => resolve(), { once: true }); + await promise; + }); + agent.followUp({ role: "user", content: "stay queued after deadline", timestamp: Date.now() }); + + await agent.prompt("start"); + + expect(hookSignal?.aborted).toBe(true); + expect(agent.peekFollowUpQueue()).toHaveLength(1); + }); + it("keeps queued work when continue() reaches its deadline inside a dequeue hook", async () => { + const agent = new Agent({ deadline: Date.now() + 25 }); + agent.replaceMessages([createAssistantMessage([{ type: "text", text: "ready" }])]); + agent.addBeforeQueuedMessageDequeueHook(async signal => { + if (!signal) throw new Error("Expected the deadline-aware dequeue signal"); + if (signal.aborted) return; + const { promise, resolve } = Promise.withResolvers(); + signal.addEventListener("abort", () => resolve(), { once: true }); + await promise; + }); + agent.followUp({ role: "user", content: "stay queued before run loop", timestamp: Date.now() }); + + await expect(agent.continue()).rejects.toThrow("Cannot continue from message role: assistant"); + + expect(agent.peekFollowUpQueue()).toHaveLength(1); + }); + + it("claims an abortable busy state while continue() awaits dequeue hooks", async () => { + const agent = new Agent(); + agent.replaceMessages([createAssistantMessage([{ type: "text", text: "ready" }])]); + agent.followUp({ role: "user", content: "stay queued", timestamp: Date.now() }); + const hookStarted = Promise.withResolvers(); + agent.addBeforeQueuedMessageDequeueHook(async signal => { + if (!signal) throw new Error("Expected continuation dequeue signal"); + hookStarted.resolve(); + if (signal.aborted) return; + const { promise, resolve } = Promise.withResolvers(); + signal.addEventListener("abort", () => resolve(), { once: true }); + await promise; + }); + + const continuing = agent.continue(); + await hookStarted.promise; + let idleResolved = false; + const idle = agent.waitForIdle().then(() => { + idleResolved = true; + }); + await Promise.resolve(); + + expect(agent.state.isStreaming).toBe(true); + expect(idleResolved).toBe(false); + await expect(agent.prompt("must not overlap")).rejects.toBeInstanceOf(AgentBusyError); + + agent.abort("cancel dequeue"); + await expect(continuing).rejects.toThrow("Cannot continue from message role: assistant"); + await idle; + expect(idleResolved).toBe(true); + expect(agent.state.isStreaming).toBe(false); + expect(agent.peekFollowUpQueue()).toHaveLength(1); + }); + + it("does not clear a successor prompt after continue() releases idle waiters", async () => { + const firstStarted = Promise.withResolvers(); + const releaseFirst = Promise.withResolvers(); + const secondStarted = Promise.withResolvers(); + const releaseSecond = Promise.withResolvers(); + const mock = createMockModel({ + responses: [ + async () => { + firstStarted.resolve(); + await releaseFirst.promise; + return { content: ["continued"] }; + }, + async () => { + secondStarted.resolve(); + await releaseSecond.promise; + return { content: ["successor"] }; + }, + ], + }); + const agent = new Agent({ streamFn: mock.stream }); + agent.replaceMessages([createAssistantMessage([{ type: "text", text: "ready" }])]); + agent.followUp({ role: "user", content: "continue", timestamp: Date.now() }); + + const continuing = agent.continue(); + await firstStarted.promise; + const successor = agent.waitForIdle().then(() => agent.prompt("next prompt")); + releaseFirst.resolve(); + await secondStarted.promise; + await continuing; + + expect(agent.state.isStreaming).toBe(true); + releaseSecond.resolve(); + await successor; + expect(agent.state.isStreaming).toBe(false); + }); + + it("resolves a predecessor idle waiter when agent_end starts a successor", async () => { + const secondStarted = Promise.withResolvers(); + const releaseSecond = Promise.withResolvers(); + const mock = createMockModel({ + responses: [ + { content: ["first"] }, + async () => { + secondStarted.resolve(); + await releaseSecond.promise; + return { content: ["second"] }; + }, + ], + }); + const agent = new Agent({ streamFn: mock.stream }); + let successor: Promise | undefined; + agent.subscribe(event => { + if (event.type === "agent_end" && !successor) { + successor = agent.prompt("successor"); + } + }); + + const predecessor = agent.prompt("predecessor"); + let predecessorIdleResolved = false; + void agent.waitForIdle().then(() => { + predecessorIdleResolved = true; + }); + await secondStarted.promise; + await predecessor; + expect(agent.state.isStreaming).toBe(true); + + releaseSecond.resolve(); + await successor; + await Promise.resolve(); + expect(predecessorIdleResolved).toBe(true); + expect(agent.state.isStreaming).toBe(false); + }); + + it("classifies an in-flight continuation cancellation as aborted", async () => { + const providerStarted = Promise.withResolvers(); + const agent = new Agent({ + streamFn: (_model, _context, options) => { + const signal = options?.signal; + if (!signal) throw new Error("Expected provider abort signal"); + providerStarted.resolve(signal); + const stream = new AssistantMessageEventStream(); + signal.addEventListener("abort", () => stream.fail(new Error("provider aborted")), { once: true }); + return stream; + }, + }); + agent.replaceMessages([createAssistantMessage([{ type: "text", text: "ready" }])]); + agent.followUp({ role: "user", content: "cancel this continuation", timestamp: Date.now() }); + const controller = new AbortController(); + + const running = agent.continue(controller.signal); + await providerStarted.promise; + controller.abort("caller cancelled"); + await running; + + const finalMessage = agent.state.messages.at(-1); + expect(finalMessage?.role).toBe("assistant"); + if (finalMessage?.role !== "assistant") throw new Error("Expected aborted assistant message"); + expect(finalMessage.stopReason).toBe("aborted"); + expect(finalMessage.errorMessage).toBe("caller cancelled"); + }); + it("continue() should process queued follow-up messages after an assistant turn", async () => { const mock = createMockModel({ responses: [{ content: ["Processed"] }] }); const agent = new Agent({ streamFn: mock.stream }); @@ -276,6 +483,12 @@ describe("Agent", () => { responses: [{ content: ["Processed 1"] }, { content: ["Processed 2"] }], }); const agent = new Agent({ streamFn: mock.stream }); + let dequeueHooks = 0; + const dequeueSignals: Array = []; + agent.addBeforeQueuedMessageDequeueHook(signal => { + dequeueHooks++; + dequeueSignals.push(signal); + }); agent.replaceMessages([ { @@ -297,11 +510,16 @@ describe("Agent", () => { timestamp: Date.now() + 1, }); - await expect(agent.continue()).resolves.toBeUndefined(); + const controller = new AbortController(); + await expect(agent.continue(controller.signal)).resolves.toBeUndefined(); const recentMessages = agent.state.messages.slice(-4); expect(recentMessages.map(m => m.role)).toEqual(["user", "assistant", "user", "assistant"]); expect(mock.calls.length).toBe(2); + expect(dequeueHooks).toBe(2); + expect(dequeueSignals).toHaveLength(2); + controller.abort(); + expect(dequeueSignals.every(signal => signal?.aborted === true)).toBe(true); }); it("delivers a steer that lands at the yield boundary instead of stranding it", async () => { @@ -856,6 +1074,10 @@ describe("Agent", () => { }, streamFn: mock.stream, }); + let beforeModelCalls = 0; + agent.addBeforeModelCallHook(() => { + beforeModelCalls++; + }); const unsubscribe = agent.subscribe(event => { if (event.type === "message_end" && event.message.role === "toolResult") { @@ -875,6 +1097,7 @@ describe("Agent", () => { { systemPrompt: "prompt-one", toolNames: ["alpha"] }, { systemPrompt: "prompt-two", toolNames: ["alpha", "beta"] }, ]); + expect(beforeModelCalls).toBe(2); }); it("prompt() drops stale forced toolChoice after same-turn tool refresh", async () => { diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 15c9f504e..04a42d13b 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -5,6 +5,11 @@ ### Fixed - Fixed GitHub Copilot requests failing with a raw `HTTP 400 model_not_available_for_integrator` on roughly half of all turns for recently rolled-out models. Copilot's fleet is not uniform — part of it rejects models that `/models` advertises on the same host — and the transient classifier matched only the older `model_not_supported` code at a fixed envelope depth, so these rejections surfaced as terminal errors instead of entering the existing retry path. Model-availability 400s are now recognized at any envelope depth and rerolled on a flat delay with a dedicated 8-attempt budget on the OpenAI transports; every other retryable failure keeps its previous backoff and attempt count. +- Fixed Cursor reads with inline OMP range selectors reporting the returned slice length as the source file's `totalLines`, which made sequential reads of an unchanged file appear inconsistent ([#7590](https://github.com/can1357/oh-my-pi/issues/7590)). +- Made model-scoped usage health ignore Codex accounts that cannot use the requested plan-gated model while retaining conservative unknown-state handling and independent usage-window resets. +- Fixed OpenAI Codex usage telemetry blocking explicitly allowed ChatGPT Team credentials when a weekly `used_percent` rounded to 100, which could route multi-account sessions to an actually exhausted sibling instead ([#7617](https://github.com/can1357/oh-my-pi/issues/7617)). +- Fixed OpenAI Codex GPT-5.x requests sending optional `reasoning.summary`, `reasoning.context`, and `text.verbosity` controls by default, reducing Codex `server_error` disconnects from unsupported request shapes. ([#4949](https://github.com/can1357/oh-my-pi/issues/4949)) +- Classified concurrent-request caps separately from quota exhaustion so they use a short retry backoff without burning a credential, and rotate credentials for account-scoped 403 caps such as Devin's overall message limit. ## [17.2.7] - 2026-08-03 diff --git a/packages/ai/src/auth-retry.ts b/packages/ai/src/auth-retry.ts index 2e4bda417..972448b2e 100644 --- a/packages/ai/src/auth-retry.ts +++ b/packages/ai/src/auth-retry.ts @@ -3,7 +3,7 @@ import type { OAuthAccess } from "./auth-storage"; import * as AIError from "./error"; import { isAuthRetryableError, isInvalidatedOAuthTokenError } from "./error/auth-classify"; import { isUsageLimit } from "./error/flags"; -import { isUsageLimitOutcome } from "./error/rate-limit"; +import { isConcurrencyCapExclusion, isUsageLimitOutcome } from "./error/rate-limit"; /** * Context passed to an {@link ApiKeyResolver} on each resolution attempt. @@ -93,11 +93,14 @@ export const AUTH_RETRY_MAX_ATTEMPTS = 64; function isDirectCredentialRotationError(error: unknown): boolean { if (isUsageLimit(error) || isInvalidatedOAuthTokenError(error)) return true; const status = AIError.status(error); - // 403: the token is valid but access was denied, so refreshing the same - // credential can't help — rotate straight through the sibling pool. - if (status === 403) return true; const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined; - if (status === undefined && message !== undefined && extractHttpStatusFromError({ message }) === 403) return true; + // A 403 normally means a valid token lacks access, so rotate through + // siblings. A concurrency-cap 403 is transient instead; do not burn a + // sibling before the caller's backoff layer can retry it. + const isForbidden = + status === 403 || + (status === undefined && message !== undefined && extractHttpStatusFromError({ message }) === 403); + if (isForbidden && !isConcurrencyCapExclusion(status, message)) return true; return isUsageLimitOutcome(status, message); } diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index e1c76bda2..9344aefc4 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -5,14 +5,17 @@ * This module defines: * - `AuthCredentialStore` interface: persistence abstraction (SQLite, remote vault, …) * - `AuthStorage` class: credential management with round-robin, usage limits, OAuth refresh - * - `SqliteAuthCredentialStore`: concrete SQLite-backed implementation + * - re-exported `SqliteAuthCredentialStore`: concrete SQLite-backed implementation */ -import { Database, type Statement } from "bun:sqlite"; import { createHash } from "node:crypto"; -import * as fs from "node:fs/promises"; -import * as path from "node:path"; -import { parseAlibabaTokenPlanCredential } from "@oh-my-pi/pi-catalog/wire/alibaba-token-plan"; -import { $env, $envExact, getAgentDbPath, getDbBusyTimeoutMs, logger } from "@oh-my-pi/pi-utils"; +import { $env, $envExact, extractRetryHint, getAgentDbPath, logger } from "@oh-my-pi/pi-utils"; +import { + isSqliteCorruptionError, + resolveCredentialIdentityKey, + SqliteAuthCredentialStore, + serializeCredential, + USAGE_REPORT_TTL_MS, +} from "./auth/sqlite-credential-store"; import type { ApiKeyResolver } from "./auth-retry"; import * as AIError from "./error"; import { isUsageLimitOutcome } from "./error/rate-limit"; @@ -28,7 +31,6 @@ import type { import { getEnvApiKey, getEnvApiKeyName } from "./stream"; import type { Provider } from "./types"; import type { - ClientProviderUsage, ClientUsageReport, ClientUsageSummary, CredentialRankingContext, @@ -70,6 +72,12 @@ import { umansUsageProvider } from "./usage/umans"; import { xaiOauthUsageProvider } from "./usage/xai-oauth"; import { zaiRankingStrategy, zaiUsageProvider } from "./usage/zai"; +export { + isSqliteBusyError, + isSqliteCorruptionError, + SqliteAuthCredentialStore, +} from "./auth/sqlite-credential-store"; + const USAGE_RANKING_METRIC_EPSILON = 1e-9; /** * Primary (short, e.g. 5h) window used-fraction at or above which a candidate @@ -675,26 +683,8 @@ const DEFAULT_USAGE_PROVIDER_MAP = new Map( ); const USAGE_CACHE_PREFIX = "usage_cache:"; -// 5 min stale tolerance. Anthropic / OpenAI rate-limit /usage hard at the IP -// level so we can't fetch all N credentials every cycle; with a long cache -// each credential's last-known value sticks visible while peers retry. UI -// data (5h / 7d / monthly limits) is fine being a few minutes stale. -const USAGE_REPORT_TTL_MS = 5 * 60_000; const USAGE_HEADER_INGEST_INTERVAL_MS = 60_000; const USAGE_LAST_GOOD_RETENTION_MS = 24 * 60 * 60_000; -/** - * Downsample usage history to at most one row per hour per account window: a - * snapshot landing in the same hour bucket as the series' latest row - * overwrites it in place. That bound makes further retention pruning - * unnecessary — 1 row/hour is ~9k rows per account window per year. - */ -const USAGE_HISTORY_BUCKET_MS = 60 * 60_000; -/** - * Merge client observed-usage flushes into at most one row per 5 minutes per - * (install, provider, model): ~300 rows/day per active model per client - * instead of one row per 10s flush. - */ -const CLIENT_USAGE_BUCKET_MS = 5 * 60_000; /** * Per-credential cool-down after a usage fetch fails. While this window is * active we serve the last successful value to avoid dropping the credential @@ -3708,7 +3698,7 @@ export class AuthStorage { } #isUsageLimitExhausted(limit: UsageLimit): boolean { - if (limit.status === "exhausted") return true; + if (limit.status !== undefined && limit.status !== "unknown") return limit.status === "exhausted"; const amount = limit.amount; if (amount.usedFraction !== undefined && amount.usedFraction >= 1) return true; if (amount.remainingFraction !== undefined && amount.remainingFraction <= 0) return true; @@ -3856,13 +3846,15 @@ export class AuthStorage { : undefined; const rankingContext: CredentialRankingContext = { modelId: options.modelId }; + const planRequirement = resolveOpenAICodexPlanRequirement(provider, options.modelId); + const planEligibilityByCredential = new Map(); const blockScope = strategy.blockScope?.(rankingContext); const blockScopes = strategy.blockScopes?.(rankingContext) ?? (blockScope ? [blockScope] : []); const reserveFraction = Number.isFinite(options.reserveFraction) ? Math.max(0, Math.min(1, options.reserveFraction)) : 0; const nowMs = Date.now(); - const accounts = await Promise.all( + let accounts = await Promise.all( pool.map(async ({ entry, index }): Promise => { const credentialType = entry.credential.type; const providerKey = this.#getProviderTypeKey(provider, credentialType); @@ -3890,6 +3882,9 @@ export class AuthStorage { if (options.signal?.aborted) throw error; report = null; } + if (planRequirement !== "none") { + planEligibilityByCredential.set(entry.id, getOpenAICodexPlanEligibility(report, planRequirement)); + } if (provider === "openai-codex") { blockedUntil = this.#getCredentialBlockedUntil(provider, providerKey, index, blockScopes); @@ -3942,6 +3937,9 @@ export class AuthStorage { }; }), ); + if (planRequirement !== "none") { + accounts = accounts.filter(account => planEligibilityByCredential.get(account.credentialId) !== false); + } if (selectedCredentialId !== undefined) { const selectedAccount = accounts.find(account => account.credentialId === selectedCredentialId); if (selectedAccount) selectedAccount.selected = true; @@ -6095,8 +6093,13 @@ export class AuthStorage { const status = AIError.status(error); const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined; if (AIError.isUsageLimit(error) || isUsageLimitOutcome(status, message)) { + // Thread the provider-specified reset window (e.g. Devin "Your limit + // will reset in 13 minutes") into the block duration so the credential + // is not reselected and hammered while the cap remains active. + const retryAfterMs = extractRetryHint(undefined, message); return ( await this.markUsageLimitReached(provider, sessionId, { + retryAfterMs, modelId: options?.modelId, apiKey: options?.apiKey, credentialId: options?.credentialId, @@ -6535,1999 +6538,3 @@ export class AuthStorage { return undefined; } } - -// ───────────────────────────────────────────────────────────────────────────── -// SqliteAuthCredentialStore -// ───────────────────────────────────────────────────────────────────────────── - -/** Row shape for auth_credentials table queries */ -type AuthRow = { - id: number; - provider: string; - credential_type: string; - data: string; - disabled_cause: string | null; - identity_key: string | null; -}; - -/** {@link AuthRow} plus `updated_at` — disabled-tombstone queries surface when the row was torn down. */ -type DisabledAuthRow = AuthRow & { updated_at: number | null }; - -type CredentialBlockRow = { - credential_id: number; - provider_key: string; - block_scope: string; - blocked_until_ms: number; - updated_at: number; -}; - -type SerializedCredentialRecord = { - credentialType: AuthCredential["type"]; - data: string; - identityKey: string | null; -}; - -const AUTH_SCHEMA_VERSION = 7; -const SQLITE_NOW_EPOCH = "CAST(strftime('%s','now') AS INTEGER)"; -const LEGACY_CODEX_BLOCK_PROVIDER_KEY = "openai-codex:oauth"; -const LEGACY_CODEX_BLOCK_SCOPE = "shared"; -const CODEX_METER_BLOCK_SCOPES = ["chat", "spark"] as const; - -/** - * SQLite's busy result code family — base `SQLITE_BUSY` plus the extended - * variants `SQLITE_BUSY_RECOVERY` (concurrent WAL recovery), `SQLITE_BUSY_SNAPSHOT`, - * and `SQLITE_BUSY_TIMEOUT`. All warrant the same backoff-and-retry treatment. - */ -export function isSqliteBusyError(err: unknown): boolean { - if (err === null || typeof err !== "object") return false; - const code = (err as { code?: unknown }).code; - return typeof code === "string" && code.startsWith("SQLITE_BUSY"); -} - -/** - * SQLite's unrecoverable-corruption result codes — the `SQLITE_CORRUPT` family - * (base plus extended variants like `SQLITE_CORRUPT_VTAB` / `SQLITE_CORRUPT_INDEX`) - * and `SQLITE_NOTADB` (the file header is not a database). Unlike - * {@link isSqliteBusyError}, these never clear by retrying: the store must be - * repaired or replaced, so callers latch and stop touching it. - */ -export function isSqliteCorruptionError(err: unknown): boolean { - if (err === null || typeof err !== "object" || !("code" in err)) return false; - const code = err.code; - return typeof code === "string" && (code.startsWith("SQLITE_CORRUPT") || code === "SQLITE_NOTADB"); -} - -function normalizeStoredAccountId(accountId: string | null | undefined): string | null { - const normalized = accountId?.trim(); - return normalized && normalized.length > 0 ? normalized : null; -} - -function normalizeStoredEmail(email: string | null | undefined): string | null { - const normalized = email?.trim().toLowerCase(); - return normalized && normalized.length > 0 ? normalized : null; -} - -function normalizeStoredIdentityKey(identityKey: string | null | undefined): string | null { - const normalized = identityKey?.trim(); - return normalized && normalized.length > 0 ? normalized : null; -} - -function serializeCredential(provider: string, credential: AuthCredential): SerializedCredentialRecord | null { - if (credential.type === "api_key") { - const data = credential.source === "login" ? { key: credential.key, source: "login" } : { key: credential.key }; - return { - credentialType: "api_key", - data: JSON.stringify(data), - identityKey: null, - }; - } - if (credential.type === "oauth") { - const { type: _type, ...rest } = credential; - return { - credentialType: "oauth", - data: JSON.stringify(rest), - identityKey: resolveCredentialIdentityKey(provider, credential), - }; - } - return null; -} - -function deserializeCredential(row: AuthRow): AuthCredential | null { - let parsed: unknown; - try { - parsed = JSON.parse(row.data); - } catch { - return null; - } - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return null; - } - if (row.credential_type === "api_key") { - const data = parsed as Record; - if (typeof data.key === "string") { - const source = data.source === "login" ? "login" : undefined; - return source ? { type: "api_key", key: data.key, source } : { type: "api_key", key: data.key }; - } - } - if (row.credential_type === "oauth") { - return { type: "oauth", ...(parsed as Record) } as AuthCredential; - } - return null; -} - -function normalizeDisabledCause(disabledCause: string): string { - const normalized = disabledCause.trim(); - return normalized.length > 0 ? normalized : "disabled"; -} - -function toStoredAuthCredential(row: AuthRow, credential: AuthCredential): StoredAuthCredential { - return { id: row.id, provider: row.provider, credential, disabledCause: row.disabled_cause }; -} - -function resolveProviderCredentialIdentityKey(provider: string, identifiers: string[]): string | null { - const emailIdentifier = identifiers.find(identifier => identifier.startsWith("email:")); - if (provider === "anthropic" || provider === "openai-codex") { - // One account email can hold several organizations/workspaces (e.g. a - // Team seat plus a personal plan), each with its own org-scoped token - // and limit pools. Scope identity by org so both subscriptions can be - // stored side by side. The qualifier rides on whichever base identity - // is available, so an unqualified account/project fallback would - // still collapse two subscriptions whenever the email could not be - // recovered. Org-less credentials (rows written before org capture - // existed) keep their bare key. - const base = - emailIdentifier ?? - identifiers.find(identifier => identifier.startsWith("account:")) ?? - identifiers.find(identifier => identifier.startsWith("project:")); - const orgIdentifier = identifiers.find(identifier => identifier.startsWith("org:")); - if (base) return orgIdentifier ? `${base}|${orgIdentifier}` : base; - // No base identity at all: the org alone still distinguishes the row. - return orgIdentifier ?? null; - } - const accountIdentifier = identifiers.find(identifier => identifier.startsWith("account:")); - if (accountIdentifier) return accountIdentifier; - if (emailIdentifier) return emailIdentifier; - const projectIdentifier = identifiers.find(identifier => identifier.startsWith("project:")); - if (projectIdentifier) return projectIdentifier; - return null; -} - -function resolveCredentialIdentityKey(provider: string, credential: AuthCredential): string | null { - if (credential.type === "api_key") return null; - return resolveProviderCredentialIdentityKey(provider, extractOAuthCredentialIdentifiers(credential)); -} - -function resolveRowCredentialIdentityKey(provider: string, row: AuthRow): string | null { - const identityKey = normalizeStoredIdentityKey(row.identity_key); - if (identityKey) return identityKey; - const credential = deserializeCredential(row); - return credential?.type === "oauth" ? resolveCredentialIdentityKey(provider, credential) : null; -} - -function matchesReplacementCredential( - provider: string, - existing: AuthCredential | null, - existingIdentityKey: string | null, - incoming: AuthCredential, -): boolean { - if (!existing || existing.type !== incoming.type) return false; - if (incoming.type === "api_key") { - if (existing.type !== "api_key") return false; - if (existing.key === incoming.key) return true; - if (provider !== "alibaba-token-plan") return false; - const existingToken = parseAlibabaTokenPlanCredential(existing.key)?.token; - const incomingToken = parseAlibabaTokenPlanCredential(incoming.key)?.token; - return existingToken !== undefined && existingToken === incomingToken; - } - const incomingIdentifiers = extractOAuthCredentialIdentifiers(incoming); - const incomingIdentityKey = resolveProviderCredentialIdentityKey(provider, incomingIdentifiers); - if (incomingIdentityKey === null) return false; - if (incomingIdentityKey === existingIdentityKey) return true; - if (existingIdentityKey === null) return false; - // One-way upgrade, applied only when the INCOMING identity key carries the - // org qualifier (only anthropic and openai-codex keys do, so other - // providers never reach the checks below). An org-scoped login `org:` - // claims (and re-keys) any existing row that denotes the same subscription: - // - `org:` — org-only row stored when identity recovery failed, claimed - // once a later same-org login recovers a base identity; - // - `` for any base identity `` (email/account/project) the incoming - // credential carries — a pre-org legacy row, mirroring the pre-org - // replace behavior; - // - `|org:` for any such base — the same subscription keyed by a - // different base, e.g. an account-keyed row stored while the email could - // not be recovered, claimed once a later login recovers the email; - // - any same-org row whose STORED credential shares a base identity with - // the incoming one — a stored credential can retain identifiers its key - // does not use (an email-keyed row also carries the account UUID), so a - // later login that loses the email but keeps the account still updates - // its row instead of duplicating the subscription. - // The reverse stays a non-match: an org-less credential only ever replaces - // via exact key equality above and must never clobber an org-scoped row. - const orgIdentifier = incomingIdentifiers.find(identifier => identifier.startsWith("org:")); - if (orgIdentifier === undefined) return false; - if (incomingIdentityKey !== orgIdentifier && !incomingIdentityKey.endsWith(`|${orgIdentifier}`)) return false; - if (existingIdentityKey === orgIdentifier) return true; - const existingIdentifiers = - existing.type === "oauth" && existingIdentityKey.endsWith(`|${orgIdentifier}`) - ? extractOAuthCredentialIdentifiers(existing) - : null; - // A base identifier that merely repeats the org qualifier's id carries no - // per-user identity (openai-codex stores the ChatGPT workspace id as both - // accountId and orgId, shared by every member) — letting it act as a - // claimable base would re-key another member's same-org row. - const orgQualifierId = orgIdentifier.slice("org:".length); - for (const identifier of incomingIdentifiers) { - const isBase = - identifier.startsWith("email:") || identifier.startsWith("account:") || identifier.startsWith("project:"); - if (!isBase) continue; - if (identifier.slice(identifier.indexOf(":") + 1) === orgQualifierId) continue; - if (existingIdentityKey === identifier) return true; - if (existingIdentityKey === `${identifier}|${orgIdentifier}`) return true; - if (existingIdentifiers?.includes(identifier)) return true; - } - return false; -} - -function extractOAuthCredentialIdentifiers(credential: OAuthCredential): string[] { - const identifiers = new Set(); - const accountId = normalizeStoredAccountId(credential.accountId); - if (accountId) identifiers.add(`account:${accountId}`); - const email = normalizeStoredEmail(credential.email); - if (email) identifiers.add(`email:${email}`); - const projectId = normalizeStoredAccountId(credential.projectId); - if (projectId) identifiers.add(`project:${projectId}`); - const orgId = normalizeStoredAccountId(credential.orgId); - if (orgId) identifiers.add(`org:${orgId}`); - const accessIdentifiers = extractOAuthTokenIdentifiers(credential.access) ?? []; - for (const identifier of accessIdentifiers) { - identifiers.add(identifier); - } - const refreshIdentifiers = extractOAuthTokenIdentifiers(credential.refresh) ?? []; - for (const identifier of refreshIdentifiers) { - identifiers.add(identifier); - } - return [...identifiers]; -} - -function extractOAuthTokenIdentifiers(token: string | undefined): string[] | undefined { - if (!token) return undefined; - const parts = token.split("."); - if (parts.length !== 3) return undefined; - try { - const payload = JSON.parse( - new TextDecoder("utf-8").decode(Uint8Array.fromBase64(parts[1], { alphabet: "base64url" })), - ) as Record; - const identifiers = new Set(); - const directEmail = normalizeStoredEmail(typeof payload.email === "string" ? payload.email : undefined); - if (directEmail) identifiers.add(`email:${directEmail}`); - const openAiProfile = payload["https://api.openai.com/profile"]; - if (typeof openAiProfile === "object" && openAiProfile !== null && !Array.isArray(openAiProfile)) { - const claimEmail = normalizeStoredEmail( - (openAiProfile as Record).email as string | undefined, - ); - if (claimEmail) identifiers.add(`email:${claimEmail}`); - } - const openAiAuth = payload["https://api.openai.com/auth"]; - const authClaims = - typeof openAiAuth === "object" && openAiAuth !== null && !Array.isArray(openAiAuth) - ? (openAiAuth as Record) - : undefined; - const accountId = normalizeStoredAccountId( - typeof payload.account_id === "string" - ? payload.account_id - : typeof payload.accountId === "string" - ? payload.accountId - : typeof payload.user_id === "string" - ? payload.user_id - : typeof payload.sub === "string" - ? payload.sub - : typeof authClaims?.chatgpt_account_id === "string" - ? authClaims.chatgpt_account_id - : undefined, - ); - if (accountId) identifiers.add(`account:${accountId}`); - return identifiers.size > 0 ? [...identifiers] : undefined; - } catch { - return undefined; - } -} -/** - * Default SQLite-backed implementation of {@link AuthCredentialStore}. - * - * Used by the pi-ai CLI and as the default store for `AuthStorage.create()`. - * Also exposes convenience methods (`saveOAuth`, `getOAuth`, `saveApiKey`, - * `getApiKey`, `listProviders`, `deleteProvider`) that callers can use directly - * without going through `AuthStorage`. - */ -export class SqliteAuthCredentialStore implements AuthCredentialStore { - #db: Database; - #listActiveStmt: Statement; - #listActiveByProviderStmt: Statement; - #listDisabledStmt: Statement; - #listDisabledByProviderStmt: Statement; - #insertStmt: Statement; - #updateStmt: Statement; - #deleteStmt: Statement; - #deleteIfMatchesStmt: Statement; - #updateIfMatchesStmt: Statement; - #deleteByProviderStmt: Statement; - #hardDeleteStmt: Statement; - #getCacheStmt: Statement; - #getCacheIncludingExpiredStmt: Statement; - #upsertCacheStmt: Statement; - #deleteCachePrefixStmt: Statement; - #deleteExpiredCacheStmt: Statement; - #updateIfMatchesWithLeaseStmt: Statement; - #deleteIfMatchesWithLeaseStmt: Statement; - #getCredentialBlockStmt: Statement; - #listCredentialBlocksByCredentialStmt: Statement; - #upsertCredentialBlockStmt: Statement; - #deleteCredentialBlocksStmt: Statement; - #deleteCredentialBlockStmt: Statement; - #deleteExpiredCredentialBlocksStmt: Statement; - #acquireCredentialRefreshLeaseStmt: Statement; - #getCredentialRefreshLeaseStmt: Statement; - #renewCredentialRefreshLeaseStmt: Statement; - #releaseCredentialRefreshLeaseStmt: Statement; - #credentialBlockReconcileAfter: Map = new Map(); - #insertUsageHistoryStmt: Statement; - #insertUsageCostStmt: Statement; - #listUsageCostsStmt: Statement; - #lastUsageHistoryStmt: Statement; - #listUsageHistoryStmt: Statement; - #updateUsageHistoryStmt: Statement; - #dataVersion: number; - #authRevision: number; - #localAuthRevision: number; - #closed = false; - - constructor(db: Database) { - this.#db = db; - this.#initializeSchema(); - this.#dataVersion = this.#readDataVersion(); - this.#authRevision = this.#readAuthRevision(); - this.#localAuthRevision = this.#readLocalAuthRevision(); - - this.#listActiveStmt = this.#db.prepare( - "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE disabled_cause IS NULL ORDER BY id ASC", - ); - this.#listActiveByProviderStmt = this.#db.prepare( - "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE provider = ? AND disabled_cause IS NULL ORDER BY id ASC", - ); - this.#listDisabledStmt = this.#db.prepare( - "SELECT id, provider, credential_type, data, disabled_cause, identity_key, updated_at FROM auth_credentials WHERE disabled_cause IS NOT NULL ORDER BY id ASC", - ); - this.#listDisabledByProviderStmt = this.#db.prepare( - "SELECT id, provider, credential_type, data, disabled_cause, identity_key, updated_at FROM auth_credentials WHERE provider = ? AND disabled_cause IS NOT NULL ORDER BY id ASC", - ); - this.#insertStmt = this.#db.prepare( - `INSERT INTO auth_credentials (provider, credential_type, data, identity_key, created_at, updated_at) VALUES (?, ?, ?, ?, ${SQLITE_NOW_EPOCH}, ${SQLITE_NOW_EPOCH}) RETURNING id`, - ); - this.#updateStmt = this.#db.prepare( - `UPDATE auth_credentials SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`, - ); - this.#updateIfMatchesStmt = this.#db.prepare( - `UPDATE auth_credentials SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ? AND data = ? AND disabled_cause IS NULL`, - ); - this.#updateIfMatchesWithLeaseStmt = this.#db.prepare( - `UPDATE auth_credentials - SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH} - WHERE id = ? AND data = ? AND disabled_cause IS NULL - AND EXISTS ( - SELECT 1 FROM auth_credential_refresh_leases - WHERE credential_id = ? AND owner = ? AND expires_at_ms > ? - )`, - ); - this.#deleteStmt = this.#db.prepare( - `UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`, - ); - this.#deleteIfMatchesStmt = this.#db.prepare( - `UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ? AND data = ? AND disabled_cause IS NULL`, - ); - this.#deleteIfMatchesWithLeaseStmt = this.#db.prepare( - `UPDATE auth_credentials - SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} - WHERE id = ? AND data = ? AND disabled_cause IS NULL - AND EXISTS ( - SELECT 1 FROM auth_credential_refresh_leases - WHERE credential_id = ? AND owner = ? AND expires_at_ms > ? - )`, - ); - this.#deleteByProviderStmt = this.#db.prepare( - `UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE provider = ? AND disabled_cause IS NULL`, - ); - this.#hardDeleteStmt = this.#db.prepare("DELETE FROM auth_credentials WHERE id = ?"); - this.#getCacheStmt = this.#db.prepare( - `SELECT value FROM cache WHERE key = ? AND expires_at > ${SQLITE_NOW_EPOCH}`, - ); - this.#getCacheIncludingExpiredStmt = this.#db.prepare("SELECT value FROM cache WHERE key = ?"); - this.#upsertCacheStmt = this.#db.prepare( - "INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at", - ); - this.#deleteCachePrefixStmt = this.#db.prepare("DELETE FROM cache WHERE substr(key, 1, ?) = ?"); - this.#deleteExpiredCacheStmt = this.#db.prepare(`DELETE FROM cache WHERE expires_at <= ${SQLITE_NOW_EPOCH}`); - this.#getCredentialBlockStmt = this.#db.prepare( - "SELECT blocked_until_ms, updated_at FROM auth_credential_blocks WHERE credential_id = ? AND provider_key = ? AND block_scope = ? AND blocked_until_ms > ?", - ); - this.#listCredentialBlocksByCredentialStmt = this.#db.prepare( - `SELECT credential_id, provider_key, block_scope, blocked_until_ms, updated_at - FROM auth_credential_blocks - WHERE credential_id = ? AND blocked_until_ms > ? - AND NOT (provider_key = ? AND block_scope = ?) - ORDER BY provider_key ASC, block_scope ASC`, - ); - this.#upsertCredentialBlockStmt = this.#db.prepare( - `INSERT INTO auth_credential_blocks (credential_id, provider_key, block_scope, blocked_until_ms, updated_at) - VALUES (?, ?, ?, ?, ${SQLITE_NOW_EPOCH}) - ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET - blocked_until_ms = MAX(blocked_until_ms, excluded.blocked_until_ms), - updated_at = excluded.updated_at`, - ); - this.#deleteCredentialBlocksStmt = this.#db.prepare("DELETE FROM auth_credential_blocks WHERE credential_id = ?"); - this.#deleteCredentialBlockStmt = this.#db.prepare( - "DELETE FROM auth_credential_blocks WHERE credential_id = ? AND provider_key = ? AND block_scope = ?", - ); - this.#deleteExpiredCredentialBlocksStmt = this.#db.prepare( - "DELETE FROM auth_credential_blocks WHERE blocked_until_ms <= ?", - ); - this.#acquireCredentialRefreshLeaseStmt = this.#db.prepare( - `INSERT INTO auth_credential_refresh_leases (credential_id, owner, expires_at_ms, updated_at) - VALUES (?, ?, ?, ${SQLITE_NOW_EPOCH}) - ON CONFLICT(credential_id) DO UPDATE SET - owner = excluded.owner, - expires_at_ms = excluded.expires_at_ms, - updated_at = excluded.updated_at - WHERE auth_credential_refresh_leases.expires_at_ms <= ?`, - ); - this.#getCredentialRefreshLeaseStmt = this.#db.prepare( - "SELECT expires_at_ms FROM auth_credential_refresh_leases WHERE credential_id = ?", - ); - this.#renewCredentialRefreshLeaseStmt = this.#db.prepare( - `UPDATE auth_credential_refresh_leases SET expires_at_ms = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE credential_id = ? AND owner = ?`, - ); - this.#releaseCredentialRefreshLeaseStmt = this.#db.prepare( - "DELETE FROM auth_credential_refresh_leases WHERE credential_id = ? AND owner = ?", - ); - this.#insertUsageHistoryStmt = this.#db.prepare( - "INSERT INTO usage_history (recorded_at, provider, account_key, email, account_id, limit_id, label, window_label, used_fraction, status, resets_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ); - this.#lastUsageHistoryStmt = this.#db.prepare( - "SELECT id, recorded_at FROM usage_history WHERE provider = ? AND account_key = ? AND limit_id = ? ORDER BY recorded_at DESC LIMIT 1", - ); - this.#updateUsageHistoryStmt = this.#db.prepare( - "UPDATE usage_history SET recorded_at = ?, email = ?, account_id = ?, label = ?, window_label = ?, used_fraction = ?, status = ?, resets_at = ? WHERE id = ?", - ); - this.#listUsageHistoryStmt = this.#db.prepare( - "SELECT recorded_at, provider, account_key, email, account_id, limit_id, label, window_label, used_fraction, status, resets_at FROM usage_history WHERE recorded_at >= ? AND (? IS NULL OR provider = ?) ORDER BY recorded_at ASC", - ); - this.#insertUsageCostStmt = this.#db.prepare( - "INSERT INTO usage_cost_history (recorded_at, provider, account_key, cost_usd) VALUES (?, ?, ?, ?)", - ); - this.#listUsageCostsStmt = this.#db.prepare( - "SELECT recorded_at, provider, account_key, cost_usd FROM usage_cost_history WHERE recorded_at >= ? AND (? IS NULL OR provider = ?) AND (? IS NULL OR account_key = ?) ORDER BY recorded_at ASC", - ); - } - - static async open(dbPath: string = getAgentDbPath()): Promise { - const dir = path.dirname(dbPath); - const dirExists = await fs - .stat(dir) - .then(s => s.isDirectory()) - .catch(() => false); - if (!dirExists) { - await fs.mkdir(dir, { recursive: true, mode: 0o700 }); - } - - // Concurrent omp startups can race against WAL recovery and the schema - // init's first lock-taking statement. Bun's default `busy_timeout` is 0, - // so retry the open on `SQLITE_BUSY` / `SQLITE_BUSY_RECOVERY` with bounded - // exponential backoff before surfacing the failure. See issue #2421. - const maxAttempts = 4; - const baseDelayMs = 100; - let lastBusyError: Error | undefined; - for (let attempt = 0; attempt < maxAttempts; attempt++) { - let db: Database | undefined; - try { - db = new Database(dbPath); - // Install the busy handler BEFORE the first lock-taking statement - // on this connection. The leases DDL below and the constructor's - // schema init both acquire locks during WAL recovery; without a - // non-zero `busy_timeout` they fail immediately with SQLITE_BUSY. - // See issue #2421. - SqliteAuthCredentialStore.#installBusyTimeout(db); - try { - await fs.chmod(dbPath, 0o600); - } catch { - // Ignore chmod failures (e.g., Windows) - } - SqliteAuthCredentialStore.#ensureAuthCredentialRefreshLeasesTable(db); - return new SqliteAuthCredentialStore(db); - } catch (err) { - db?.close(); - if (!isSqliteBusyError(err)) { - throw err; - } - lastBusyError = err instanceof Error ? err : new Error(String(err)); - if (attempt < maxAttempts - 1) { - await Bun.sleep(baseDelayMs * 2 ** attempt); - } - } - } - throw new AIError.ConfigurationError( - `Failed to open auth database at '${dbPath}' after ${maxAttempts} attempts: ${lastBusyError?.message}`, - { cause: lastBusyError }, - ); - } - - static #ensureAuthCredentialRefreshLeasesTable(db: Database): void { - db.run(` - CREATE TABLE IF NOT EXISTS auth_credential_refresh_leases ( - credential_id INTEGER PRIMARY KEY, - owner TEXT NOT NULL, - expires_at_ms INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_auth_credential_refresh_leases_expires ON auth_credential_refresh_leases(expires_at_ms); - `); - } - - /** - * Install the per-connection busy handler so lock-taking statements wait for - * a contended writer instead of failing immediately (Bun defaults - * `busy_timeout` to 0). MUST run before the first lock-taking statement on - * the connection: concurrent omp startups race WAL recovery and the leases - * DDL. Uses the centralized timeout so headless hosts keep their bounded - * busy wait instead of the interactive 5s value. See issues #2421, #7298. - */ - static #installBusyTimeout(db: Database): void { - db.run(`PRAGMA busy_timeout = ${getDbBusyTimeoutMs()}`); - } - - #initializeSchema(): void { - // Install the busy handler BEFORE any lock-taking statement (incl. - // `PRAGMA journal_mode=WAL`, which acquires an exclusive lock during WAL - // recovery). Without this, concurrent omp startups can crash here with - // `SQLITE_BUSY` / `SQLITE_BUSY_RECOVERY`. Re-setting when opened via - // `open()` (which already installed it) is idempotent. See issue #2421. - SqliteAuthCredentialStore.#installBusyTimeout(this.#db); - this.#db.run(` - PRAGMA journal_mode=WAL; - PRAGMA synchronous=NORMAL; - CREATE TABLE IF NOT EXISTS auth_schema_version ( - id INTEGER PRIMARY KEY CHECK (id = 1), - version INTEGER NOT NULL - ); - CREATE TABLE IF NOT EXISTS cache ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - expires_at INTEGER NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache(expires_at); - CREATE TABLE IF NOT EXISTS usage_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - recorded_at INTEGER NOT NULL, - provider TEXT NOT NULL, - account_key TEXT NOT NULL, - email TEXT, - account_id TEXT, - limit_id TEXT NOT NULL, - label TEXT NOT NULL, - window_label TEXT, - used_fraction REAL, - status TEXT, - resets_at INTEGER - ); - CREATE INDEX IF NOT EXISTS idx_usage_history_series ON usage_history(provider, account_key, limit_id, recorded_at); - CREATE TABLE IF NOT EXISTS usage_cost_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - recorded_at INTEGER NOT NULL, - provider TEXT NOT NULL, - account_key TEXT NOT NULL, - cost_usd REAL NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_usage_cost_history_lookup ON usage_cost_history(provider, account_key, recorded_at); - CREATE INDEX IF NOT EXISTS idx_usage_history_recorded ON usage_history(recorded_at); - CREATE TABLE IF NOT EXISTS clients ( - install_id TEXT PRIMARY KEY, - hostname TEXT, - first_seen INTEGER NOT NULL, - last_seen INTEGER NOT NULL - ); - CREATE TABLE IF NOT EXISTS client_usage ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - recorded_at INTEGER NOT NULL, - install_id TEXT NOT NULL, - provider TEXT NOT NULL, - model TEXT NOT NULL, - requests INTEGER NOT NULL, - input_tokens INTEGER NOT NULL, - output_tokens INTEGER NOT NULL, - cache_read_tokens INTEGER NOT NULL, - cache_write_tokens INTEGER NOT NULL, - cost_usd REAL NOT NULL DEFAULT 0 - ); - CREATE INDEX IF NOT EXISTS idx_client_usage_series ON client_usage(install_id, provider, model, recorded_at); - CREATE INDEX IF NOT EXISTS idx_client_usage_recorded ON client_usage(recorded_at); - `); - - if (!this.#authCredentialsTableExists()) { - this.#createAuthCredentialsTable(); - this.#createAuthCredentialBlocksTable(); - this.#createAuthCredentialRefreshLeasesTable(); - this.#createAuthCredentialBlockCompatibilityObjects(); - this.#createAuthChangeTrackingObjects(); - this.#writeAuthSchemaVersion(AUTH_SCHEMA_VERSION); - return; - } - - const recordedVersion = this.#readAuthSchemaVersion(); - const schemaVersion = recordedVersion ?? this.#inferAuthSchemaVersion(); - if (schemaVersion > AUTH_SCHEMA_VERSION) { - logger.warn("SqliteAuthCredentialStore schema version mismatch", { - current: schemaVersion, - expected: AUTH_SCHEMA_VERSION, - }); - } else if (schemaVersion < AUTH_SCHEMA_VERSION) { - this.#migrateAuthSchema(schemaVersion); - } - - this.#createAuthCredentialIndexes(); - this.#createAuthCredentialBlocksTable(); - this.#createAuthCredentialRefreshLeasesTable(); - if (schemaVersion <= AUTH_SCHEMA_VERSION) { - this.#createAuthCredentialBlockCompatibilityObjects(); - } - this.#createAuthChangeTrackingObjects(); - this.#backfillCredentialIdentityKeys(); - // Rewriting an already-current version row is a no-op write transaction - // on every boot; only persist when the recorded version actually changes. - if (recordedVersion !== AUTH_SCHEMA_VERSION && schemaVersion <= AUTH_SCHEMA_VERSION) { - this.#writeAuthSchemaVersion(AUTH_SCHEMA_VERSION); - } - } - - #authCredentialsTableExists(): boolean { - const stmt = this.#db.prepare( - "SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'auth_credentials'", - ); - try { - const row = stmt.get() as { present?: number } | undefined; - return row?.present === 1; - } finally { - stmt.finalize(); - } - } - - #readAuthSchemaVersion(): number | null { - const stmt = this.#db.prepare("SELECT version FROM auth_schema_version WHERE id = 1"); - try { - const row = stmt.get() as { version?: number } | undefined; - return typeof row?.version === "number" ? row.version : null; - } finally { - stmt.finalize(); - } - } - - #writeAuthSchemaVersion(version: number): void { - const stmt = this.#db.prepare("INSERT OR REPLACE INTO auth_schema_version(id, version) VALUES (1, ?)"); - try { - stmt.run(version); - } finally { - stmt.finalize(); - } - } - - #inferAuthSchemaVersion(): number { - const stmt = this.#db.prepare("PRAGMA table_info(auth_credentials)"); - try { - const cols = stmt.all() as Array<{ name?: string }>; - return this.#inferAuthSchemaVersionFromColumns(cols); - } finally { - stmt.finalize(); - } - } - - #inferAuthSchemaVersionFromColumns(cols: Array<{ name?: string }>): number { - const hasDisabledCause = cols.some(column => column.name === "disabled_cause"); - const hasIdentityKey = cols.some(column => column.name === "identity_key"); - const hasAccountId = cols.some(column => column.name === "account_id"); - const hasEmail = cols.some(column => column.name === "email"); - if (hasIdentityKey) return 3; - if (hasAccountId || hasEmail) return 2; - if (hasDisabledCause) return 1; - return 0; - } - - #createAuthCredentialsTable(): void { - this.#db.run(` - CREATE TABLE IF NOT EXISTS auth_credentials ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - provider TEXT NOT NULL, - credential_type TEXT NOT NULL, - data TEXT NOT NULL, - disabled_cause TEXT DEFAULT NULL, - identity_key TEXT DEFAULT NULL, - created_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}), - updated_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}) - ); - `); - this.#createAuthCredentialIndexes(); - } - - #createAuthCredentialIndexes(): void { - this.#db.run(` - CREATE INDEX IF NOT EXISTS idx_auth_provider ON auth_credentials(provider); - CREATE INDEX IF NOT EXISTS idx_auth_provider_identity ON auth_credentials(provider, identity_key) WHERE identity_key IS NOT NULL; - `); - } - - #createAuthCredentialBlocksTable(): void { - this.#db.run(` - CREATE TABLE IF NOT EXISTS auth_credential_blocks ( - credential_id INTEGER NOT NULL, - provider_key TEXT NOT NULL, - block_scope TEXT NOT NULL DEFAULT '', - blocked_until_ms INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - PRIMARY KEY (credential_id, provider_key, block_scope) - ); - CREATE INDEX IF NOT EXISTS idx_auth_credential_blocks_expires ON auth_credential_blocks(blocked_until_ms); - `); - } - - #createAuthChangeTrackingObjects(): void { - this.#db.run(` - CREATE TABLE IF NOT EXISTS auth_change_revision ( - id INTEGER PRIMARY KEY CHECK (id = 1), - revision INTEGER NOT NULL - ); - INSERT OR IGNORE INTO auth_change_revision (id, revision) VALUES (1, 0); - CREATE TEMP TABLE IF NOT EXISTS auth_local_change_revision ( - id INTEGER PRIMARY KEY CHECK (id = 1), - revision INTEGER NOT NULL - ); - INSERT OR IGNORE INTO auth_local_change_revision (id, revision) VALUES (1, 0); - `); - for (const table of ["auth_credentials", "auth_credential_blocks"] as const) { - for (const event of ["INSERT", "UPDATE", "DELETE"] as const) { - this.#db.run(` - CREATE TRIGGER IF NOT EXISTS auth_change_revision_${table}_${event.toLowerCase()} - AFTER ${event} ON ${table} - BEGIN - UPDATE auth_change_revision SET revision = revision + 1 WHERE id = 1; - END; - `); - this.#db.run(` - CREATE TEMP TRIGGER IF NOT EXISTS auth_local_change_revision_${table}_${event.toLowerCase()} - AFTER ${event} ON main.${table} - BEGIN - UPDATE auth_local_change_revision SET revision = revision + 1 WHERE id = 1; - END; - `); - } - } - } - - #createAuthCredentialBlockMirrorGuardTable(): void { - this.#db.run(` - CREATE TABLE IF NOT EXISTS auth_credential_block_mirror_guard ( - credential_id INTEGER PRIMARY KEY - ) WITHOUT ROWID; - `); - } - - /** - * Keep a physical Codex `shared` row for pre-meter binaries that read this - * database directly. Meter rows are canonical for current code. The guard - * suppresses feedback while triggers update the compatibility projection. - */ - #createAuthCredentialBlockCompatibilityTriggers(): void { - for (const event of ["INSERT", "UPDATE"] as const) { - const eventName = event.toLowerCase(); - this.#db.run(` - CREATE TRIGGER IF NOT EXISTS auth_codex_shared_${eventName}_to_meters - AFTER ${event} ON auth_credential_blocks - WHEN NEW.provider_key = 'openai-codex:oauth' - AND NEW.block_scope = 'shared' - AND NOT EXISTS ( - SELECT 1 FROM auth_credential_block_mirror_guard - WHERE credential_id = NEW.credential_id - ) - BEGIN - INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id) - VALUES (NEW.credential_id); - INSERT INTO auth_credential_blocks ( - credential_id, - provider_key, - block_scope, - blocked_until_ms, - updated_at - ) - VALUES ( - NEW.credential_id, - NEW.provider_key, - 'chat', - NEW.blocked_until_ms, - NEW.updated_at - ) - ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET - blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms), - updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at); - INSERT INTO auth_credential_blocks ( - credential_id, - provider_key, - block_scope, - blocked_until_ms, - updated_at - ) - VALUES ( - NEW.credential_id, - NEW.provider_key, - 'spark', - NEW.blocked_until_ms, - NEW.updated_at - ) - ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET - blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms), - updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at); - DELETE FROM auth_credential_block_mirror_guard - WHERE credential_id = NEW.credential_id; - END; - - CREATE TRIGGER IF NOT EXISTS auth_codex_meter_${eventName}_to_shared - AFTER ${event} ON auth_credential_blocks - WHEN NEW.provider_key = 'openai-codex:oauth' - AND NEW.block_scope IN ('chat', 'spark') - AND NOT EXISTS ( - SELECT 1 FROM auth_credential_block_mirror_guard - WHERE credential_id = NEW.credential_id - ) - BEGIN - INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id) - VALUES (NEW.credential_id); - DELETE FROM auth_credential_blocks - WHERE credential_id = NEW.credential_id - AND provider_key = NEW.provider_key - AND block_scope = 'shared'; - INSERT INTO auth_credential_blocks ( - credential_id, - provider_key, - block_scope, - blocked_until_ms, - updated_at - ) - SELECT - NEW.credential_id, - NEW.provider_key, - 'shared', - MAX(blocked_until_ms), - MAX(updated_at) - FROM auth_credential_blocks - WHERE credential_id = NEW.credential_id - AND provider_key = NEW.provider_key - AND block_scope IN ('chat', 'spark') - GROUP BY credential_id, provider_key; - DELETE FROM auth_credential_block_mirror_guard - WHERE credential_id = NEW.credential_id; - END; - `); - } - - this.#db.run(` - CREATE TRIGGER IF NOT EXISTS auth_codex_shared_delete_to_meters - AFTER DELETE ON auth_credential_blocks - WHEN OLD.provider_key = 'openai-codex:oauth' - AND OLD.block_scope = 'shared' - AND NOT EXISTS ( - SELECT 1 FROM auth_credential_block_mirror_guard - WHERE credential_id = OLD.credential_id - ) - BEGIN - INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id) - VALUES (OLD.credential_id); - DELETE FROM auth_credential_blocks - WHERE credential_id = OLD.credential_id - AND provider_key = OLD.provider_key - AND block_scope IN ('chat', 'spark'); - DELETE FROM auth_credential_block_mirror_guard - WHERE credential_id = OLD.credential_id; - END; - - CREATE TRIGGER IF NOT EXISTS auth_codex_meter_delete_to_shared - AFTER DELETE ON auth_credential_blocks - WHEN OLD.provider_key = 'openai-codex:oauth' - AND OLD.block_scope IN ('chat', 'spark') - AND NOT EXISTS ( - SELECT 1 FROM auth_credential_block_mirror_guard - WHERE credential_id = OLD.credential_id - ) - BEGIN - INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id) - VALUES (OLD.credential_id); - DELETE FROM auth_credential_blocks - WHERE credential_id = OLD.credential_id - AND provider_key = OLD.provider_key - AND block_scope = 'shared'; - INSERT INTO auth_credential_blocks ( - credential_id, - provider_key, - block_scope, - blocked_until_ms, - updated_at - ) - SELECT - OLD.credential_id, - OLD.provider_key, - 'shared', - MAX(blocked_until_ms), - MAX(updated_at) - FROM auth_credential_blocks - WHERE credential_id = OLD.credential_id - AND provider_key = OLD.provider_key - AND block_scope IN ('chat', 'spark') - GROUP BY credential_id, provider_key; - DELETE FROM auth_credential_block_mirror_guard - WHERE credential_id = OLD.credential_id; - END; - `); - } - - #createAuthCredentialBlockCompatibilityObjects(): void { - this.#createAuthCredentialBlockMirrorGuardTable(); - this.#createAuthCredentialBlockCompatibilityTriggers(); - } - - #createAuthCredentialRefreshLeasesTable(): void { - SqliteAuthCredentialStore.#ensureAuthCredentialRefreshLeasesTable(this.#db); - } - - #migrateAuthSchema(fromVersion: number): void { - if (fromVersion < 1) { - this.#migrateAuthSchemaV0ToV1(); - } - if (fromVersion < 3) { - this.#migrateAuthSchemaV1OrV2ToV3(); - } - if (fromVersion < 4) { - this.#migrateAuthSchemaV3ToV4(); - } - if (fromVersion < 5) { - this.#migrateAuthSchemaV4ToV5(); - } - if (fromVersion < 6) { - this.#migrateAuthSchemaV5ToV6(); - } - if (fromVersion < 7) { - this.#migrateAuthSchemaV6ToV7(); - } - } - - #migrateAuthSchemaV0ToV1(): void { - const migrate = this.#db.transaction(() => { - const stmt = this.#db.prepare("PRAGMA table_info(auth_credentials)"); - let hasDisabled = false; - try { - const v0Cols = stmt.all() as Array<{ name?: string }>; - hasDisabled = v0Cols.some(col => col.name === "disabled"); - } finally { - stmt.finalize(); - } - - this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_v0"); - this.#db.run(` - CREATE TABLE auth_credentials ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - provider TEXT NOT NULL, - credential_type TEXT NOT NULL, - data TEXT NOT NULL, - disabled_cause TEXT DEFAULT NULL, - created_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}), - updated_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}) - ); - `); - this.#db.run(` - INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, created_at, updated_at) - SELECT - id, - provider, - credential_type, - data, - ${hasDisabled ? "CASE WHEN disabled = 1 THEN 'disabled' ELSE NULL END" : "NULL"}, - created_at, - updated_at - FROM auth_credentials_v0 - `); - this.#db.run("DROP TABLE auth_credentials_v0"); - }); - migrate(); - } - - #migrateAuthSchemaV1OrV2ToV3(): void { - const migrate = this.#db.transaction(() => { - this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_legacy"); - this.#createAuthCredentialsTable(); - this.#db.run(` - INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, identity_key, created_at, updated_at) - SELECT - id, - provider, - credential_type, - data, - disabled_cause, - NULL, - created_at, - updated_at - FROM auth_credentials_legacy - `); - this.#db.run("DROP TABLE auth_credentials_legacy"); - }); - migrate(); - } - - #migrateAuthSchemaV3ToV4(): void { - const migrate = this.#db.transaction(() => { - this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_v3"); - this.#createAuthCredentialsTable(); - this.#db.run(` - INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, identity_key, created_at, updated_at) - SELECT - id, - provider, - credential_type, - data, - disabled_cause, - identity_key, - created_at, - updated_at - FROM auth_credentials_v3 - `); - this.#db.run("DROP TABLE auth_credentials_v3"); - }); - migrate(); - } - - #migrateAuthSchemaV4ToV5(): void { - const migrate = this.#db.transaction(() => { - this.#createAuthCredentialBlocksTable(); - }); - migrate(); - } - - #migrateAuthSchemaV5ToV6(): void { - const migrate = this.#db.transaction(() => { - this.#createAuthCredentialRefreshLeasesTable(); - }); - migrate(); - } - - #migrateAuthSchemaV6ToV7(): void { - const migrate = this.#db.transaction(() => { - this.#createAuthCredentialBlocksTable(); - this.#createAuthCredentialBlockMirrorGuardTable(); - this.#db.run(` - DELETE FROM auth_credential_block_mirror_guard; - INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id) - SELECT DISTINCT credential_id - FROM auth_credential_blocks - WHERE provider_key = 'openai-codex:oauth' - AND block_scope IN ('chat', 'spark', 'shared'); - - INSERT INTO auth_credential_blocks ( - credential_id, - provider_key, - block_scope, - blocked_until_ms, - updated_at - ) - SELECT credential_id, provider_key, 'chat', blocked_until_ms, updated_at - FROM auth_credential_blocks - WHERE provider_key = 'openai-codex:oauth' - AND block_scope = 'shared' - ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET - blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms), - updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at); - - INSERT INTO auth_credential_blocks ( - credential_id, - provider_key, - block_scope, - blocked_until_ms, - updated_at - ) - SELECT credential_id, provider_key, 'spark', blocked_until_ms, updated_at - FROM auth_credential_blocks - WHERE provider_key = 'openai-codex:oauth' - AND block_scope = 'shared' - ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET - blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms), - updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at); - - INSERT INTO auth_credential_blocks ( - credential_id, - provider_key, - block_scope, - blocked_until_ms, - updated_at - ) - SELECT - credential_id, - provider_key, - 'shared', - MAX(blocked_until_ms), - MAX(updated_at) - FROM auth_credential_blocks - WHERE provider_key = 'openai-codex:oauth' - AND block_scope IN ('chat', 'spark') - GROUP BY credential_id, provider_key - ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET - blocked_until_ms = excluded.blocked_until_ms, - updated_at = excluded.updated_at; - - DELETE FROM auth_credential_block_mirror_guard; - `); - this.#createAuthCredentialBlockCompatibilityTriggers(); - this.#writeAuthSchemaVersion(7); - }); - migrate.immediate(); - } - - #backfillCredentialIdentityKeys(): void { - const selectRowsStmt = this.#db.prepare( - "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE identity_key IS NULL ORDER BY id ASC", - ); - let rows: AuthRow[]; - try { - rows = selectRowsStmt.all() as AuthRow[]; - } finally { - selectRowsStmt.finalize(); - } - if (rows.length === 0) return; - - let updateIdentity: Statement | null = null; - try { - for (const row of rows) { - const identityKey = resolveRowCredentialIdentityKey(row.provider, row); - // Rows whose identity cannot be derived stay NULL; writing NULL over - // NULL would just burn a write transaction on every boot. - if (identityKey === null) continue; - updateIdentity ??= this.#db.prepare("UPDATE auth_credentials SET identity_key = ? WHERE id = ?"); - updateIdentity.run(identityKey, row.id); - } - } finally { - updateIdentity?.finalize(); - } - } - - // ─── AuthCredentialStore interface ────────────────────────────────────── - - listAuthCredentials(provider?: string): StoredAuthCredential[] { - const rows = - (provider - ? (this.#listActiveByProviderStmt.all(provider) as AuthRow[]) - : (this.#listActiveStmt.all() as AuthRow[])) ?? []; - - const results: StoredAuthCredential[] = []; - for (const row of rows) { - const credential = deserializeCredential(row); - if (!credential) continue; - results.push(toStoredAuthCredential(row, credential)); - } - return results; - } - - async listDisabledCredentials(provider?: string): Promise { - const rows = - (provider - ? (this.#listDisabledByProviderStmt.all(provider) as DisabledAuthRow[]) - : (this.#listDisabledStmt.all() as DisabledAuthRow[])) ?? []; - const results: DisabledCredentialSummary[] = []; - for (const row of rows) { - const credential = deserializeCredential(row); - const summary: DisabledCredentialSummary = { - id: row.id, - provider: row.provider, - type: row.credential_type === "api_key" ? "api_key" : "oauth", - cause: row.disabled_cause ?? "disabled", - }; - if (credential?.type === "oauth") { - if (credential.email) summary.email = credential.email; - if (credential.accountId) summary.accountId = credential.accountId; - if (credential.orgId) summary.orgId = credential.orgId; - if (credential.orgName) summary.orgName = credential.orgName; - } - if (typeof row.updated_at === "number" && Number.isFinite(row.updated_at)) { - summary.disabledAtMs = row.updated_at * 1000; - } - results.push(summary); - } - return results; - } - - replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[] { - const replace = this.#db.transaction((providerName: string, items: AuthCredential[]) => { - const existingRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[]; - const existing = existingRows.map(row => ({ - id: row.id, - credential: deserializeCredential(row), - identityKey: resolveRowCredentialIdentityKey(providerName, row), - })); - - const result: StoredAuthCredential[] = []; - const matchedExistingIds = new Set(); - - for (const credential of items) { - const serialized = serializeCredential(providerName, credential); - if (!serialized) continue; - const match = existing.find( - entry => - !matchedExistingIds.has(entry.id) && - matchesReplacementCredential(providerName, entry.credential, entry.identityKey, credential), - ); - if (match) { - matchedExistingIds.add(match.id); - this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, match.id); - result.push({ id: match.id, provider: providerName, credential, disabledCause: null }); - } else { - const row = this.#insertStmt.get( - providerName, - serialized.credentialType, - serialized.data, - serialized.identityKey, - ) as { id?: number } | undefined; - if (row?.id) { - result.push({ id: row.id, provider: providerName, credential, disabledCause: null }); - } - } - } - - for (const row of existing) { - if (!matchedExistingIds.has(row.id)) { - this.#deleteStmt.run("replaced by newer credential", row.id); - } - } - - return result; - }); - - const result = replace(provider, credentials); - this.#purgeSupersededDisabledRows(provider, result); - return result; - } - - upsertAuthCredentialForProvider(provider: string, credential: AuthCredential): StoredAuthCredential[] { - const upsert = this.#db.transaction((providerName: string, item: AuthCredential) => { - const serialized = serializeCredential(providerName, item); - if (!serialized) return this.listAuthCredentials(providerName); - const existingRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[]; - const existing = existingRows.map(row => ({ - id: row.id, - credential: deserializeCredential(row), - identityKey: resolveRowCredentialIdentityKey(providerName, row), - })); - - if (item.type === "oauth") { - for (const row of existing) { - if (row.credential && row.credential.type === "api_key") { - this.#deleteStmt.run("replaced by oauth login", row.id); - } - } - } - - let targetId: number | null = null; - for (const row of existing) { - if (!matchesReplacementCredential(providerName, row.credential, row.identityKey, item)) continue; - if (targetId === null) { - targetId = row.id; - this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, row.id); - continue; - } - this.#deleteStmt.run("replaced by newer credential", row.id); - } - - if (targetId === null) { - const row = this.#insertStmt.get( - providerName, - serialized.credentialType, - serialized.data, - serialized.identityKey, - ) as { id?: number } | undefined; - targetId = row?.id ?? null; - } - - const activeRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[]; - const result: StoredAuthCredential[] = []; - for (const row of activeRows) { - const activeCredential = deserializeCredential(row); - if (!activeCredential) continue; - result.push(toStoredAuthCredential(row, activeCredential)); - } - return result; - }); - - const result = upsert(provider, credential); - this.#purgeSupersededDisabledRows(provider, result); - return result; - } - - /** - * Hard-deletes disabled rows for a provider when an active replacement exists. - * OAuth credentials match by identity key; API keys match by provider and type. - * Disabled rows without an active same-type replacement remain recoverable. - */ - #purgeSupersededDisabledRows(provider: string, activeRows: StoredAuthCredential[]): void { - try { - let hasActiveApiKey = false; - const activeIdentityKeys = new Set(); - for (const row of activeRows) { - if (row.credential.type === "api_key") { - hasActiveApiKey = true; - continue; - } - const identityKey = resolveCredentialIdentityKey(provider, row.credential); - if (identityKey) activeIdentityKeys.add(identityKey); - } - if (!hasActiveApiKey && activeIdentityKeys.size === 0) return; - - const disabledRows = this.#listDisabledByProviderStmt.all(provider) as AuthRow[]; - for (const row of disabledRows) { - if (hasActiveApiKey && row.credential_type === "api_key") { - this.#hardDeleteStmt.run(row.id); - continue; - } - const identityKey = resolveRowCredentialIdentityKey(provider, row); - if (identityKey && activeIdentityKeys.has(identityKey)) { - this.#hardDeleteStmt.run(row.id); - } - } - } catch { - // Best-effort cleanup; don't let it break the main operation - } - } - - updateAuthCredential(id: number, credential: AuthCredential): void { - try { - const providerStmt = this.#db.prepare("SELECT provider FROM auth_credentials WHERE id = ?"); - let providerRow: { provider?: string } | undefined; - try { - providerRow = providerStmt.get(id) as { provider?: string } | undefined; - } finally { - providerStmt.finalize(); - } - const provider = providerRow?.provider ?? ""; - const serialized = serializeCredential(provider, credential); - if (!serialized) return; - this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, id); - if (provider) { - this.#purgeSupersededDisabledRows(provider, this.listAuthCredentials(provider)); - } - } catch { - // Ignore update failures - } - } - - tryUpdateAuthCredentialIfMatches( - id: number, - expectedData: string, - credential: AuthCredential, - lease?: CredentialRefreshLeaseFence, - ): boolean { - const providerStmt = this.#db.prepare("SELECT provider FROM auth_credentials WHERE id = ?"); - let providerRow: { provider?: string } | undefined; - try { - providerRow = providerStmt.get(id) as { provider?: string } | undefined; - } finally { - providerStmt.finalize(); - } - const provider = providerRow?.provider ?? ""; - const serialized = serializeCredential(provider, credential); - if (!serialized) return false; - const result = lease - ? (this.#updateIfMatchesWithLeaseStmt.run( - serialized.credentialType, - serialized.data, - serialized.identityKey, - id, - expectedData, - id, - lease.owner, - lease.nowMs, - ) as { changes: number }) - : (this.#updateIfMatchesStmt.run( - serialized.credentialType, - serialized.data, - serialized.identityKey, - id, - expectedData, - ) as { changes: number }); - if (result.changes === 0) return false; - if (provider) { - this.#purgeSupersededDisabledRows(provider, this.listAuthCredentials(provider)); - } - return true; - } - - deleteAuthCredential(id: number, disabledCause: string): void { - try { - this.#deleteStmt.run(normalizeDisabledCause(disabledCause), id); - } catch { - // Ignore delete failures - } - } - - /** - * CAS-style disable: only soft-deletes the row when its `data` column still - * matches `expectedData` and the row has not already been disabled. Used by - * the OAuth refresh-failure path to avoid clobbering a peer that rotated the - * row between our pre-check and the disable. - */ - tryDisableAuthCredentialIfMatches( - id: number, - expectedData: string, - disabledCause: string, - lease?: CredentialRefreshLeaseFence, - ): boolean { - const result = lease - ? (this.#deleteIfMatchesWithLeaseStmt.run( - normalizeDisabledCause(disabledCause), - id, - expectedData, - id, - lease.owner, - lease.nowMs, - ) as { changes: number }) - : (this.#deleteIfMatchesStmt.run(normalizeDisabledCause(disabledCause), id, expectedData) as { - changes: number; - }); - return result.changes > 0; - } - deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void { - try { - this.#deleteByProviderStmt.run(normalizeDisabledCause(disabledCause), provider); - } catch { - // Ignore delete failures - } - } - - getCache(key: string, options?: { includeExpired?: boolean }): string | null { - try { - const stmt = options?.includeExpired === true ? this.#getCacheIncludingExpiredStmt : this.#getCacheStmt; - const row = stmt.get(key) as { value?: string } | undefined; - return row?.value ?? null; - } catch { - return null; - } - } - - setCache(key: string, value: string, expiresAtSec: number): void { - try { - this.#upsertCacheStmt.run(key, value, expiresAtSec); - } catch { - // Ignore cache set failures - } - } - - /** Drop all cache rows whose keys start with the supplied prefix. */ - deleteCachePrefix(prefix: string): void { - try { - this.#deleteCachePrefixStmt.run(prefix.length, prefix); - } catch { - // Ignore cache delete failures - } - } - - cleanExpiredCache(): void { - try { - this.#deleteExpiredCacheStmt.run(); - } catch { - // Ignore cleanup errors - } - } - - getCredentialBlock(credentialId: number, providerKey: string, blockScope: string): number | undefined { - const nowMs = Date.now(); - const isCodexBlock = providerKey === LEGACY_CODEX_BLOCK_PROVIDER_KEY; - // Current callers use meter scopes. The physical shared row exists only - // for direct SQLite readers from pre-meter releases. - if (isCodexBlock && blockScope === LEGACY_CODEX_BLOCK_SCOPE) { - return undefined; - } - if (!isCodexBlock) this.#deleteExpiredCredentialBlocksStmt.run(nowMs); - const row = this.#getCredentialBlockStmt.get(credentialId, providerKey, blockScope, nowMs) as - | { blocked_until_ms?: number; updated_at?: number } - | undefined; - return typeof row?.blocked_until_ms === "number" ? row.blocked_until_ms : undefined; - } - - getCredentialBlockReconcileAfter(credentialId: number, providerKey: string, blockScope: string): number | undefined { - const nowMs = Date.now(); - const isCodexBlock = providerKey === LEGACY_CODEX_BLOCK_PROVIDER_KEY; - if (isCodexBlock && blockScope === LEGACY_CODEX_BLOCK_SCOPE) { - return undefined; - } - if (!isCodexBlock) this.#deleteExpiredCredentialBlocksStmt.run(nowMs); - const row = this.#getCredentialBlockStmt.get(credentialId, providerKey, blockScope, nowMs) as - | { blocked_until_ms?: number; updated_at?: number } - | undefined; - if (typeof row?.blocked_until_ms !== "number") return undefined; - const memoryReconcileAfter = - this.#credentialBlockReconcileAfter.get(`${credentialId}\0${providerKey}\0${blockScope}`) ?? 0; - const persistedReconcileAfter = - typeof row.updated_at === "number" ? row.updated_at * 1000 + USAGE_REPORT_TTL_MS : 0; - const reconcileAfter = Math.max(memoryReconcileAfter, persistedReconcileAfter); - return reconcileAfter > nowMs ? Math.min(row.blocked_until_ms, reconcileAfter) : undefined; - } - - upsertCredentialBlock(block: StoredCredentialBlock): void { - const isLegacyCodexBlock = - block.providerKey === LEGACY_CODEX_BLOCK_PROVIDER_KEY && block.blockScope === LEGACY_CODEX_BLOCK_SCOPE; - const blockScopes = isLegacyCodexBlock ? CODEX_METER_BLOCK_SCOPES : [block.blockScope]; - const upsert = this.#db.transaction(() => { - for (const blockScope of blockScopes) { - this.#upsertCredentialBlockStmt.run( - block.credentialId, - block.providerKey, - blockScope, - block.blockedUntilMs, - ); - } - }); - upsert.immediate(); - - const reconcileAfterMs = Math.min(block.blockedUntilMs, Date.now() + USAGE_REPORT_TTL_MS); - for (const blockScope of blockScopes) { - this.#credentialBlockReconcileAfter.set( - `${block.credentialId}\0${block.providerKey}\0${blockScope}`, - reconcileAfterMs, - ); - } - if (isLegacyCodexBlock) { - this.#credentialBlockReconcileAfter.delete( - `${block.credentialId}\0${block.providerKey}\0${LEGACY_CODEX_BLOCK_SCOPE}`, - ); - } - } - - deleteCredentialBlock(credentialId: number, providerKey: string, blockScope: string): void { - this.#deleteCredentialBlockStmt.run(credentialId, providerKey, blockScope); - this.#credentialBlockReconcileAfter.delete(`${credentialId}\0${providerKey}\0${blockScope}`); - } - - deleteCredentialBlocks(credentialId: number): void { - this.#deleteCredentialBlocksStmt.run(credentialId); - for (const key of this.#credentialBlockReconcileAfter.keys()) { - if (key.startsWith(`${credentialId}\0`)) this.#credentialBlockReconcileAfter.delete(key); - } - } - - cleanExpiredCredentialBlocks(nowMs: number): void { - this.#deleteExpiredCredentialBlocksStmt.run(nowMs); - for (const [key, reconcileAfterMs] of this.#credentialBlockReconcileAfter) { - if (reconcileAfterMs <= nowMs) this.#credentialBlockReconcileAfter.delete(key); - } - } - - listCredentialBlocks(credentialIds: readonly number[]): StoredCredentialBlock[] { - if (credentialIds.length === 0) return []; - const nowMs = Date.now(); - this.cleanExpiredCredentialBlocks(nowMs); - const seenCredentialIds = new Set(); - const blocks: StoredCredentialBlock[] = []; - for (const credentialId of credentialIds) { - if (seenCredentialIds.has(credentialId)) continue; - seenCredentialIds.add(credentialId); - const rows = this.#listCredentialBlocksByCredentialStmt.all( - credentialId, - nowMs, - LEGACY_CODEX_BLOCK_PROVIDER_KEY, - LEGACY_CODEX_BLOCK_SCOPE, - ) as CredentialBlockRow[]; - for (const row of rows) { - blocks.push({ - credentialId: row.credential_id, - providerKey: row.provider_key, - blockScope: row.block_scope, - blockedUntilMs: row.blocked_until_ms, - updatedAtMs: row.updated_at * 1000, - }); - } - } - return blocks; - } - - tryAcquireCredentialRefreshLease(credentialId: number, owner: string, expiresAtMs: number): boolean { - const result = this.#acquireCredentialRefreshLeaseStmt.run(credentialId, owner, expiresAtMs, Date.now()) as { - changes: number; - }; - return result.changes === 1; - } - - getCredentialRefreshLeaseExpiresAt(credentialId: number): number | undefined { - const row = this.#getCredentialRefreshLeaseStmt.get(credentialId) as { expires_at_ms?: number } | undefined; - if (typeof row?.expires_at_ms !== "number") return undefined; - if (row.expires_at_ms <= Date.now()) return undefined; - return row.expires_at_ms; - } - - renewCredentialRefreshLease(credentialId: number, owner: string, expiresAtMs: number): boolean { - const result = this.#renewCredentialRefreshLeaseStmt.run(expiresAtMs, credentialId, owner) as { - changes: number; - }; - return result.changes === 1; - } - - releaseCredentialRefreshLease(credentialId: number, owner: string): void { - try { - this.#releaseCredentialRefreshLeaseStmt.run(credentialId, owner); - } catch { - // Ignore lease release failures; expired leases are stealable. - } - } - - recordUsageSnapshots(entries: UsageHistoryEntry[]): void { - try { - for (const entry of entries) { - const bucket = Math.floor(entry.recordedAt / USAGE_HISTORY_BUCKET_MS); - const last = this.#lastUsageHistoryStmt.get(entry.provider, entry.accountKey, entry.limitId) as - | { id: number; recorded_at: number } - | undefined; - if (last && Math.floor(last.recorded_at / USAGE_HISTORY_BUCKET_MS) === bucket) { - this.#updateUsageHistoryStmt.run( - entry.recordedAt, - entry.email ?? null, - entry.accountId ?? null, - entry.label, - entry.windowLabel ?? null, - entry.usedFraction ?? null, - entry.status ?? null, - entry.resetsAt ?? null, - last.id, - ); - continue; - } - this.#insertUsageHistoryStmt.run( - entry.recordedAt, - entry.provider, - entry.accountKey, - entry.email ?? null, - entry.accountId ?? null, - entry.limitId, - entry.label, - entry.windowLabel ?? null, - entry.usedFraction ?? null, - entry.status ?? null, - entry.resetsAt ?? null, - ); - } - } catch { - // History is best-effort; never break the usage fetch path. - } - } - - listUsageHistory(query?: UsageHistoryQuery): UsageHistoryEntry[] { - try { - const provider = query?.provider ?? null; - const rows = this.#listUsageHistoryStmt.all(query?.sinceMs ?? 0, provider, provider) as Array<{ - recorded_at: number; - provider: string; - account_key: string; - email: string | null; - account_id: string | null; - limit_id: string; - label: string; - window_label: string | null; - used_fraction: number | null; - status: string | null; - resets_at: number | null; - }>; - return rows.map(row => ({ - recordedAt: row.recorded_at, - provider: row.provider as Provider, - accountKey: row.account_key, - email: row.email ?? undefined, - accountId: row.account_id ?? undefined, - limitId: row.limit_id, - label: row.label, - windowLabel: row.window_label ?? undefined, - usedFraction: row.used_fraction ?? undefined, - status: (row.status ?? undefined) as UsageHistoryEntry["status"], - resetsAt: row.resets_at ?? undefined, - })); - } catch { - return []; - } - } - recordUsageCosts(entries: UsageCostHistoryEntry[]): void { - try { - for (const entry of entries) { - this.#insertUsageCostStmt.run(entry.recordedAt, entry.provider, entry.accountKey, entry.costUsd); - } - } catch { - // Cost history is best-effort; never break request persistence. - } - } - - listUsageCosts(query?: UsageCostHistoryQuery): UsageCostHistoryEntry[] { - try { - const provider = query?.provider ?? null; - const accountKey = query?.accountKey ?? null; - const rows = this.#listUsageCostsStmt.all( - query?.sinceMs ?? 0, - provider, - provider, - accountKey, - accountKey, - ) as Array<{ - recorded_at: number; - provider: string; - account_key: string; - cost_usd: number; - }>; - return rows.map(row => ({ - recordedAt: row.recorded_at, - provider: row.provider as Provider, - accountKey: row.account_key, - costUsd: row.cost_usd, - })); - } catch { - return []; - } - } - - recordClientUsage(report: ClientUsageReport): void { - const now = Date.now(); - this.#db - .query( - `INSERT INTO clients (install_id, hostname, first_seen, last_seen) VALUES (?, ?, ?, ?) - ON CONFLICT(install_id) DO UPDATE SET hostname = COALESCE(excluded.hostname, hostname), last_seen = excluded.last_seen`, - ) - .run(report.installId, report.hostname ?? null, now, now); - const findBucket = this.#db.query( - `SELECT id FROM client_usage - WHERE install_id = ? AND provider = ? AND model = ? AND recorded_at >= ? - ORDER BY recorded_at DESC LIMIT 1`, - ); - const merge = this.#db.query( - `UPDATE client_usage SET recorded_at = ?, requests = requests + ?, input_tokens = input_tokens + ?, - output_tokens = output_tokens + ?, cache_read_tokens = cache_read_tokens + ?, - cache_write_tokens = cache_write_tokens + ?, cost_usd = cost_usd + ? WHERE id = ?`, - ); - const insert = this.#db.query( - `INSERT INTO client_usage (recorded_at, install_id, provider, model, requests, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, cost_usd) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ); - for (const entry of report.entries) { - // Merge into the newest row of the same (install, provider, model) - // bucket so 10s client flushes don't accrete one row apiece forever. - const bucketFloor = entry.at - CLIENT_USAGE_BUCKET_MS; - const existing = findBucket.get(report.installId, entry.provider, entry.model, bucketFloor) as { - id: number; - } | null; - if (existing) { - merge.run( - entry.at, - entry.requests, - entry.inputTokens, - entry.outputTokens, - entry.cacheReadTokens, - entry.cacheWriteTokens, - entry.costUsd, - existing.id, - ); - continue; - } - insert.run( - entry.at, - report.installId, - entry.provider, - entry.model, - entry.requests, - entry.inputTokens, - entry.outputTokens, - entry.cacheReadTokens, - entry.cacheWriteTokens, - entry.costUsd, - ); - } - } - - getClientUsageSummary(sinceMs: number): ClientUsageSummary { - const clients = this.#db - .query("SELECT install_id, hostname, first_seen, last_seen FROM clients ORDER BY last_seen DESC") - .all() as Array<{ install_id: string; hostname: string | null; first_seen: number; last_seen: number }>; - const aggregates = this.#db - .query( - `SELECT install_id, provider, SUM(requests) requests, SUM(input_tokens) input_tokens, - SUM(output_tokens) output_tokens, SUM(cache_read_tokens) cache_read_tokens, - SUM(cache_write_tokens) cache_write_tokens, SUM(cost_usd) cost_usd - FROM client_usage WHERE recorded_at >= ? GROUP BY install_id, provider - ORDER BY install_id, SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) DESC`, - ) - .all(sinceMs) as Array<{ - install_id: string; - provider: string; - requests: number; - input_tokens: number; - output_tokens: number; - cache_read_tokens: number; - cache_write_tokens: number; - cost_usd: number; - }>; - const providersByInstall = new Map(); - for (const row of aggregates) { - let list = providersByInstall.get(row.install_id); - if (!list) { - list = []; - providersByInstall.set(row.install_id, list); - } - list.push({ - provider: row.provider, - requests: row.requests, - inputTokens: row.input_tokens, - outputTokens: row.output_tokens, - cacheReadTokens: row.cache_read_tokens, - cacheWriteTokens: row.cache_write_tokens, - costUsd: row.cost_usd, - }); - } - return { - clients: clients.map(client => ({ - installId: client.install_id, - hostname: client.hostname ?? undefined, - firstSeen: client.first_seen, - lastSeen: client.last_seen, - providers: providersByInstall.get(client.install_id) ?? [], - })), - }; - } - - // ─── Convenience methods for CLI ──────────────────────────────────────── - - /** - * Save OAuth credentials for a provider. - * Preserves unrelated identities and replaces only the matching credential. - */ - saveOAuth(provider: string, credentials: OAuthCredentials): void { - const credential: AuthCredential = { type: "oauth", ...credentials }; - this.upsertAuthCredentialForProvider(provider, credential); - } - - /** - * Get OAuth credentials for a provider. - */ - getOAuth(provider: string): OAuthCredentials | null { - const rows = this.#listActiveByProviderStmt.all(provider) as AuthRow[]; - for (const row of rows) { - const credential = deserializeCredential(row); - if (credential && credential.type === "oauth") { - const { type: _type, ...oauth } = credential; - return oauth as OAuthCredentials; - } - } - return null; - } - - /** - * Save API key for a provider (replaces existing). - */ - saveApiKey(provider: string, apiKey: string): void { - const credential: AuthCredential = { type: "api_key", key: apiKey }; - this.replaceAuthCredentialsForProvider(provider, [credential]); - } - - /** - * Get API key for a provider. - */ - getApiKey(provider: string): string | null { - const rows = this.#listActiveByProviderStmt.all(provider) as AuthRow[]; - for (const row of rows) { - const credential = deserializeCredential(row); - if (credential && credential.type === "api_key") { - return credential.key; - } - } - return null; - } - - /** - * List all providers with credentials. - */ - listProviders(): string[] { - const rows = this.#listActiveStmt.all() as AuthRow[]; - const providers = new Set(); - for (const row of rows) { - providers.add(row.provider); - } - return Array.from(providers); - } - - /** - * Delete all credentials for a provider. - */ - deleteProvider(provider: string): void { - this.deleteAuthCredentialsForProvider(provider, "deleted by user"); - } - - /** - * SQLite increments `data_version` when another connection commits. Own - * writes leave it unchanged and already notify AuthStorage directly. - */ - pollExternalChanges(): boolean { - this.#acknowledgeLocalAuthChanges(); - const dataVersion = this.#readDataVersion(); - if (dataVersion === this.#dataVersion) return false; - this.#dataVersion = dataVersion; - const authRevision = this.#readAuthRevision(); - if (authRevision === this.#authRevision) return false; - this.#authRevision = authRevision; - return true; - } - - acknowledgeLocalChanges(): void { - this.#acknowledgeLocalAuthChanges(); - } - - #acknowledgeLocalAuthChanges(): void { - const localAuthRevision = this.#readLocalAuthRevision(); - this.#authRevision += localAuthRevision - this.#localAuthRevision; - this.#localAuthRevision = localAuthRevision; - } - - #readDataVersion(): number { - const row = this.#db.query("PRAGMA data_version").get() as { data_version?: number } | null; - return row?.data_version ?? 0; - } - - #readAuthRevision(): number { - const row = this.#db.query("SELECT revision FROM auth_change_revision WHERE id = 1").get() as { - revision?: number; - } | null; - return row?.revision ?? 0; - } - - #readLocalAuthRevision(): number { - const row = this.#db.query("SELECT revision FROM auth_local_change_revision WHERE id = 1").get() as { - revision?: number; - } | null; - return row?.revision ?? 0; - } - - close(): void { - if (this.#closed) return; - this.#closed = true; - this.#listActiveStmt.finalize(); - this.#listActiveByProviderStmt.finalize(); - this.#listDisabledStmt.finalize(); - this.#listDisabledByProviderStmt.finalize(); - this.#insertStmt.finalize(); - this.#updateStmt.finalize(); - this.#deleteStmt.finalize(); - this.#deleteIfMatchesStmt.finalize(); - this.#deleteByProviderStmt.finalize(); - this.#hardDeleteStmt.finalize(); - this.#getCacheStmt.finalize(); - this.#getCacheIncludingExpiredStmt.finalize(); - this.#upsertCacheStmt.finalize(); - this.#deleteExpiredCacheStmt.finalize(); - this.#getCredentialBlockStmt.finalize(); - this.#listCredentialBlocksByCredentialStmt.finalize(); - this.#upsertCredentialBlockStmt.finalize(); - this.#deleteCredentialBlocksStmt.finalize(); - this.#deleteCredentialBlockStmt.finalize(); - this.#deleteExpiredCredentialBlocksStmt.finalize(); - this.#insertUsageHistoryStmt.finalize(); - this.#lastUsageHistoryStmt.finalize(); - this.#listUsageHistoryStmt.finalize(); - this.#updateUsageHistoryStmt.finalize(); - this.#insertUsageCostStmt.finalize(); - this.#listUsageCostsStmt.finalize(); - this.#updateIfMatchesStmt.finalize(); - this.#updateIfMatchesWithLeaseStmt.finalize(); - this.#deleteIfMatchesWithLeaseStmt.finalize(); - this.#deleteCachePrefixStmt.finalize(); - this.#acquireCredentialRefreshLeaseStmt.finalize(); - this.#getCredentialRefreshLeaseStmt.finalize(); - this.#renewCredentialRefreshLeaseStmt.finalize(); - this.#releaseCredentialRefreshLeaseStmt.finalize(); - this.#db.close(); - } -} diff --git a/packages/ai/src/auth/sqlite-credential-store.ts b/packages/ai/src/auth/sqlite-credential-store.ts new file mode 100644 index 000000000..0ae14f851 --- /dev/null +++ b/packages/ai/src/auth/sqlite-credential-store.ts @@ -0,0 +1,2049 @@ +/** + * SQLite-backed credential persistence for AuthStorage. + * + * The public AuthCredentialStore interface remains in ../auth-storage so local + * and remote stores share the same contract. + */ +import { Database, type Statement } from "bun:sqlite"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { parseAlibabaTokenPlanCredential } from "@oh-my-pi/pi-catalog/wire/alibaba-token-plan"; +import { getAgentDbPath, getDbBusyTimeoutMs, logger } from "@oh-my-pi/pi-utils"; +import type { + AuthCredential, + AuthCredentialStore, + CredentialRefreshLeaseFence, + DisabledCredentialSummary, + OAuthCredential, + StoredAuthCredential, + StoredCredentialBlock, +} from "../auth-storage"; +import * as AIError from "../error"; +import type { OAuthCredentials } from "../registry/oauth/types"; +import type { Provider } from "../types"; +import type { + ClientProviderUsage, + ClientUsageReport, + ClientUsageSummary, + UsageCostHistoryEntry, + UsageCostHistoryQuery, + UsageHistoryEntry, + UsageHistoryQuery, +} from "../usage"; + +// 5 min stale tolerance. Anthropic / OpenAI rate-limit /usage hard at the IP +// level so we can't fetch all N credentials every cycle; with a long cache +// each credential's last-known value sticks visible while peers retry. UI +// data (5h / 7d / monthly limits) is fine being a few minutes stale. +export const USAGE_REPORT_TTL_MS = 5 * 60_000; + +/** + * Downsample usage history to at most one row per hour per account window: a + * snapshot landing in the same hour bucket as the series' latest row + * overwrites it in place. That bound makes further retention pruning + * unnecessary — 1 row/hour is ~9k rows per account window per year. + */ +const USAGE_HISTORY_BUCKET_MS = 60 * 60_000; + +/** + * Merge client observed-usage flushes into at most one row per 5 minutes per + * (install, provider, model): ~300 rows/day per active model per client + * instead of one row per 10s flush. + */ +const CLIENT_USAGE_BUCKET_MS = 5 * 60_000; + +// ───────────────────────────────────────────────────────────────────────────── +// SqliteAuthCredentialStore +// ───────────────────────────────────────────────────────────────────────────── + +/** Row shape for auth_credentials table queries */ +type AuthRow = { + id: number; + provider: string; + credential_type: string; + data: string; + disabled_cause: string | null; + identity_key: string | null; +}; + +/** {@link AuthRow} plus `updated_at` — disabled-tombstone queries surface when the row was torn down. */ +type DisabledAuthRow = AuthRow & { updated_at: number | null }; + +type CredentialBlockRow = { + credential_id: number; + provider_key: string; + block_scope: string; + blocked_until_ms: number; + updated_at: number; +}; + +type SerializedCredentialRecord = { + credentialType: AuthCredential["type"]; + data: string; + identityKey: string | null; +}; + +const AUTH_SCHEMA_VERSION = 7; +const SQLITE_NOW_EPOCH = "CAST(strftime('%s','now') AS INTEGER)"; +const LEGACY_CODEX_BLOCK_PROVIDER_KEY = "openai-codex:oauth"; +const LEGACY_CODEX_BLOCK_SCOPE = "shared"; +const CODEX_METER_BLOCK_SCOPES = ["chat", "spark"] as const; + +/** + * SQLite's busy result code family — base `SQLITE_BUSY` plus the extended + * variants `SQLITE_BUSY_RECOVERY` (concurrent WAL recovery), `SQLITE_BUSY_SNAPSHOT`, + * and `SQLITE_BUSY_TIMEOUT`. All warrant the same backoff-and-retry treatment. + */ +export function isSqliteBusyError(err: unknown): boolean { + if (err === null || typeof err !== "object") return false; + const code = (err as { code?: unknown }).code; + return typeof code === "string" && code.startsWith("SQLITE_BUSY"); +} + +/** + * SQLite's unrecoverable-corruption result codes — the `SQLITE_CORRUPT` family + * (base plus extended variants like `SQLITE_CORRUPT_VTAB` / `SQLITE_CORRUPT_INDEX`) + * and `SQLITE_NOTADB` (the file header is not a database). Unlike + * {@link isSqliteBusyError}, these never clear by retrying: the store must be + * repaired or replaced, so callers latch and stop touching it. + */ +export function isSqliteCorruptionError(err: unknown): boolean { + if (err === null || typeof err !== "object" || !("code" in err)) return false; + const code = err.code; + return typeof code === "string" && (code.startsWith("SQLITE_CORRUPT") || code === "SQLITE_NOTADB"); +} + +function normalizeStoredAccountId(accountId: string | null | undefined): string | null { + const normalized = accountId?.trim(); + return normalized && normalized.length > 0 ? normalized : null; +} + +function normalizeStoredEmail(email: string | null | undefined): string | null { + const normalized = email?.trim().toLowerCase(); + return normalized && normalized.length > 0 ? normalized : null; +} + +function normalizeStoredIdentityKey(identityKey: string | null | undefined): string | null { + const normalized = identityKey?.trim(); + return normalized && normalized.length > 0 ? normalized : null; +} + +export function serializeCredential(provider: string, credential: AuthCredential): SerializedCredentialRecord | null { + if (credential.type === "api_key") { + const data = credential.source === "login" ? { key: credential.key, source: "login" } : { key: credential.key }; + return { + credentialType: "api_key", + data: JSON.stringify(data), + identityKey: null, + }; + } + if (credential.type === "oauth") { + const { type: _type, ...rest } = credential; + return { + credentialType: "oauth", + data: JSON.stringify(rest), + identityKey: resolveCredentialIdentityKey(provider, credential), + }; + } + return null; +} + +function deserializeCredential(row: AuthRow): AuthCredential | null { + let parsed: unknown; + try { + parsed = JSON.parse(row.data); + } catch { + return null; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + if (row.credential_type === "api_key") { + const data = parsed as Record; + if (typeof data.key === "string") { + const source = data.source === "login" ? "login" : undefined; + return source ? { type: "api_key", key: data.key, source } : { type: "api_key", key: data.key }; + } + } + if (row.credential_type === "oauth") { + return { type: "oauth", ...(parsed as Record) } as AuthCredential; + } + return null; +} + +function normalizeDisabledCause(disabledCause: string): string { + const normalized = disabledCause.trim(); + return normalized.length > 0 ? normalized : "disabled"; +} + +function toStoredAuthCredential(row: AuthRow, credential: AuthCredential): StoredAuthCredential { + return { id: row.id, provider: row.provider, credential, disabledCause: row.disabled_cause }; +} + +function resolveProviderCredentialIdentityKey(provider: string, identifiers: string[]): string | null { + const emailIdentifier = identifiers.find(identifier => identifier.startsWith("email:")); + if (provider === "anthropic" || provider === "openai-codex") { + // One account email can hold several organizations/workspaces (e.g. a + // Team seat plus a personal plan), each with its own org-scoped token + // and limit pools. Scope identity by org so both subscriptions can be + // stored side by side. The qualifier rides on whichever base identity + // is available, so an unqualified account/project fallback would + // still collapse two subscriptions whenever the email could not be + // recovered. Org-less credentials (rows written before org capture + // existed) keep their bare key. + const base = + emailIdentifier ?? + identifiers.find(identifier => identifier.startsWith("account:")) ?? + identifiers.find(identifier => identifier.startsWith("project:")); + const orgIdentifier = identifiers.find(identifier => identifier.startsWith("org:")); + if (base) return orgIdentifier ? `${base}|${orgIdentifier}` : base; + // No base identity at all: the org alone still distinguishes the row. + return orgIdentifier ?? null; + } + const accountIdentifier = identifiers.find(identifier => identifier.startsWith("account:")); + if (accountIdentifier) return accountIdentifier; + if (emailIdentifier) return emailIdentifier; + const projectIdentifier = identifiers.find(identifier => identifier.startsWith("project:")); + if (projectIdentifier) return projectIdentifier; + return null; +} + +export function resolveCredentialIdentityKey(provider: string, credential: AuthCredential): string | null { + if (credential.type === "api_key") return null; + return resolveProviderCredentialIdentityKey(provider, extractOAuthCredentialIdentifiers(credential)); +} + +function resolveRowCredentialIdentityKey(provider: string, row: AuthRow): string | null { + const identityKey = normalizeStoredIdentityKey(row.identity_key); + if (identityKey) return identityKey; + const credential = deserializeCredential(row); + return credential?.type === "oauth" ? resolveCredentialIdentityKey(provider, credential) : null; +} + +function matchesReplacementCredential( + provider: string, + existing: AuthCredential | null, + existingIdentityKey: string | null, + incoming: AuthCredential, +): boolean { + if (!existing || existing.type !== incoming.type) return false; + if (incoming.type === "api_key") { + if (existing.type !== "api_key") return false; + if (existing.key === incoming.key) return true; + if (provider !== "alibaba-token-plan") return false; + const existingToken = parseAlibabaTokenPlanCredential(existing.key)?.token; + const incomingToken = parseAlibabaTokenPlanCredential(incoming.key)?.token; + return existingToken !== undefined && existingToken === incomingToken; + } + const incomingIdentifiers = extractOAuthCredentialIdentifiers(incoming); + const incomingIdentityKey = resolveProviderCredentialIdentityKey(provider, incomingIdentifiers); + if (incomingIdentityKey === null) return false; + if (incomingIdentityKey === existingIdentityKey) return true; + if (existingIdentityKey === null) return false; + // One-way upgrade, applied only when the INCOMING identity key carries the + // org qualifier (only anthropic and openai-codex keys do, so other + // providers never reach the checks below). An org-scoped login `org:` + // claims (and re-keys) any existing row that denotes the same subscription: + // - `org:` — org-only row stored when identity recovery failed, claimed + // once a later same-org login recovers a base identity; + // - `` for any base identity `` (email/account/project) the incoming + // credential carries — a pre-org legacy row, mirroring the pre-org + // replace behavior; + // - `|org:` for any such base — the same subscription keyed by a + // different base, e.g. an account-keyed row stored while the email could + // not be recovered, claimed once a later login recovers the email; + // - any same-org row whose STORED credential shares a base identity with + // the incoming one — a stored credential can retain identifiers its key + // does not use (an email-keyed row also carries the account UUID), so a + // later login that loses the email but keeps the account still updates + // its row instead of duplicating the subscription. + // The reverse stays a non-match: an org-less credential only ever replaces + // via exact key equality above and must never clobber an org-scoped row. + const orgIdentifier = incomingIdentifiers.find(identifier => identifier.startsWith("org:")); + if (orgIdentifier === undefined) return false; + if (incomingIdentityKey !== orgIdentifier && !incomingIdentityKey.endsWith(`|${orgIdentifier}`)) return false; + if (existingIdentityKey === orgIdentifier) return true; + const existingIdentifiers = + existing.type === "oauth" && existingIdentityKey.endsWith(`|${orgIdentifier}`) + ? extractOAuthCredentialIdentifiers(existing) + : null; + // A base identifier that merely repeats the org qualifier's id carries no + // per-user identity (openai-codex stores the ChatGPT workspace id as both + // accountId and orgId, shared by every member) — letting it act as a + // claimable base would re-key another member's same-org row. + const orgQualifierId = orgIdentifier.slice("org:".length); + for (const identifier of incomingIdentifiers) { + const isBase = + identifier.startsWith("email:") || identifier.startsWith("account:") || identifier.startsWith("project:"); + if (!isBase) continue; + if (identifier.slice(identifier.indexOf(":") + 1) === orgQualifierId) continue; + if (existingIdentityKey === identifier) return true; + if (existingIdentityKey === `${identifier}|${orgIdentifier}`) return true; + if (existingIdentifiers?.includes(identifier)) return true; + } + return false; +} + +function extractOAuthCredentialIdentifiers(credential: OAuthCredential): string[] { + const identifiers = new Set(); + const accountId = normalizeStoredAccountId(credential.accountId); + if (accountId) identifiers.add(`account:${accountId}`); + const email = normalizeStoredEmail(credential.email); + if (email) identifiers.add(`email:${email}`); + const projectId = normalizeStoredAccountId(credential.projectId); + if (projectId) identifiers.add(`project:${projectId}`); + const orgId = normalizeStoredAccountId(credential.orgId); + if (orgId) identifiers.add(`org:${orgId}`); + const accessIdentifiers = extractOAuthTokenIdentifiers(credential.access) ?? []; + for (const identifier of accessIdentifiers) { + identifiers.add(identifier); + } + const refreshIdentifiers = extractOAuthTokenIdentifiers(credential.refresh) ?? []; + for (const identifier of refreshIdentifiers) { + identifiers.add(identifier); + } + return [...identifiers]; +} + +function extractOAuthTokenIdentifiers(token: string | undefined): string[] | undefined { + if (!token) return undefined; + const parts = token.split("."); + if (parts.length !== 3) return undefined; + try { + const payload = JSON.parse( + new TextDecoder("utf-8").decode(Uint8Array.fromBase64(parts[1], { alphabet: "base64url" })), + ) as Record; + const identifiers = new Set(); + const directEmail = normalizeStoredEmail(typeof payload.email === "string" ? payload.email : undefined); + if (directEmail) identifiers.add(`email:${directEmail}`); + const openAiProfile = payload["https://api.openai.com/profile"]; + if (typeof openAiProfile === "object" && openAiProfile !== null && !Array.isArray(openAiProfile)) { + const claimEmail = normalizeStoredEmail( + (openAiProfile as Record).email as string | undefined, + ); + if (claimEmail) identifiers.add(`email:${claimEmail}`); + } + const openAiAuth = payload["https://api.openai.com/auth"]; + const authClaims = + typeof openAiAuth === "object" && openAiAuth !== null && !Array.isArray(openAiAuth) + ? (openAiAuth as Record) + : undefined; + const accountId = normalizeStoredAccountId( + typeof payload.account_id === "string" + ? payload.account_id + : typeof payload.accountId === "string" + ? payload.accountId + : typeof payload.user_id === "string" + ? payload.user_id + : typeof payload.sub === "string" + ? payload.sub + : typeof authClaims?.chatgpt_account_id === "string" + ? authClaims.chatgpt_account_id + : undefined, + ); + if (accountId) identifiers.add(`account:${accountId}`); + return identifiers.size > 0 ? [...identifiers] : undefined; + } catch { + return undefined; + } +} +/** + * Default SQLite-backed implementation of {@link AuthCredentialStore}. + * + * Used by the pi-ai CLI and as the default store for `AuthStorage.create()`. + * Also exposes convenience methods (`saveOAuth`, `getOAuth`, `saveApiKey`, + * `getApiKey`, `listProviders`, `deleteProvider`) that callers can use directly + * without going through `AuthStorage`. + */ +export class SqliteAuthCredentialStore implements AuthCredentialStore { + #db: Database; + #listActiveStmt: Statement; + #listActiveByProviderStmt: Statement; + #listDisabledStmt: Statement; + #listDisabledByProviderStmt: Statement; + #insertStmt: Statement; + #updateStmt: Statement; + #deleteStmt: Statement; + #deleteIfMatchesStmt: Statement; + #updateIfMatchesStmt: Statement; + #deleteByProviderStmt: Statement; + #hardDeleteStmt: Statement; + #getCacheStmt: Statement; + #getCacheIncludingExpiredStmt: Statement; + #upsertCacheStmt: Statement; + #deleteCachePrefixStmt: Statement; + #deleteExpiredCacheStmt: Statement; + #updateIfMatchesWithLeaseStmt: Statement; + #deleteIfMatchesWithLeaseStmt: Statement; + #getCredentialBlockStmt: Statement; + #listCredentialBlocksByCredentialStmt: Statement; + #upsertCredentialBlockStmt: Statement; + #deleteCredentialBlocksStmt: Statement; + #deleteCredentialBlockStmt: Statement; + #deleteExpiredCredentialBlocksStmt: Statement; + #acquireCredentialRefreshLeaseStmt: Statement; + #getCredentialRefreshLeaseStmt: Statement; + #renewCredentialRefreshLeaseStmt: Statement; + #releaseCredentialRefreshLeaseStmt: Statement; + #credentialBlockReconcileAfter: Map = new Map(); + #insertUsageHistoryStmt: Statement; + #insertUsageCostStmt: Statement; + #listUsageCostsStmt: Statement; + #lastUsageHistoryStmt: Statement; + #listUsageHistoryStmt: Statement; + #updateUsageHistoryStmt: Statement; + #dataVersion: number; + #authRevision: number; + #localAuthRevision: number; + #closed = false; + + constructor(db: Database) { + this.#db = db; + this.#initializeSchema(); + this.#dataVersion = this.#readDataVersion(); + this.#authRevision = this.#readAuthRevision(); + this.#localAuthRevision = this.#readLocalAuthRevision(); + + this.#listActiveStmt = this.#db.prepare( + "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE disabled_cause IS NULL ORDER BY id ASC", + ); + this.#listActiveByProviderStmt = this.#db.prepare( + "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE provider = ? AND disabled_cause IS NULL ORDER BY id ASC", + ); + this.#listDisabledStmt = this.#db.prepare( + "SELECT id, provider, credential_type, data, disabled_cause, identity_key, updated_at FROM auth_credentials WHERE disabled_cause IS NOT NULL ORDER BY id ASC", + ); + this.#listDisabledByProviderStmt = this.#db.prepare( + "SELECT id, provider, credential_type, data, disabled_cause, identity_key, updated_at FROM auth_credentials WHERE provider = ? AND disabled_cause IS NOT NULL ORDER BY id ASC", + ); + this.#insertStmt = this.#db.prepare( + `INSERT INTO auth_credentials (provider, credential_type, data, identity_key, created_at, updated_at) VALUES (?, ?, ?, ?, ${SQLITE_NOW_EPOCH}, ${SQLITE_NOW_EPOCH}) RETURNING id`, + ); + this.#updateStmt = this.#db.prepare( + `UPDATE auth_credentials SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`, + ); + this.#updateIfMatchesStmt = this.#db.prepare( + `UPDATE auth_credentials SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ? AND data = ? AND disabled_cause IS NULL`, + ); + this.#updateIfMatchesWithLeaseStmt = this.#db.prepare( + `UPDATE auth_credentials + SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH} + WHERE id = ? AND data = ? AND disabled_cause IS NULL + AND EXISTS ( + SELECT 1 FROM auth_credential_refresh_leases + WHERE credential_id = ? AND owner = ? AND expires_at_ms > ? + )`, + ); + this.#deleteStmt = this.#db.prepare( + `UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`, + ); + this.#deleteIfMatchesStmt = this.#db.prepare( + `UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ? AND data = ? AND disabled_cause IS NULL`, + ); + this.#deleteIfMatchesWithLeaseStmt = this.#db.prepare( + `UPDATE auth_credentials + SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} + WHERE id = ? AND data = ? AND disabled_cause IS NULL + AND EXISTS ( + SELECT 1 FROM auth_credential_refresh_leases + WHERE credential_id = ? AND owner = ? AND expires_at_ms > ? + )`, + ); + this.#deleteByProviderStmt = this.#db.prepare( + `UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE provider = ? AND disabled_cause IS NULL`, + ); + this.#hardDeleteStmt = this.#db.prepare("DELETE FROM auth_credentials WHERE id = ?"); + this.#getCacheStmt = this.#db.prepare( + `SELECT value FROM cache WHERE key = ? AND expires_at > ${SQLITE_NOW_EPOCH}`, + ); + this.#getCacheIncludingExpiredStmt = this.#db.prepare("SELECT value FROM cache WHERE key = ?"); + this.#upsertCacheStmt = this.#db.prepare( + "INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at", + ); + this.#deleteCachePrefixStmt = this.#db.prepare("DELETE FROM cache WHERE substr(key, 1, ?) = ?"); + this.#deleteExpiredCacheStmt = this.#db.prepare(`DELETE FROM cache WHERE expires_at <= ${SQLITE_NOW_EPOCH}`); + this.#getCredentialBlockStmt = this.#db.prepare( + "SELECT blocked_until_ms, updated_at FROM auth_credential_blocks WHERE credential_id = ? AND provider_key = ? AND block_scope = ? AND blocked_until_ms > ?", + ); + this.#listCredentialBlocksByCredentialStmt = this.#db.prepare( + `SELECT credential_id, provider_key, block_scope, blocked_until_ms, updated_at + FROM auth_credential_blocks + WHERE credential_id = ? AND blocked_until_ms > ? + AND NOT (provider_key = ? AND block_scope = ?) + ORDER BY provider_key ASC, block_scope ASC`, + ); + this.#upsertCredentialBlockStmt = this.#db.prepare( + `INSERT INTO auth_credential_blocks (credential_id, provider_key, block_scope, blocked_until_ms, updated_at) + VALUES (?, ?, ?, ?, ${SQLITE_NOW_EPOCH}) + ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET + blocked_until_ms = MAX(blocked_until_ms, excluded.blocked_until_ms), + updated_at = excluded.updated_at`, + ); + this.#deleteCredentialBlocksStmt = this.#db.prepare("DELETE FROM auth_credential_blocks WHERE credential_id = ?"); + this.#deleteCredentialBlockStmt = this.#db.prepare( + "DELETE FROM auth_credential_blocks WHERE credential_id = ? AND provider_key = ? AND block_scope = ?", + ); + this.#deleteExpiredCredentialBlocksStmt = this.#db.prepare( + "DELETE FROM auth_credential_blocks WHERE blocked_until_ms <= ?", + ); + this.#acquireCredentialRefreshLeaseStmt = this.#db.prepare( + `INSERT INTO auth_credential_refresh_leases (credential_id, owner, expires_at_ms, updated_at) + VALUES (?, ?, ?, ${SQLITE_NOW_EPOCH}) + ON CONFLICT(credential_id) DO UPDATE SET + owner = excluded.owner, + expires_at_ms = excluded.expires_at_ms, + updated_at = excluded.updated_at + WHERE auth_credential_refresh_leases.expires_at_ms <= ?`, + ); + this.#getCredentialRefreshLeaseStmt = this.#db.prepare( + "SELECT expires_at_ms FROM auth_credential_refresh_leases WHERE credential_id = ?", + ); + this.#renewCredentialRefreshLeaseStmt = this.#db.prepare( + `UPDATE auth_credential_refresh_leases SET expires_at_ms = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE credential_id = ? AND owner = ?`, + ); + this.#releaseCredentialRefreshLeaseStmt = this.#db.prepare( + "DELETE FROM auth_credential_refresh_leases WHERE credential_id = ? AND owner = ?", + ); + this.#insertUsageHistoryStmt = this.#db.prepare( + "INSERT INTO usage_history (recorded_at, provider, account_key, email, account_id, limit_id, label, window_label, used_fraction, status, resets_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ); + this.#lastUsageHistoryStmt = this.#db.prepare( + "SELECT id, recorded_at FROM usage_history WHERE provider = ? AND account_key = ? AND limit_id = ? ORDER BY recorded_at DESC LIMIT 1", + ); + this.#updateUsageHistoryStmt = this.#db.prepare( + "UPDATE usage_history SET recorded_at = ?, email = ?, account_id = ?, label = ?, window_label = ?, used_fraction = ?, status = ?, resets_at = ? WHERE id = ?", + ); + this.#listUsageHistoryStmt = this.#db.prepare( + "SELECT recorded_at, provider, account_key, email, account_id, limit_id, label, window_label, used_fraction, status, resets_at FROM usage_history WHERE recorded_at >= ? AND (? IS NULL OR provider = ?) ORDER BY recorded_at ASC", + ); + this.#insertUsageCostStmt = this.#db.prepare( + "INSERT INTO usage_cost_history (recorded_at, provider, account_key, cost_usd) VALUES (?, ?, ?, ?)", + ); + this.#listUsageCostsStmt = this.#db.prepare( + "SELECT recorded_at, provider, account_key, cost_usd FROM usage_cost_history WHERE recorded_at >= ? AND (? IS NULL OR provider = ?) AND (? IS NULL OR account_key = ?) ORDER BY recorded_at ASC", + ); + } + + static async open(dbPath: string = getAgentDbPath()): Promise { + const dir = path.dirname(dbPath); + const dirExists = await fs + .stat(dir) + .then(s => s.isDirectory()) + .catch(() => false); + if (!dirExists) { + await fs.mkdir(dir, { recursive: true, mode: 0o700 }); + } + + // Concurrent omp startups can race against WAL recovery and the schema + // init's first lock-taking statement. Bun's default `busy_timeout` is 0, + // so retry the open on `SQLITE_BUSY` / `SQLITE_BUSY_RECOVERY` with bounded + // exponential backoff before surfacing the failure. See issue #2421. + const maxAttempts = 4; + const baseDelayMs = 100; + let lastBusyError: Error | undefined; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + let db: Database | undefined; + try { + db = new Database(dbPath); + // Install the busy handler BEFORE the first lock-taking statement + // on this connection. The leases DDL below and the constructor's + // schema init both acquire locks during WAL recovery; without a + // non-zero `busy_timeout` they fail immediately with SQLITE_BUSY. + // See issue #2421. + SqliteAuthCredentialStore.#installBusyTimeout(db); + try { + await fs.chmod(dbPath, 0o600); + } catch { + // Ignore chmod failures (e.g., Windows) + } + SqliteAuthCredentialStore.#ensureAuthCredentialRefreshLeasesTable(db); + return new SqliteAuthCredentialStore(db); + } catch (err) { + db?.close(); + if (!isSqliteBusyError(err)) { + throw err; + } + lastBusyError = err instanceof Error ? err : new Error(String(err)); + if (attempt < maxAttempts - 1) { + await Bun.sleep(baseDelayMs * 2 ** attempt); + } + } + } + throw new AIError.ConfigurationError( + `Failed to open auth database at '${dbPath}' after ${maxAttempts} attempts: ${lastBusyError?.message}`, + { cause: lastBusyError }, + ); + } + + static #ensureAuthCredentialRefreshLeasesTable(db: Database): void { + db.run(` + CREATE TABLE IF NOT EXISTS auth_credential_refresh_leases ( + credential_id INTEGER PRIMARY KEY, + owner TEXT NOT NULL, + expires_at_ms INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_auth_credential_refresh_leases_expires ON auth_credential_refresh_leases(expires_at_ms); + `); + } + + /** + * Install the per-connection busy handler so lock-taking statements wait for + * a contended writer instead of failing immediately (Bun defaults + * `busy_timeout` to 0). MUST run before the first lock-taking statement on + * the connection: concurrent omp startups race WAL recovery and the leases + * DDL. Uses the centralized timeout so headless hosts keep their bounded + * busy wait instead of the interactive 5s value. See issues #2421, #7298. + */ + static #installBusyTimeout(db: Database): void { + db.run(`PRAGMA busy_timeout = ${getDbBusyTimeoutMs()}`); + } + + #initializeSchema(): void { + // Install the busy handler BEFORE any lock-taking statement (incl. + // `PRAGMA journal_mode=WAL`, which acquires an exclusive lock during WAL + // recovery). Without this, concurrent omp startups can crash here with + // `SQLITE_BUSY` / `SQLITE_BUSY_RECOVERY`. Re-setting when opened via + // `open()` (which already installed it) is idempotent. See issue #2421. + SqliteAuthCredentialStore.#installBusyTimeout(this.#db); + this.#db.run(` + PRAGMA journal_mode=WAL; + PRAGMA synchronous=NORMAL; + CREATE TABLE IF NOT EXISTS auth_schema_version ( + id INTEGER PRIMARY KEY CHECK (id = 1), + version INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS cache ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + expires_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache(expires_at); + CREATE TABLE IF NOT EXISTS usage_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recorded_at INTEGER NOT NULL, + provider TEXT NOT NULL, + account_key TEXT NOT NULL, + email TEXT, + account_id TEXT, + limit_id TEXT NOT NULL, + label TEXT NOT NULL, + window_label TEXT, + used_fraction REAL, + status TEXT, + resets_at INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_usage_history_series ON usage_history(provider, account_key, limit_id, recorded_at); + CREATE TABLE IF NOT EXISTS usage_cost_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recorded_at INTEGER NOT NULL, + provider TEXT NOT NULL, + account_key TEXT NOT NULL, + cost_usd REAL NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_usage_cost_history_lookup ON usage_cost_history(provider, account_key, recorded_at); + CREATE INDEX IF NOT EXISTS idx_usage_history_recorded ON usage_history(recorded_at); + CREATE TABLE IF NOT EXISTS clients ( + install_id TEXT PRIMARY KEY, + hostname TEXT, + first_seen INTEGER NOT NULL, + last_seen INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS client_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recorded_at INTEGER NOT NULL, + install_id TEXT NOT NULL, + provider TEXT NOT NULL, + model TEXT NOT NULL, + requests INTEGER NOT NULL, + input_tokens INTEGER NOT NULL, + output_tokens INTEGER NOT NULL, + cache_read_tokens INTEGER NOT NULL, + cache_write_tokens INTEGER NOT NULL, + cost_usd REAL NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_client_usage_series ON client_usage(install_id, provider, model, recorded_at); + CREATE INDEX IF NOT EXISTS idx_client_usage_recorded ON client_usage(recorded_at); + `); + + if (!this.#authCredentialsTableExists()) { + this.#createAuthCredentialsTable(); + this.#createAuthCredentialBlocksTable(); + this.#createAuthCredentialRefreshLeasesTable(); + this.#createAuthCredentialBlockCompatibilityObjects(); + this.#createAuthChangeTrackingObjects(); + this.#writeAuthSchemaVersion(AUTH_SCHEMA_VERSION); + return; + } + + const recordedVersion = this.#readAuthSchemaVersion(); + const schemaVersion = recordedVersion ?? this.#inferAuthSchemaVersion(); + if (schemaVersion > AUTH_SCHEMA_VERSION) { + logger.warn("SqliteAuthCredentialStore schema version mismatch", { + current: schemaVersion, + expected: AUTH_SCHEMA_VERSION, + }); + } else if (schemaVersion < AUTH_SCHEMA_VERSION) { + this.#migrateAuthSchema(schemaVersion); + } + + this.#createAuthCredentialIndexes(); + this.#createAuthCredentialBlocksTable(); + this.#createAuthCredentialRefreshLeasesTable(); + if (schemaVersion <= AUTH_SCHEMA_VERSION) { + this.#createAuthCredentialBlockCompatibilityObjects(); + } + this.#createAuthChangeTrackingObjects(); + this.#backfillCredentialIdentityKeys(); + // Rewriting an already-current version row is a no-op write transaction + // on every boot; only persist when the recorded version actually changes. + if (recordedVersion !== AUTH_SCHEMA_VERSION && schemaVersion <= AUTH_SCHEMA_VERSION) { + this.#writeAuthSchemaVersion(AUTH_SCHEMA_VERSION); + } + } + + #authCredentialsTableExists(): boolean { + const stmt = this.#db.prepare( + "SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'auth_credentials'", + ); + try { + const row = stmt.get() as { present?: number } | undefined; + return row?.present === 1; + } finally { + stmt.finalize(); + } + } + + #readAuthSchemaVersion(): number | null { + const stmt = this.#db.prepare("SELECT version FROM auth_schema_version WHERE id = 1"); + try { + const row = stmt.get() as { version?: number } | undefined; + return typeof row?.version === "number" ? row.version : null; + } finally { + stmt.finalize(); + } + } + + #writeAuthSchemaVersion(version: number): void { + const stmt = this.#db.prepare("INSERT OR REPLACE INTO auth_schema_version(id, version) VALUES (1, ?)"); + try { + stmt.run(version); + } finally { + stmt.finalize(); + } + } + + #inferAuthSchemaVersion(): number { + const stmt = this.#db.prepare("PRAGMA table_info(auth_credentials)"); + try { + const cols = stmt.all() as Array<{ name?: string }>; + return this.#inferAuthSchemaVersionFromColumns(cols); + } finally { + stmt.finalize(); + } + } + + #inferAuthSchemaVersionFromColumns(cols: Array<{ name?: string }>): number { + const hasDisabledCause = cols.some(column => column.name === "disabled_cause"); + const hasIdentityKey = cols.some(column => column.name === "identity_key"); + const hasAccountId = cols.some(column => column.name === "account_id"); + const hasEmail = cols.some(column => column.name === "email"); + if (hasIdentityKey) return 3; + if (hasAccountId || hasEmail) return 2; + if (hasDisabledCause) return 1; + return 0; + } + + #createAuthCredentialsTable(): void { + this.#db.run(` + CREATE TABLE IF NOT EXISTS auth_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL, + credential_type TEXT NOT NULL, + data TEXT NOT NULL, + disabled_cause TEXT DEFAULT NULL, + identity_key TEXT DEFAULT NULL, + created_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}), + updated_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}) + ); + `); + this.#createAuthCredentialIndexes(); + } + + #createAuthCredentialIndexes(): void { + this.#db.run(` + CREATE INDEX IF NOT EXISTS idx_auth_provider ON auth_credentials(provider); + CREATE INDEX IF NOT EXISTS idx_auth_provider_identity ON auth_credentials(provider, identity_key) WHERE identity_key IS NOT NULL; + `); + } + + #createAuthCredentialBlocksTable(): void { + this.#db.run(` + CREATE TABLE IF NOT EXISTS auth_credential_blocks ( + credential_id INTEGER NOT NULL, + provider_key TEXT NOT NULL, + block_scope TEXT NOT NULL DEFAULT '', + blocked_until_ms INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (credential_id, provider_key, block_scope) + ); + CREATE INDEX IF NOT EXISTS idx_auth_credential_blocks_expires ON auth_credential_blocks(blocked_until_ms); + `); + } + + #createAuthChangeTrackingObjects(): void { + this.#db.run(` + CREATE TABLE IF NOT EXISTS auth_change_revision ( + id INTEGER PRIMARY KEY CHECK (id = 1), + revision INTEGER NOT NULL + ); + INSERT OR IGNORE INTO auth_change_revision (id, revision) VALUES (1, 0); + CREATE TEMP TABLE IF NOT EXISTS auth_local_change_revision ( + id INTEGER PRIMARY KEY CHECK (id = 1), + revision INTEGER NOT NULL + ); + INSERT OR IGNORE INTO auth_local_change_revision (id, revision) VALUES (1, 0); + `); + for (const table of ["auth_credentials", "auth_credential_blocks"] as const) { + for (const event of ["INSERT", "UPDATE", "DELETE"] as const) { + this.#db.run(` + CREATE TRIGGER IF NOT EXISTS auth_change_revision_${table}_${event.toLowerCase()} + AFTER ${event} ON ${table} + BEGIN + UPDATE auth_change_revision SET revision = revision + 1 WHERE id = 1; + END; + `); + this.#db.run(` + CREATE TEMP TRIGGER IF NOT EXISTS auth_local_change_revision_${table}_${event.toLowerCase()} + AFTER ${event} ON main.${table} + BEGIN + UPDATE auth_local_change_revision SET revision = revision + 1 WHERE id = 1; + END; + `); + } + } + } + + #createAuthCredentialBlockMirrorGuardTable(): void { + this.#db.run(` + CREATE TABLE IF NOT EXISTS auth_credential_block_mirror_guard ( + credential_id INTEGER PRIMARY KEY + ) WITHOUT ROWID; + `); + } + + /** + * Keep a physical Codex `shared` row for pre-meter binaries that read this + * database directly. Meter rows are canonical for current code. The guard + * suppresses feedback while triggers update the compatibility projection. + */ + #createAuthCredentialBlockCompatibilityTriggers(): void { + for (const event of ["INSERT", "UPDATE"] as const) { + const eventName = event.toLowerCase(); + this.#db.run(` + CREATE TRIGGER IF NOT EXISTS auth_codex_shared_${eventName}_to_meters + AFTER ${event} ON auth_credential_blocks + WHEN NEW.provider_key = 'openai-codex:oauth' + AND NEW.block_scope = 'shared' + AND NOT EXISTS ( + SELECT 1 FROM auth_credential_block_mirror_guard + WHERE credential_id = NEW.credential_id + ) + BEGIN + INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id) + VALUES (NEW.credential_id); + INSERT INTO auth_credential_blocks ( + credential_id, + provider_key, + block_scope, + blocked_until_ms, + updated_at + ) + VALUES ( + NEW.credential_id, + NEW.provider_key, + 'chat', + NEW.blocked_until_ms, + NEW.updated_at + ) + ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET + blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms), + updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at); + INSERT INTO auth_credential_blocks ( + credential_id, + provider_key, + block_scope, + blocked_until_ms, + updated_at + ) + VALUES ( + NEW.credential_id, + NEW.provider_key, + 'spark', + NEW.blocked_until_ms, + NEW.updated_at + ) + ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET + blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms), + updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at); + DELETE FROM auth_credential_block_mirror_guard + WHERE credential_id = NEW.credential_id; + END; + + CREATE TRIGGER IF NOT EXISTS auth_codex_meter_${eventName}_to_shared + AFTER ${event} ON auth_credential_blocks + WHEN NEW.provider_key = 'openai-codex:oauth' + AND NEW.block_scope IN ('chat', 'spark') + AND NOT EXISTS ( + SELECT 1 FROM auth_credential_block_mirror_guard + WHERE credential_id = NEW.credential_id + ) + BEGIN + INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id) + VALUES (NEW.credential_id); + DELETE FROM auth_credential_blocks + WHERE credential_id = NEW.credential_id + AND provider_key = NEW.provider_key + AND block_scope = 'shared'; + INSERT INTO auth_credential_blocks ( + credential_id, + provider_key, + block_scope, + blocked_until_ms, + updated_at + ) + SELECT + NEW.credential_id, + NEW.provider_key, + 'shared', + MAX(blocked_until_ms), + MAX(updated_at) + FROM auth_credential_blocks + WHERE credential_id = NEW.credential_id + AND provider_key = NEW.provider_key + AND block_scope IN ('chat', 'spark') + GROUP BY credential_id, provider_key; + DELETE FROM auth_credential_block_mirror_guard + WHERE credential_id = NEW.credential_id; + END; + `); + } + + this.#db.run(` + CREATE TRIGGER IF NOT EXISTS auth_codex_shared_delete_to_meters + AFTER DELETE ON auth_credential_blocks + WHEN OLD.provider_key = 'openai-codex:oauth' + AND OLD.block_scope = 'shared' + AND NOT EXISTS ( + SELECT 1 FROM auth_credential_block_mirror_guard + WHERE credential_id = OLD.credential_id + ) + BEGIN + INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id) + VALUES (OLD.credential_id); + DELETE FROM auth_credential_blocks + WHERE credential_id = OLD.credential_id + AND provider_key = OLD.provider_key + AND block_scope IN ('chat', 'spark'); + DELETE FROM auth_credential_block_mirror_guard + WHERE credential_id = OLD.credential_id; + END; + + CREATE TRIGGER IF NOT EXISTS auth_codex_meter_delete_to_shared + AFTER DELETE ON auth_credential_blocks + WHEN OLD.provider_key = 'openai-codex:oauth' + AND OLD.block_scope IN ('chat', 'spark') + AND NOT EXISTS ( + SELECT 1 FROM auth_credential_block_mirror_guard + WHERE credential_id = OLD.credential_id + ) + BEGIN + INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id) + VALUES (OLD.credential_id); + DELETE FROM auth_credential_blocks + WHERE credential_id = OLD.credential_id + AND provider_key = OLD.provider_key + AND block_scope = 'shared'; + INSERT INTO auth_credential_blocks ( + credential_id, + provider_key, + block_scope, + blocked_until_ms, + updated_at + ) + SELECT + OLD.credential_id, + OLD.provider_key, + 'shared', + MAX(blocked_until_ms), + MAX(updated_at) + FROM auth_credential_blocks + WHERE credential_id = OLD.credential_id + AND provider_key = OLD.provider_key + AND block_scope IN ('chat', 'spark') + GROUP BY credential_id, provider_key; + DELETE FROM auth_credential_block_mirror_guard + WHERE credential_id = OLD.credential_id; + END; + `); + } + + #createAuthCredentialBlockCompatibilityObjects(): void { + this.#createAuthCredentialBlockMirrorGuardTable(); + this.#createAuthCredentialBlockCompatibilityTriggers(); + } + + #createAuthCredentialRefreshLeasesTable(): void { + SqliteAuthCredentialStore.#ensureAuthCredentialRefreshLeasesTable(this.#db); + } + + #migrateAuthSchema(fromVersion: number): void { + if (fromVersion < 1) { + this.#migrateAuthSchemaV0ToV1(); + } + if (fromVersion < 3) { + this.#migrateAuthSchemaV1OrV2ToV3(); + } + if (fromVersion < 4) { + this.#migrateAuthSchemaV3ToV4(); + } + if (fromVersion < 5) { + this.#migrateAuthSchemaV4ToV5(); + } + if (fromVersion < 6) { + this.#migrateAuthSchemaV5ToV6(); + } + if (fromVersion < 7) { + this.#migrateAuthSchemaV6ToV7(); + } + } + + #migrateAuthSchemaV0ToV1(): void { + const migrate = this.#db.transaction(() => { + const stmt = this.#db.prepare("PRAGMA table_info(auth_credentials)"); + let hasDisabled = false; + try { + const v0Cols = stmt.all() as Array<{ name?: string }>; + hasDisabled = v0Cols.some(col => col.name === "disabled"); + } finally { + stmt.finalize(); + } + + this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_v0"); + this.#db.run(` + CREATE TABLE auth_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL, + credential_type TEXT NOT NULL, + data TEXT NOT NULL, + disabled_cause TEXT DEFAULT NULL, + created_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}), + updated_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}) + ); + `); + this.#db.run(` + INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, created_at, updated_at) + SELECT + id, + provider, + credential_type, + data, + ${hasDisabled ? "CASE WHEN disabled = 1 THEN 'disabled' ELSE NULL END" : "NULL"}, + created_at, + updated_at + FROM auth_credentials_v0 + `); + this.#db.run("DROP TABLE auth_credentials_v0"); + }); + migrate(); + } + + #migrateAuthSchemaV1OrV2ToV3(): void { + const migrate = this.#db.transaction(() => { + this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_legacy"); + this.#createAuthCredentialsTable(); + this.#db.run(` + INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, identity_key, created_at, updated_at) + SELECT + id, + provider, + credential_type, + data, + disabled_cause, + NULL, + created_at, + updated_at + FROM auth_credentials_legacy + `); + this.#db.run("DROP TABLE auth_credentials_legacy"); + }); + migrate(); + } + + #migrateAuthSchemaV3ToV4(): void { + const migrate = this.#db.transaction(() => { + this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_v3"); + this.#createAuthCredentialsTable(); + this.#db.run(` + INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, identity_key, created_at, updated_at) + SELECT + id, + provider, + credential_type, + data, + disabled_cause, + identity_key, + created_at, + updated_at + FROM auth_credentials_v3 + `); + this.#db.run("DROP TABLE auth_credentials_v3"); + }); + migrate(); + } + + #migrateAuthSchemaV4ToV5(): void { + const migrate = this.#db.transaction(() => { + this.#createAuthCredentialBlocksTable(); + }); + migrate(); + } + + #migrateAuthSchemaV5ToV6(): void { + const migrate = this.#db.transaction(() => { + this.#createAuthCredentialRefreshLeasesTable(); + }); + migrate(); + } + + #migrateAuthSchemaV6ToV7(): void { + const migrate = this.#db.transaction(() => { + this.#createAuthCredentialBlocksTable(); + this.#createAuthCredentialBlockMirrorGuardTable(); + this.#db.run(` + DELETE FROM auth_credential_block_mirror_guard; + INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id) + SELECT DISTINCT credential_id + FROM auth_credential_blocks + WHERE provider_key = 'openai-codex:oauth' + AND block_scope IN ('chat', 'spark', 'shared'); + + INSERT INTO auth_credential_blocks ( + credential_id, + provider_key, + block_scope, + blocked_until_ms, + updated_at + ) + SELECT credential_id, provider_key, 'chat', blocked_until_ms, updated_at + FROM auth_credential_blocks + WHERE provider_key = 'openai-codex:oauth' + AND block_scope = 'shared' + ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET + blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms), + updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at); + + INSERT INTO auth_credential_blocks ( + credential_id, + provider_key, + block_scope, + blocked_until_ms, + updated_at + ) + SELECT credential_id, provider_key, 'spark', blocked_until_ms, updated_at + FROM auth_credential_blocks + WHERE provider_key = 'openai-codex:oauth' + AND block_scope = 'shared' + ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET + blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms), + updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at); + + INSERT INTO auth_credential_blocks ( + credential_id, + provider_key, + block_scope, + blocked_until_ms, + updated_at + ) + SELECT + credential_id, + provider_key, + 'shared', + MAX(blocked_until_ms), + MAX(updated_at) + FROM auth_credential_blocks + WHERE provider_key = 'openai-codex:oauth' + AND block_scope IN ('chat', 'spark') + GROUP BY credential_id, provider_key + ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET + blocked_until_ms = excluded.blocked_until_ms, + updated_at = excluded.updated_at; + + DELETE FROM auth_credential_block_mirror_guard; + `); + this.#createAuthCredentialBlockCompatibilityTriggers(); + this.#writeAuthSchemaVersion(7); + }); + migrate.immediate(); + } + + #backfillCredentialIdentityKeys(): void { + const selectRowsStmt = this.#db.prepare( + "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE identity_key IS NULL ORDER BY id ASC", + ); + let rows: AuthRow[]; + try { + rows = selectRowsStmt.all() as AuthRow[]; + } finally { + selectRowsStmt.finalize(); + } + if (rows.length === 0) return; + + let updateIdentity: Statement | null = null; + try { + for (const row of rows) { + const identityKey = resolveRowCredentialIdentityKey(row.provider, row); + // Rows whose identity cannot be derived stay NULL; writing NULL over + // NULL would just burn a write transaction on every boot. + if (identityKey === null) continue; + updateIdentity ??= this.#db.prepare("UPDATE auth_credentials SET identity_key = ? WHERE id = ?"); + updateIdentity.run(identityKey, row.id); + } + } finally { + updateIdentity?.finalize(); + } + } + + // ─── AuthCredentialStore interface ────────────────────────────────────── + + listAuthCredentials(provider?: string): StoredAuthCredential[] { + const rows = + (provider + ? (this.#listActiveByProviderStmt.all(provider) as AuthRow[]) + : (this.#listActiveStmt.all() as AuthRow[])) ?? []; + + const results: StoredAuthCredential[] = []; + for (const row of rows) { + const credential = deserializeCredential(row); + if (!credential) continue; + results.push(toStoredAuthCredential(row, credential)); + } + return results; + } + + async listDisabledCredentials(provider?: string): Promise { + const rows = + (provider + ? (this.#listDisabledByProviderStmt.all(provider) as DisabledAuthRow[]) + : (this.#listDisabledStmt.all() as DisabledAuthRow[])) ?? []; + const results: DisabledCredentialSummary[] = []; + for (const row of rows) { + const credential = deserializeCredential(row); + const summary: DisabledCredentialSummary = { + id: row.id, + provider: row.provider, + type: row.credential_type === "api_key" ? "api_key" : "oauth", + cause: row.disabled_cause ?? "disabled", + }; + if (credential?.type === "oauth") { + if (credential.email) summary.email = credential.email; + if (credential.accountId) summary.accountId = credential.accountId; + if (credential.orgId) summary.orgId = credential.orgId; + if (credential.orgName) summary.orgName = credential.orgName; + } + if (typeof row.updated_at === "number" && Number.isFinite(row.updated_at)) { + summary.disabledAtMs = row.updated_at * 1000; + } + results.push(summary); + } + return results; + } + + replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[] { + const replace = this.#db.transaction((providerName: string, items: AuthCredential[]) => { + const existingRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[]; + const existing = existingRows.map(row => ({ + id: row.id, + credential: deserializeCredential(row), + identityKey: resolveRowCredentialIdentityKey(providerName, row), + })); + + const result: StoredAuthCredential[] = []; + const matchedExistingIds = new Set(); + + for (const credential of items) { + const serialized = serializeCredential(providerName, credential); + if (!serialized) continue; + const match = existing.find( + entry => + !matchedExistingIds.has(entry.id) && + matchesReplacementCredential(providerName, entry.credential, entry.identityKey, credential), + ); + if (match) { + matchedExistingIds.add(match.id); + this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, match.id); + result.push({ id: match.id, provider: providerName, credential, disabledCause: null }); + } else { + const row = this.#insertStmt.get( + providerName, + serialized.credentialType, + serialized.data, + serialized.identityKey, + ) as { id?: number } | undefined; + if (row?.id) { + result.push({ id: row.id, provider: providerName, credential, disabledCause: null }); + } + } + } + + for (const row of existing) { + if (!matchedExistingIds.has(row.id)) { + this.#deleteStmt.run("replaced by newer credential", row.id); + } + } + + return result; + }); + + const result = replace(provider, credentials); + this.#purgeSupersededDisabledRows(provider, result); + return result; + } + + upsertAuthCredentialForProvider(provider: string, credential: AuthCredential): StoredAuthCredential[] { + const upsert = this.#db.transaction((providerName: string, item: AuthCredential) => { + const serialized = serializeCredential(providerName, item); + if (!serialized) return this.listAuthCredentials(providerName); + const existingRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[]; + const existing = existingRows.map(row => ({ + id: row.id, + credential: deserializeCredential(row), + identityKey: resolveRowCredentialIdentityKey(providerName, row), + })); + + if (item.type === "oauth") { + for (const row of existing) { + if (row.credential && row.credential.type === "api_key") { + this.#deleteStmt.run("replaced by oauth login", row.id); + } + } + } + + let targetId: number | null = null; + for (const row of existing) { + if (!matchesReplacementCredential(providerName, row.credential, row.identityKey, item)) continue; + if (targetId === null) { + targetId = row.id; + this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, row.id); + continue; + } + this.#deleteStmt.run("replaced by newer credential", row.id); + } + + if (targetId === null) { + const row = this.#insertStmt.get( + providerName, + serialized.credentialType, + serialized.data, + serialized.identityKey, + ) as { id?: number } | undefined; + targetId = row?.id ?? null; + } + + const activeRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[]; + const result: StoredAuthCredential[] = []; + for (const row of activeRows) { + const activeCredential = deserializeCredential(row); + if (!activeCredential) continue; + result.push(toStoredAuthCredential(row, activeCredential)); + } + return result; + }); + + const result = upsert(provider, credential); + this.#purgeSupersededDisabledRows(provider, result); + return result; + } + + /** + * Hard-deletes disabled rows for a provider when an active replacement exists. + * OAuth credentials match by identity key; API keys match by provider and type. + * Disabled rows without an active same-type replacement remain recoverable. + */ + #purgeSupersededDisabledRows(provider: string, activeRows: StoredAuthCredential[]): void { + try { + let hasActiveApiKey = false; + const activeIdentityKeys = new Set(); + for (const row of activeRows) { + if (row.credential.type === "api_key") { + hasActiveApiKey = true; + continue; + } + const identityKey = resolveCredentialIdentityKey(provider, row.credential); + if (identityKey) activeIdentityKeys.add(identityKey); + } + if (!hasActiveApiKey && activeIdentityKeys.size === 0) return; + + const disabledRows = this.#listDisabledByProviderStmt.all(provider) as AuthRow[]; + for (const row of disabledRows) { + if (hasActiveApiKey && row.credential_type === "api_key") { + this.#hardDeleteStmt.run(row.id); + continue; + } + const identityKey = resolveRowCredentialIdentityKey(provider, row); + if (identityKey && activeIdentityKeys.has(identityKey)) { + this.#hardDeleteStmt.run(row.id); + } + } + } catch { + // Best-effort cleanup; don't let it break the main operation + } + } + + updateAuthCredential(id: number, credential: AuthCredential): void { + try { + const providerStmt = this.#db.prepare("SELECT provider FROM auth_credentials WHERE id = ?"); + let providerRow: { provider?: string } | undefined; + try { + providerRow = providerStmt.get(id) as { provider?: string } | undefined; + } finally { + providerStmt.finalize(); + } + const provider = providerRow?.provider ?? ""; + const serialized = serializeCredential(provider, credential); + if (!serialized) return; + this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, id); + if (provider) { + this.#purgeSupersededDisabledRows(provider, this.listAuthCredentials(provider)); + } + } catch { + // Ignore update failures + } + } + + tryUpdateAuthCredentialIfMatches( + id: number, + expectedData: string, + credential: AuthCredential, + lease?: CredentialRefreshLeaseFence, + ): boolean { + const providerStmt = this.#db.prepare("SELECT provider FROM auth_credentials WHERE id = ?"); + let providerRow: { provider?: string } | undefined; + try { + providerRow = providerStmt.get(id) as { provider?: string } | undefined; + } finally { + providerStmt.finalize(); + } + const provider = providerRow?.provider ?? ""; + const serialized = serializeCredential(provider, credential); + if (!serialized) return false; + const result = lease + ? (this.#updateIfMatchesWithLeaseStmt.run( + serialized.credentialType, + serialized.data, + serialized.identityKey, + id, + expectedData, + id, + lease.owner, + lease.nowMs, + ) as { changes: number }) + : (this.#updateIfMatchesStmt.run( + serialized.credentialType, + serialized.data, + serialized.identityKey, + id, + expectedData, + ) as { changes: number }); + if (result.changes === 0) return false; + if (provider) { + this.#purgeSupersededDisabledRows(provider, this.listAuthCredentials(provider)); + } + return true; + } + + deleteAuthCredential(id: number, disabledCause: string): void { + try { + this.#deleteStmt.run(normalizeDisabledCause(disabledCause), id); + } catch { + // Ignore delete failures + } + } + + /** + * CAS-style disable: only soft-deletes the row when its `data` column still + * matches `expectedData` and the row has not already been disabled. Used by + * the OAuth refresh-failure path to avoid clobbering a peer that rotated the + * row between our pre-check and the disable. + */ + tryDisableAuthCredentialIfMatches( + id: number, + expectedData: string, + disabledCause: string, + lease?: CredentialRefreshLeaseFence, + ): boolean { + const result = lease + ? (this.#deleteIfMatchesWithLeaseStmt.run( + normalizeDisabledCause(disabledCause), + id, + expectedData, + id, + lease.owner, + lease.nowMs, + ) as { changes: number }) + : (this.#deleteIfMatchesStmt.run(normalizeDisabledCause(disabledCause), id, expectedData) as { + changes: number; + }); + return result.changes > 0; + } + deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void { + try { + this.#deleteByProviderStmt.run(normalizeDisabledCause(disabledCause), provider); + } catch { + // Ignore delete failures + } + } + + getCache(key: string, options?: { includeExpired?: boolean }): string | null { + try { + const stmt = options?.includeExpired === true ? this.#getCacheIncludingExpiredStmt : this.#getCacheStmt; + const row = stmt.get(key) as { value?: string } | undefined; + return row?.value ?? null; + } catch { + return null; + } + } + + setCache(key: string, value: string, expiresAtSec: number): void { + try { + this.#upsertCacheStmt.run(key, value, expiresAtSec); + } catch { + // Ignore cache set failures + } + } + + /** Drop all cache rows whose keys start with the supplied prefix. */ + deleteCachePrefix(prefix: string): void { + try { + this.#deleteCachePrefixStmt.run(prefix.length, prefix); + } catch { + // Ignore cache delete failures + } + } + + cleanExpiredCache(): void { + try { + this.#deleteExpiredCacheStmt.run(); + } catch { + // Ignore cleanup errors + } + } + + getCredentialBlock(credentialId: number, providerKey: string, blockScope: string): number | undefined { + const nowMs = Date.now(); + const isCodexBlock = providerKey === LEGACY_CODEX_BLOCK_PROVIDER_KEY; + // Current callers use meter scopes. The physical shared row exists only + // for direct SQLite readers from pre-meter releases. + if (isCodexBlock && blockScope === LEGACY_CODEX_BLOCK_SCOPE) { + return undefined; + } + if (!isCodexBlock) this.#deleteExpiredCredentialBlocksStmt.run(nowMs); + const row = this.#getCredentialBlockStmt.get(credentialId, providerKey, blockScope, nowMs) as + | { blocked_until_ms?: number; updated_at?: number } + | undefined; + return typeof row?.blocked_until_ms === "number" ? row.blocked_until_ms : undefined; + } + + getCredentialBlockReconcileAfter(credentialId: number, providerKey: string, blockScope: string): number | undefined { + const nowMs = Date.now(); + const isCodexBlock = providerKey === LEGACY_CODEX_BLOCK_PROVIDER_KEY; + if (isCodexBlock && blockScope === LEGACY_CODEX_BLOCK_SCOPE) { + return undefined; + } + if (!isCodexBlock) this.#deleteExpiredCredentialBlocksStmt.run(nowMs); + const row = this.#getCredentialBlockStmt.get(credentialId, providerKey, blockScope, nowMs) as + | { blocked_until_ms?: number; updated_at?: number } + | undefined; + if (typeof row?.blocked_until_ms !== "number") return undefined; + const memoryReconcileAfter = + this.#credentialBlockReconcileAfter.get(`${credentialId}\0${providerKey}\0${blockScope}`) ?? 0; + const persistedReconcileAfter = + typeof row.updated_at === "number" ? row.updated_at * 1000 + USAGE_REPORT_TTL_MS : 0; + const reconcileAfter = Math.max(memoryReconcileAfter, persistedReconcileAfter); + return reconcileAfter > nowMs ? Math.min(row.blocked_until_ms, reconcileAfter) : undefined; + } + + upsertCredentialBlock(block: StoredCredentialBlock): void { + const isLegacyCodexBlock = + block.providerKey === LEGACY_CODEX_BLOCK_PROVIDER_KEY && block.blockScope === LEGACY_CODEX_BLOCK_SCOPE; + const blockScopes = isLegacyCodexBlock ? CODEX_METER_BLOCK_SCOPES : [block.blockScope]; + const upsert = this.#db.transaction(() => { + for (const blockScope of blockScopes) { + this.#upsertCredentialBlockStmt.run( + block.credentialId, + block.providerKey, + blockScope, + block.blockedUntilMs, + ); + } + }); + upsert.immediate(); + + const reconcileAfterMs = Math.min(block.blockedUntilMs, Date.now() + USAGE_REPORT_TTL_MS); + for (const blockScope of blockScopes) { + this.#credentialBlockReconcileAfter.set( + `${block.credentialId}\0${block.providerKey}\0${blockScope}`, + reconcileAfterMs, + ); + } + if (isLegacyCodexBlock) { + this.#credentialBlockReconcileAfter.delete( + `${block.credentialId}\0${block.providerKey}\0${LEGACY_CODEX_BLOCK_SCOPE}`, + ); + } + } + + deleteCredentialBlock(credentialId: number, providerKey: string, blockScope: string): void { + this.#deleteCredentialBlockStmt.run(credentialId, providerKey, blockScope); + this.#credentialBlockReconcileAfter.delete(`${credentialId}\0${providerKey}\0${blockScope}`); + } + + deleteCredentialBlocks(credentialId: number): void { + this.#deleteCredentialBlocksStmt.run(credentialId); + for (const key of this.#credentialBlockReconcileAfter.keys()) { + if (key.startsWith(`${credentialId}\0`)) this.#credentialBlockReconcileAfter.delete(key); + } + } + + cleanExpiredCredentialBlocks(nowMs: number): void { + this.#deleteExpiredCredentialBlocksStmt.run(nowMs); + for (const [key, reconcileAfterMs] of this.#credentialBlockReconcileAfter) { + if (reconcileAfterMs <= nowMs) this.#credentialBlockReconcileAfter.delete(key); + } + } + + listCredentialBlocks(credentialIds: readonly number[]): StoredCredentialBlock[] { + if (credentialIds.length === 0) return []; + const nowMs = Date.now(); + this.cleanExpiredCredentialBlocks(nowMs); + const seenCredentialIds = new Set(); + const blocks: StoredCredentialBlock[] = []; + for (const credentialId of credentialIds) { + if (seenCredentialIds.has(credentialId)) continue; + seenCredentialIds.add(credentialId); + const rows = this.#listCredentialBlocksByCredentialStmt.all( + credentialId, + nowMs, + LEGACY_CODEX_BLOCK_PROVIDER_KEY, + LEGACY_CODEX_BLOCK_SCOPE, + ) as CredentialBlockRow[]; + for (const row of rows) { + blocks.push({ + credentialId: row.credential_id, + providerKey: row.provider_key, + blockScope: row.block_scope, + blockedUntilMs: row.blocked_until_ms, + updatedAtMs: row.updated_at * 1000, + }); + } + } + return blocks; + } + + tryAcquireCredentialRefreshLease(credentialId: number, owner: string, expiresAtMs: number): boolean { + const result = this.#acquireCredentialRefreshLeaseStmt.run(credentialId, owner, expiresAtMs, Date.now()) as { + changes: number; + }; + return result.changes === 1; + } + + getCredentialRefreshLeaseExpiresAt(credentialId: number): number | undefined { + const row = this.#getCredentialRefreshLeaseStmt.get(credentialId) as { expires_at_ms?: number } | undefined; + if (typeof row?.expires_at_ms !== "number") return undefined; + if (row.expires_at_ms <= Date.now()) return undefined; + return row.expires_at_ms; + } + + renewCredentialRefreshLease(credentialId: number, owner: string, expiresAtMs: number): boolean { + const result = this.#renewCredentialRefreshLeaseStmt.run(expiresAtMs, credentialId, owner) as { + changes: number; + }; + return result.changes === 1; + } + + releaseCredentialRefreshLease(credentialId: number, owner: string): void { + try { + this.#releaseCredentialRefreshLeaseStmt.run(credentialId, owner); + } catch { + // Ignore lease release failures; expired leases are stealable. + } + } + + recordUsageSnapshots(entries: UsageHistoryEntry[]): void { + try { + for (const entry of entries) { + const bucket = Math.floor(entry.recordedAt / USAGE_HISTORY_BUCKET_MS); + const last = this.#lastUsageHistoryStmt.get(entry.provider, entry.accountKey, entry.limitId) as + | { id: number; recorded_at: number } + | undefined; + if (last && Math.floor(last.recorded_at / USAGE_HISTORY_BUCKET_MS) === bucket) { + this.#updateUsageHistoryStmt.run( + entry.recordedAt, + entry.email ?? null, + entry.accountId ?? null, + entry.label, + entry.windowLabel ?? null, + entry.usedFraction ?? null, + entry.status ?? null, + entry.resetsAt ?? null, + last.id, + ); + continue; + } + this.#insertUsageHistoryStmt.run( + entry.recordedAt, + entry.provider, + entry.accountKey, + entry.email ?? null, + entry.accountId ?? null, + entry.limitId, + entry.label, + entry.windowLabel ?? null, + entry.usedFraction ?? null, + entry.status ?? null, + entry.resetsAt ?? null, + ); + } + } catch { + // History is best-effort; never break the usage fetch path. + } + } + + listUsageHistory(query?: UsageHistoryQuery): UsageHistoryEntry[] { + try { + const provider = query?.provider ?? null; + const rows = this.#listUsageHistoryStmt.all(query?.sinceMs ?? 0, provider, provider) as Array<{ + recorded_at: number; + provider: string; + account_key: string; + email: string | null; + account_id: string | null; + limit_id: string; + label: string; + window_label: string | null; + used_fraction: number | null; + status: string | null; + resets_at: number | null; + }>; + return rows.map(row => ({ + recordedAt: row.recorded_at, + provider: row.provider as Provider, + accountKey: row.account_key, + email: row.email ?? undefined, + accountId: row.account_id ?? undefined, + limitId: row.limit_id, + label: row.label, + windowLabel: row.window_label ?? undefined, + usedFraction: row.used_fraction ?? undefined, + status: (row.status ?? undefined) as UsageHistoryEntry["status"], + resetsAt: row.resets_at ?? undefined, + })); + } catch { + return []; + } + } + recordUsageCosts(entries: UsageCostHistoryEntry[]): void { + try { + for (const entry of entries) { + this.#insertUsageCostStmt.run(entry.recordedAt, entry.provider, entry.accountKey, entry.costUsd); + } + } catch { + // Cost history is best-effort; never break request persistence. + } + } + + listUsageCosts(query?: UsageCostHistoryQuery): UsageCostHistoryEntry[] { + try { + const provider = query?.provider ?? null; + const accountKey = query?.accountKey ?? null; + const rows = this.#listUsageCostsStmt.all( + query?.sinceMs ?? 0, + provider, + provider, + accountKey, + accountKey, + ) as Array<{ + recorded_at: number; + provider: string; + account_key: string; + cost_usd: number; + }>; + return rows.map(row => ({ + recordedAt: row.recorded_at, + provider: row.provider as Provider, + accountKey: row.account_key, + costUsd: row.cost_usd, + })); + } catch { + return []; + } + } + + recordClientUsage(report: ClientUsageReport): void { + const now = Date.now(); + this.#db + .query( + `INSERT INTO clients (install_id, hostname, first_seen, last_seen) VALUES (?, ?, ?, ?) + ON CONFLICT(install_id) DO UPDATE SET hostname = COALESCE(excluded.hostname, hostname), last_seen = excluded.last_seen`, + ) + .run(report.installId, report.hostname ?? null, now, now); + const findBucket = this.#db.query( + `SELECT id FROM client_usage + WHERE install_id = ? AND provider = ? AND model = ? AND recorded_at >= ? + ORDER BY recorded_at DESC LIMIT 1`, + ); + const merge = this.#db.query( + `UPDATE client_usage SET recorded_at = ?, requests = requests + ?, input_tokens = input_tokens + ?, + output_tokens = output_tokens + ?, cache_read_tokens = cache_read_tokens + ?, + cache_write_tokens = cache_write_tokens + ?, cost_usd = cost_usd + ? WHERE id = ?`, + ); + const insert = this.#db.query( + `INSERT INTO client_usage (recorded_at, install_id, provider, model, requests, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, cost_usd) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + for (const entry of report.entries) { + // Merge into the newest row of the same (install, provider, model) + // bucket so 10s client flushes don't accrete one row apiece forever. + const bucketFloor = entry.at - CLIENT_USAGE_BUCKET_MS; + const existing = findBucket.get(report.installId, entry.provider, entry.model, bucketFloor) as { + id: number; + } | null; + if (existing) { + merge.run( + entry.at, + entry.requests, + entry.inputTokens, + entry.outputTokens, + entry.cacheReadTokens, + entry.cacheWriteTokens, + entry.costUsd, + existing.id, + ); + continue; + } + insert.run( + entry.at, + report.installId, + entry.provider, + entry.model, + entry.requests, + entry.inputTokens, + entry.outputTokens, + entry.cacheReadTokens, + entry.cacheWriteTokens, + entry.costUsd, + ); + } + } + + getClientUsageSummary(sinceMs: number): ClientUsageSummary { + const clients = this.#db + .query("SELECT install_id, hostname, first_seen, last_seen FROM clients ORDER BY last_seen DESC") + .all() as Array<{ install_id: string; hostname: string | null; first_seen: number; last_seen: number }>; + const aggregates = this.#db + .query( + `SELECT install_id, provider, SUM(requests) requests, SUM(input_tokens) input_tokens, + SUM(output_tokens) output_tokens, SUM(cache_read_tokens) cache_read_tokens, + SUM(cache_write_tokens) cache_write_tokens, SUM(cost_usd) cost_usd + FROM client_usage WHERE recorded_at >= ? GROUP BY install_id, provider + ORDER BY install_id, SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) DESC`, + ) + .all(sinceMs) as Array<{ + install_id: string; + provider: string; + requests: number; + input_tokens: number; + output_tokens: number; + cache_read_tokens: number; + cache_write_tokens: number; + cost_usd: number; + }>; + const providersByInstall = new Map(); + for (const row of aggregates) { + let list = providersByInstall.get(row.install_id); + if (!list) { + list = []; + providersByInstall.set(row.install_id, list); + } + list.push({ + provider: row.provider, + requests: row.requests, + inputTokens: row.input_tokens, + outputTokens: row.output_tokens, + cacheReadTokens: row.cache_read_tokens, + cacheWriteTokens: row.cache_write_tokens, + costUsd: row.cost_usd, + }); + } + return { + clients: clients.map(client => ({ + installId: client.install_id, + hostname: client.hostname ?? undefined, + firstSeen: client.first_seen, + lastSeen: client.last_seen, + providers: providersByInstall.get(client.install_id) ?? [], + })), + }; + } + + // ─── Convenience methods for CLI ──────────────────────────────────────── + + /** + * Save OAuth credentials for a provider. + * Preserves unrelated identities and replaces only the matching credential. + */ + saveOAuth(provider: string, credentials: OAuthCredentials): void { + const credential: AuthCredential = { type: "oauth", ...credentials }; + this.upsertAuthCredentialForProvider(provider, credential); + } + + /** + * Get OAuth credentials for a provider. + */ + getOAuth(provider: string): OAuthCredentials | null { + const rows = this.#listActiveByProviderStmt.all(provider) as AuthRow[]; + for (const row of rows) { + const credential = deserializeCredential(row); + if (credential && credential.type === "oauth") { + const { type: _type, ...oauth } = credential; + return oauth as OAuthCredentials; + } + } + return null; + } + + /** + * Save API key for a provider (replaces existing). + */ + saveApiKey(provider: string, apiKey: string): void { + const credential: AuthCredential = { type: "api_key", key: apiKey }; + this.replaceAuthCredentialsForProvider(provider, [credential]); + } + + /** + * Get API key for a provider. + */ + getApiKey(provider: string): string | null { + const rows = this.#listActiveByProviderStmt.all(provider) as AuthRow[]; + for (const row of rows) { + const credential = deserializeCredential(row); + if (credential && credential.type === "api_key") { + return credential.key; + } + } + return null; + } + + /** + * List all providers with credentials. + */ + listProviders(): string[] { + const rows = this.#listActiveStmt.all() as AuthRow[]; + const providers = new Set(); + for (const row of rows) { + providers.add(row.provider); + } + return Array.from(providers); + } + + /** + * Delete all credentials for a provider. + */ + deleteProvider(provider: string): void { + this.deleteAuthCredentialsForProvider(provider, "deleted by user"); + } + + /** + * SQLite increments `data_version` when another connection commits. Own + * writes leave it unchanged and already notify AuthStorage directly. + */ + pollExternalChanges(): boolean { + this.#acknowledgeLocalAuthChanges(); + const dataVersion = this.#readDataVersion(); + if (dataVersion === this.#dataVersion) return false; + this.#dataVersion = dataVersion; + const authRevision = this.#readAuthRevision(); + if (authRevision === this.#authRevision) return false; + this.#authRevision = authRevision; + return true; + } + + acknowledgeLocalChanges(): void { + this.#acknowledgeLocalAuthChanges(); + } + + #acknowledgeLocalAuthChanges(): void { + const localAuthRevision = this.#readLocalAuthRevision(); + this.#authRevision += localAuthRevision - this.#localAuthRevision; + this.#localAuthRevision = localAuthRevision; + } + + #readDataVersion(): number { + const row = this.#db.query("PRAGMA data_version").get() as { data_version?: number } | null; + return row?.data_version ?? 0; + } + + #readAuthRevision(): number { + const row = this.#db.query("SELECT revision FROM auth_change_revision WHERE id = 1").get() as { + revision?: number; + } | null; + return row?.revision ?? 0; + } + + #readLocalAuthRevision(): number { + const row = this.#db.query("SELECT revision FROM auth_local_change_revision WHERE id = 1").get() as { + revision?: number; + } | null; + return row?.revision ?? 0; + } + + close(): void { + if (this.#closed) return; + this.#closed = true; + this.#listActiveStmt.finalize(); + this.#listActiveByProviderStmt.finalize(); + this.#listDisabledStmt.finalize(); + this.#listDisabledByProviderStmt.finalize(); + this.#insertStmt.finalize(); + this.#updateStmt.finalize(); + this.#deleteStmt.finalize(); + this.#deleteIfMatchesStmt.finalize(); + this.#deleteByProviderStmt.finalize(); + this.#hardDeleteStmt.finalize(); + this.#getCacheStmt.finalize(); + this.#getCacheIncludingExpiredStmt.finalize(); + this.#upsertCacheStmt.finalize(); + this.#deleteExpiredCacheStmt.finalize(); + this.#getCredentialBlockStmt.finalize(); + this.#listCredentialBlocksByCredentialStmt.finalize(); + this.#upsertCredentialBlockStmt.finalize(); + this.#deleteCredentialBlocksStmt.finalize(); + this.#deleteCredentialBlockStmt.finalize(); + this.#deleteExpiredCredentialBlocksStmt.finalize(); + this.#insertUsageHistoryStmt.finalize(); + this.#lastUsageHistoryStmt.finalize(); + this.#listUsageHistoryStmt.finalize(); + this.#updateUsageHistoryStmt.finalize(); + this.#insertUsageCostStmt.finalize(); + this.#listUsageCostsStmt.finalize(); + this.#updateIfMatchesStmt.finalize(); + this.#updateIfMatchesWithLeaseStmt.finalize(); + this.#deleteIfMatchesWithLeaseStmt.finalize(); + this.#deleteCachePrefixStmt.finalize(); + this.#acquireCredentialRefreshLeaseStmt.finalize(); + this.#getCredentialRefreshLeaseStmt.finalize(); + this.#renewCredentialRefreshLeaseStmt.finalize(); + this.#releaseCredentialRefreshLeaseStmt.finalize(); + this.#db.close(); + } +} diff --git a/packages/ai/src/error/auth-classify.ts b/packages/ai/src/error/auth-classify.ts index 575bcc1a1..a253b0ad8 100644 --- a/packages/ai/src/error/auth-classify.ts +++ b/packages/ai/src/error/auth-classify.ts @@ -1,6 +1,6 @@ import { extractHttpStatusFromError } from "@oh-my-pi/pi-utils"; import { isOAuthExpiry, isUsageLimit } from "./flags"; -import { isUsageLimitOutcome } from "./rate-limit"; +import { isConcurrencyCapExclusion, isUsageLimitOutcome } from "./rate-limit"; /** * Whether an OAuth refresh failure is definitive (the credential must be @@ -38,9 +38,10 @@ export function isAuthRetryableError(error: unknown): boolean { if (isUsageLimit(error)) return true; if (isInvalidatedOAuthTokenError(error)) return true; const httpStatus = extractHttpStatusFromError(error); - if (httpStatus === 401 || httpStatus === 403) return true; const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined; const embeddedStatus = message ? extractHttpStatusFromError({ message }) : undefined; - if (embeddedStatus === 401 || embeddedStatus === 403) return true; - return isUsageLimitOutcome(httpStatus ?? embeddedStatus, message); + const status = httpStatus ?? embeddedStatus; + if (isConcurrencyCapExclusion(status, message)) return false; + if (status === 401 || status === 403) return true; + return isUsageLimitOutcome(status, message); } diff --git a/packages/ai/src/error/flags.ts b/packages/ai/src/error/flags.ts index 4e348493b..c72ba9074 100644 --- a/packages/ai/src/error/flags.ts +++ b/packages/ai/src/error/flags.ts @@ -7,7 +7,13 @@ import { ProviderHttpError, STREAM_ENVELOPE_ERROR_PREFIX, } from "./classes"; -import { isOpaqueStatusBody, isUsageLimitStatus, matchesUsageLimitText, parseRateLimitReason } from "./rate-limit"; +import { + isAccountScopedCapText, + isOpaqueStatusBody, + isUsageLimitStatus, + matchesUsageLimitText, + parseRateLimitReason, +} from "./rate-limit"; export const Flag = { Class: 0x1000, @@ -339,15 +345,34 @@ function classifyText(errorMessage: string | undefined, errorStatus: number | un const isOpaque = isOpaqueStatusBody(cleanMessage); const isLimitStatus = isUsageLimitStatus(statusClean); + const reason = parseRateLimitReason(cleanMessage); + // Concurrency caps (e.g. Vertex "Online prediction concurrent requests + // quota exceeded") are shed-and-backoff, not credential-rotatable — + // exclude them even when the quota-worded phrasing matches the generic + // usage-limit text matcher, whose `quota.?exceeded` arm would otherwise + // set Flag.UsageLimit and burn a healthy sibling credential. HTTP 402 is + // excluded from this gate: it is categorically an account-billing cap, so + // a 402 whose body merely mentions concurrency still classifies as a + // usage limit, mirroring isUsageLimitOutcome. + const isBillingCapStatus = statusClean === 402; + const concurrencyExcluded = reason === "CONCURRENT_LIMIT" && !isBillingCapStatus; if ( - matchesUsageLimitText(cleanMessage) || - (isLimitStatus && (isOpaque || parseRateLimitReason(cleanMessage) === "QUOTA_EXHAUSTED")) + !concurrencyExcluded && + (matchesUsageLimitText(cleanMessage) || + ((statusClean === 403 || statusClean === undefined) && isAccountScopedCapText(cleanMessage)) || + (isLimitStatus && + (isOpaque || reason === "QUOTA_EXHAUSTED" || (isBillingCapStatus && reason === "CONCURRENT_LIMIT")))) ) { kinds |= Flag.UsageLimit; } if (isTimeoutText(errorMessage)) kinds |= Flag.Transient | Flag.Timeout; else if (isTransientErrorText(errorMessage)) kinds |= Flag.Transient; + // A concurrency cap (e.g. Vertex "Online prediction concurrent requests + // quota exceeded") is transient — shed-and-backoff. The bare wording need + // not match TRANSIENT_TRANSPORT_PATTERN, so flag it explicitly to keep + // AIError.retriable from treating the temporary cap as terminal. + if (reason === "CONCURRENT_LIMIT") kinds |= Flag.Transient; if ((api === "openai-responses" || api === "openai-codex-responses") && isStaleResponsesText(errorMessage)) { kinds |= Flag.StaleResponsesItem; } @@ -405,7 +430,10 @@ export function classify(error: unknown, api?: Api): number { if (code === "overloaded_error" || code === "rate_limit_error") { linkKinds |= Flag.Transient; } - if (codeStatus === 401 || codeStatus === 403) { + if ( + (codeStatus === 401 || codeStatus === 403) && + !(codeStatus === 403 && parseRateLimitReason(link.message) === "CONCURRENT_LIMIT") + ) { linkKinds |= Flag.AuthFailed; } else if (codeStatus === 429) { if ((linkKinds & Flag.UsageLimit) === 0) { diff --git a/packages/ai/src/error/index.ts b/packages/ai/src/error/index.ts index 4cdb1c3f5..fff0e2036 100644 --- a/packages/ai/src/error/index.ts +++ b/packages/ai/src/error/index.ts @@ -9,5 +9,6 @@ export * from "./format"; export * from "./gateway"; export * from "./oauth"; export * from "./provider"; +export * from "./rate-limit"; export * from "./retryable"; export * from "./validation"; diff --git a/packages/ai/src/error/rate-limit.ts b/packages/ai/src/error/rate-limit.ts index 7252e0dcf..972a533ec 100644 --- a/packages/ai/src/error/rate-limit.ts +++ b/packages/ai/src/error/rate-limit.ts @@ -6,12 +6,14 @@ export type RateLimitReason = | "QUOTA_EXHAUSTED" | "RATE_LIMIT_EXCEEDED" + | "CONCURRENT_LIMIT" | "MODEL_CAPACITY_EXHAUSTED" | "SERVER_ERROR" | "UNKNOWN"; const QUOTA_EXHAUSTED_BACKOFF_MS = 30 * 60 * 1000; // 30 min const RATE_LIMIT_EXCEEDED_BACKOFF_MS = 30 * 1000; // 30s +const CONCURRENT_LIMIT_BACKOFF_MS = 5 * 1000; // 5s const MODEL_CAPACITY_BASE_MS = 45 * 1000; // 45s base const MODEL_CAPACITY_JITTER_MS = 30 * 1000; // ±15s const SERVER_ERROR_BACKOFF_MS = 20 * 1000; // 20s @@ -26,12 +28,24 @@ const OPENROUTER_DAILY_FREE_LIMIT_PATTERN = /\bfree[-_ ]models[-_ ]per[-_ ]day\b // before classifying explicit details; an otherwise opaque status is transient // model capacity, while quota/rate-limit/server wording remains authoritative. const RESOURCE_EXHAUSTED_PATTERN = /resource.?exhausted/gi; +const CONCURRENT_LIMIT_PATTERN = + // Require an actual cap signal near "concurrent". "Too many concurrent + // requests" is itself a cap signal; bare feature rejections such as + // "concurrent invocation is not supported" remain excluded. + /\btoo many\s+concurren\w*\s+(?:requests?|invocations?)\b|\bconcurren\w*\b[^\n]{0,60}\b(?:limit|quota|exceed\w*|reach\w*)\b|\b(?:limit|quota|exceed\w*|reach\w*)\b[^\n]{0,60}\bconcurren\w*\b|\bconcurren[a-z]*[-_](?:[a-z]+[_-])*(?:limit|quota|exceed\w*|reach\w*)/i; +const ACCOUNT_SCOPED_403_PATTERN = + // The bare "limit will reset" / "will reset in" phrasing also appears on + // statusless per-minute transients ("Rate limit will reset in 30 seconds"), + // so gate the reset-window alternative on account-specific wording (Devin's + // "Your limit will reset in …"); the overall/account qualifiers arm above + // already covers the rest. + /\b(?:overall|account|organization|team|workspace)\b[^\n]{0,40}\b(?:message |request )?rate.?limit\b|\byour\b[^\n]{0,30}\b(?:limit )?will reset\b/i; /** * Classify a rate-limit error message into a reason category. * Priority order: explicit details in a resource-exhausted error > QUOTA - * (Antigravity "quota will reset") > MODEL_CAPACITY > QUOTA (account) > - * RATE_LIMIT > QUOTA (generic) > SERVER_ERROR > bare resource-exhausted > UNKNOWN. + * (Antigravity "quota will reset") > CONCURRENT_LIMIT > MODEL_CAPACITY > + * QUOTA (account) > RATE_LIMIT > QUOTA (generic) > SERVER_ERROR > bare resource-exhausted > UNKNOWN. * * Bare "resource exhausted" / "resource_exhausted" maps to MODEL_CAPACITY (transient, short wait). * Explicit details such as "quota exceeded" retain their normal classification. @@ -50,6 +64,10 @@ export function parseRateLimitReason(errorMessage: string): RateLimitReason { return "QUOTA_EXHAUSTED"; } + if (CONCURRENT_LIMIT_PATTERN.test(errorMessage)) { + return "CONCURRENT_LIMIT"; + } + if (lower.includes("capacity") || lower.includes("overloaded") || lower.includes("529") || lower.includes("503")) { return "MODEL_CAPACITY_EXHAUSTED"; } @@ -111,6 +129,8 @@ export function calculateRateLimitBackoffMs(reason: RateLimitReason): number { return QUOTA_EXHAUSTED_BACKOFF_MS; case "RATE_LIMIT_EXCEEDED": return RATE_LIMIT_EXCEEDED_BACKOFF_MS; + case "CONCURRENT_LIMIT": + return CONCURRENT_LIMIT_BACKOFF_MS; case "MODEL_CAPACITY_EXHAUSTED": return MODEL_CAPACITY_BASE_MS + Math.random() * MODEL_CAPACITY_JITTER_MS; case "SERVER_ERROR": @@ -148,18 +168,37 @@ export function isUsageLimitStatus(status: number | undefined): boolean { * 3. Body is absent or {@link isOpaqueStatusBody opaque} (just the status, * empty JSON, HTTP framing only) → rotate conservatively: the server * gave us nothing else to go on. - * 4. Body has content → defer to {@link parseRateLimitReason}. Only - * `QUOTA_EXHAUSTED` rotates; `RATE_LIMIT_EXCEEDED` (`Too many requests`, + * 4. Body has content → defer to {@link parseRateLimitReason}. `QUOTA_EXHAUSTED` + * rotates; for the categorical 402 billing cap a `CONCURRENT_LIMIT` body + * also rotates (the cap is concurrent-worded but the status is still an + * exhausted billing cap). `RATE_LIMIT_EXCEEDED` (`Too many requests`, * per-minute caps), `MODEL_CAPACITY_EXHAUSTED` (`Service overloaded`), * `SERVER_ERROR`, and `UNKNOWN` (`Please retry in 5s`) stay in the * provider's own backoff layer so transient 429s don't burn sibling * credentials. */ export function isUsageLimitOutcome(status: number | undefined, message: string | undefined): boolean { + // Concurrency caps are shed-and-backoff, not credential-rotatable — but only + // for quota-worded 429 / other statuses. HTTP 402 is categorically an + // account-billing cap, so a 402 whose body happens to mention concurrency is + // still an exhausted billing cap and must rotate; gate the exclusion on the + // status not being that categorical billing cap. + const isBillingCapStatus = status === 402; + if (isConcurrencyCapExclusion(status, message)) return false; if (message && matchesUsageLimitText(message)) return true; + // A 403 is normally an auth failure, but several providers deliver an + // account-scoped cap with it (Devin/Codeium Connect `permission_denied`, + // GitHub Copilot). Devin's end-of-stream Connect trailer carries no HTTP + // status at all (it arrives as a `permission_denied` ValidationError), so + // accept an undefined status too — but only when the body names a cap that + // resets, never on a bare 403, which stays an auth failure. + if ((status === 403 || status === undefined) && message && isAccountScopedCapText(message)) return true; if (!isUsageLimitStatus(status)) return false; if (!message || isOpaqueStatusBody(message)) return true; - return parseRateLimitReason(message) === "QUOTA_EXHAUSTED"; + const reason = parseRateLimitReason(message); + // For the categorical 402 billing cap a concurrency-worded body is still an + // exhausted cap (rotate); for 429 / other only QUOTA_EXHAUSTED rotates. + return reason === "QUOTA_EXHAUSTED" || (isBillingCapStatus && reason === "CONCURRENT_LIMIT"); } /** @@ -190,3 +229,23 @@ export function matchesUsageLimitText(errorMessage: string): boolean { OPENROUTER_DAILY_FREE_LIMIT_PATTERN.test(errorMessage) ); } + +/** + * Account-scoped cap phrasing delivered on a 403 (or a statusless Connect + * trailer): "Reached overall message rate limit", "Your limit will reset in …". + * Kept separate from {@link matchesUsageLimitText} because the bare wording is + * ambiguous without the 403 / statusless-account context; consumed by both + * {@link isUsageLimitOutcome} (rotation decision) and `flags.ts` (Flag.UsageLimit). + */ +export function isAccountScopedCapText(message: string): boolean { + return ACCOUNT_SCOPED_403_PATTERN.test(message); +} + +/** + * A concurrency cap on a non-billing status is shed-and-backoff, not + * credential-rotatable. This mirrors the exclusion in {@link isUsageLimitOutcome} + * for the 403 auth-retry entry points. A 402 remains a categorical billing cap. + */ +export function isConcurrencyCapExclusion(status: number | undefined, message: string | undefined): boolean { + return message !== undefined && parseRateLimitReason(message) === "CONCURRENT_LIMIT" && status !== 402; +} diff --git a/packages/ai/src/providers/cursor-pi-args.ts b/packages/ai/src/providers/cursor-pi-args.ts index d7b759f5a..c88275b8f 100644 --- a/packages/ai/src/providers/cursor-pi-args.ts +++ b/packages/ai/src/providers/cursor-pi-args.ts @@ -47,6 +47,36 @@ export function piReadPath(readPath: string, offset?: number, limit?: number): s return count === undefined ? `${readPath}:raw:${start}-` : `${readPath}:raw:${start}+${count}`; } +const READ_RANGE_CHUNK_RE = /^L?(\d+)(?:(\.\.|[-+])L?(\d+)?)?$/i; + +function isReadRangeList(value: string): boolean { + return value.split(",").every(chunk => { + const match = READ_RANGE_CHUNK_RE.exec(chunk); + if (!match) return false; + const start = Number.parseInt(match[1]!, 10); + if (start < 1) return false; + const separator = match[2]; + if (!separator) return true; + const end = match[3] ? Number.parseInt(match[3], 10) : undefined; + if (separator === "+") return end !== undefined && end >= 1; + return end === undefined || end >= start; + }); +} + +/** + * Whether a read path ends in an OMP line selector, including compound `raw` + * forms. Cursor uses this only to describe the operation already executed by + * the coding-agent read tool; the selector remains embedded in the path. + */ +export function piReadPathHasRange(readPath: string): boolean { + const chunks = readPath.split(":"); + const last = chunks.at(-1); + if (last && isReadRangeList(last)) return true; + if (last?.toLowerCase() !== "raw") return false; + const preceding = chunks.at(-2); + return preceding !== undefined && isReadRangeList(preceding); +} + /** * The same range as {@link piReadPath}, rendered for a transcript block rather * than for execution. diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts index f1c466041..d276a27b0 100644 --- a/packages/ai/src/providers/cursor.ts +++ b/packages/ai/src/providers/cursor.ts @@ -216,6 +216,7 @@ import { piLimit, piLsPath, piReadDisplayPath, + piReadPathHasRange, piTimeout, } from "./cursor/exec-modern"; @@ -1322,7 +1323,7 @@ async function handleExecServerMessage( buildReadResultFromToolResult( args.path, toolResult, - args.offset !== undefined || args.limit !== undefined, + args.offset !== undefined || args.limit !== undefined || piReadPathHasRange(args.path), ), reason => buildReadRejectedResult(args.path, reason), error => buildReadErrorResult(args.path, error), @@ -2439,15 +2440,21 @@ function toolResultDetailBoolean(toolResult: ToolResultMessage, key: string): bo /** * The file's own line count, when the tool recorded one. * - * `details.meta.truncation.totalLines` is the whole file; the flat - * `details.truncation.totalLines` counts from the window's start line and is - * deliberately not consulted here. Absent for a read that returned the file - * whole, where the payload IS the file and counting it is exact. + * Read results expose the source-wide count directly when known. Older tool + * results carry it at `details.meta.truncation.totalLines`; the flat + * `details.truncation.totalLines` counts from a window's start and is + * deliberately not consulted here. */ function readTotalLinesFromDetails(toolResult: ToolResultMessage): number | undefined { - if (!toolResult.details || typeof toolResult.details !== "object") return undefined; - const meta = (toolResult.details as { meta?: { truncation?: { totalLines?: unknown } } }).meta; - const totalLines = meta?.truncation?.totalLines; + const details = toolResult.details; + if (!details || typeof details !== "object") return undefined; + const direct = "totalLines" in details ? details.totalLines : undefined; + if (typeof direct === "number" && Number.isFinite(direct)) return direct; + const meta = "meta" in details ? details.meta : undefined; + if (!meta || typeof meta !== "object") return undefined; + const truncation = "truncation" in meta ? meta.truncation : undefined; + if (!truncation || typeof truncation !== "object") return undefined; + const totalLines = "totalLines" in truncation ? truncation.totalLines : undefined; return typeof totalLines === "number" && Number.isFinite(totalLines) ? totalLines : undefined; } @@ -2467,7 +2474,7 @@ function buildReadResultFromToolResult(path: string, toolResult: ToolResultMessa // whole file. Under a composed window it is the window's, and answering a // 20-line page of a 100-line file with `total_lines: 20` tells a paginating // server it has reached the end. - const totalLines = readTotalLinesFromDetails(toolResult) ?? (text ? text.split("\n").length : 0); + const totalLines = readTotalLinesFromDetails(toolResult) ?? (rangeApplied ? 0 : text ? text.split("\n").length : 0); return create(ReadResultSchema, { result: { case: "success", diff --git a/packages/ai/src/providers/cursor/exec-modern.ts b/packages/ai/src/providers/cursor/exec-modern.ts index ebf1bccdf..621f00a6e 100644 --- a/packages/ai/src/providers/cursor/exec-modern.ts +++ b/packages/ai/src/providers/cursor/exec-modern.ts @@ -81,6 +81,7 @@ export { piLsPath, piReadDisplayPath, piReadPath, + piReadPathHasRange, piTimeout, } from "../cursor-pi-args"; diff --git a/packages/ai/src/providers/mock.ts b/packages/ai/src/providers/mock.ts index 1e81292d2..a77a4bf95 100644 --- a/packages/ai/src/providers/mock.ts +++ b/packages/ai/src/providers/mock.ts @@ -67,7 +67,7 @@ export type MockApi = typeof MOCK_API; export type MockContent = | string | { type: "text"; text: string } - | { type: "thinking"; thinking: string } + | { type: "thinking"; thinking: string; thinkingSignature?: string } | { type: "toolCall"; /** Optional explicit id; auto-generated when omitted. */ diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 8c27fe700..9e320b70c 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -128,7 +128,7 @@ import { redactSensitiveInObject, transformMessages } from "./transform-messages export interface OpenAICodexResponsesOptions extends StreamOptions { reasoning?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; reasoningSummary?: "auto" | "concise" | "detailed" | null; - /** `reasoning.context` replay scope; defaults to `all_turns` when unset. The `all_turns` value is gated to gpt-5.4+ Codex models — older ids reject it, so it is suppressed and `context` omitted. */ + /** Explicit `reasoning.context` replay scope. Omitted by default so Codex applies its native request policy. */ reasoningContext?: CodexReasoningContext; textVerbosity?: "low" | "medium" | "high"; codexMode?: boolean; @@ -1530,7 +1530,7 @@ export async function buildTransformedCodexRequestBody( } const codexOptions: CodexRequestOptions = { reasoningEffort: options?.reasoning, - reasoningSummary: options?.reasoningSummary === undefined ? "auto" : options.reasoningSummary, + reasoningSummary: options?.reasoningSummary, reasoningContext: options?.reasoningContext, textVerbosity: options?.textVerbosity, include: options?.include, diff --git a/packages/ai/src/providers/openai-codex/request-transformer.ts b/packages/ai/src/providers/openai-codex/request-transformer.ts index b0b157e9c..13a2cc7f9 100644 --- a/packages/ai/src/providers/openai-codex/request-transformer.ts +++ b/packages/ai/src/providers/openai-codex/request-transformer.ts @@ -33,7 +33,7 @@ export interface CodexRequestOptions { /** User-facing effort; maps 1:1 onto the wire tier of the same name. */ reasoningEffort?: CodexCallerEffort | "none"; reasoningSummary?: ReasoningConfig["summary"] | null; - /** Explicit `reasoning.context` override; defaults to `all_turns` when unset. Gated to gpt-5.4+ Codex models (older ids reject it, so it is suppressed and `context` omitted). Note that under Responses Lite (`responsesLite`), the server strictly requires `reasoning.context` to be `all_turns`, which overrides this option and forces `all_turns`. */ + /** Explicit `reasoning.context` override. Omitted by default; Responses Lite forces `all_turns` as required by that transport. */ reasoningContext?: CodexReasoningContext; textVerbosity?: "low" | "medium" | "high"; include?: string[]; @@ -145,13 +145,12 @@ function getReasoningConfig( const config: ReasoningConfig = { effort: effort === "none" ? "none" : mapCodexWireEffort(model, effort), }; - // `reasoning.summary` is accepted only from gpt-5.4 onward; earlier Codex ids - // (gpt-5.1-codex, gpt-5.3-codex, gpt-5.3-codex-spark) reject it with - // "Unsupported parameter: 'reasoning.summary' is not supported with this model". - // Mirrors the all_turns gate: an explicit summary is suppressed on unsupported - // ids, letting the server skip the human-readable summary stream. - if (options.reasoningSummary !== null && supportsCodexReasoningSummary(model.id)) { - config.summary = options.reasoningSummary ?? "detailed"; + if ( + options.reasoningSummary !== undefined && + options.reasoningSummary !== null && + supportsCodexReasoningSummary(model.id) + ) { + config.summary = options.reasoningSummary; } return config; } @@ -444,21 +443,14 @@ export async function transformRequestBody( ...body.reasoning, ...reasoningConfig, }; - // Default reasoning replay to `all_turns`, mirroring codex-rs; an - // explicit `reasoningContext` overrides the default. The `all_turns` - // value is only accepted from gpt-5.4 onward — earlier Codex ids - // (gpt-5.1-codex, gpt-5.3-codex, gpt-5.3-codex-spark) reject it with - // "Unsupported value: 'all_turns' is not supported with this model". - // For those, drop `context` so the server applies its `current_turn` - // default. The version gate is authoritative: even an explicit - // `all_turns` override is suppressed on unsupported models, while - // `current_turn`/`auto` (universally supported) always pass through. - // Note: Responses Lite forces `all_turns` to satisfy the transport's server invariant. - const context = responsesLite ? "all_turns" : (options.reasoningContext ?? "all_turns"); - if (context === "all_turns" && !supportsAllTurnsReasoningContext(model.id)) { - delete body.reasoning.context; - } else { - body.reasoning.context = context; + // Responses Lite requires `all_turns`; the full transport leaves context to the server unless explicitly set. + const context = responsesLite ? "all_turns" : options.reasoningContext; + if (context !== undefined) { + if (context === "all_turns" && !supportsAllTurnsReasoningContext(model.id)) { + delete body.reasoning.context; + } else { + body.reasoning.context = context; + } } } else { delete body.reasoning; @@ -481,10 +473,12 @@ export async function transformRequestBody( delete body.stream_options; } - body.text = { - ...body.text, - verbosity: options.textVerbosity || "medium", - }; + if (options.textVerbosity !== undefined) { + body.text = { + ...body.text, + verbosity: options.textVerbosity, + }; + } const include = Array.isArray(options.include) ? [...options.include] : []; include.push("reasoning.encrypted_content"); diff --git a/packages/ai/src/registry/oauth/devin.ts b/packages/ai/src/registry/oauth/devin.ts index b3b8029c5..e8c8c251b 100644 --- a/packages/ai/src/registry/oauth/devin.ts +++ b/packages/ai/src/registry/oauth/devin.ts @@ -33,7 +33,7 @@ class DevinOAuthFlow extends OAuthCallbackFlow { }); } - generateState(): string { + override generateState(): string { return crypto.randomUUID(); } diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index aff282f07..f89dfd0e3 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -21,7 +21,7 @@ import { createAuthRetryKeyState, isApiKeyResolver, resolveNextAuthRetryKey } fr import * as AIError from "./error"; import { ProviderHttpError } from "./error"; import { isInvalidatedOAuthTokenError } from "./error/auth-classify"; -import { isUsageLimitOutcome } from "./error/rate-limit"; +import { isConcurrencyCapExclusion, isUsageLimitOutcome } from "./error/rate-limit"; import type { BedrockOptions } from "./providers/amazon-bedrock"; import type { AnthropicOptions } from "./providers/anthropic"; import { coworkFetch } from "./providers/cowork-fetch"; @@ -995,7 +995,7 @@ function isRetryableUpstreamError(error: unknown, status: number | undefined, me // instead of burning siblings. if (AIError.isUsageLimit(error)) return true; if (isInvalidatedOAuthTokenError(error)) return true; - if (status === 401 || status === 403) return true; + if (status === 401 || (status === 403 && !isConcurrencyCapExclusion(status, message))) return true; return isUsageLimitOutcome(status, message); } @@ -1705,7 +1705,7 @@ function mapOptionsForApi( serviceTier: options?.serviceTier, preferWebsockets: options?.preferWebsockets, codexCompaction: options?.codexCompaction, - reasoningSummary: options?.hideThinkingSummary ? null : "detailed", + reasoningSummary: options?.hideThinkingSummary ? null : undefined, textVerbosity: options?.textVerbosity, }); diff --git a/packages/ai/src/usage/openai-codex.ts b/packages/ai/src/usage/openai-codex.ts index 57b080199..48f2248ca 100644 --- a/packages/ai/src/usage/openai-codex.ts +++ b/packages/ai/src/usage/openai-codex.ts @@ -263,11 +263,10 @@ function buildUsageAmount(window: ParsedUsageWindow): UsageAmount { }; } -function buildUsageStatus(usedFraction?: number, limitReached?: boolean): UsageLimit["status"] { - if (limitReached) return "exhausted"; - if (usedFraction === undefined) return "unknown"; - if (usedFraction >= 1) return "exhausted"; - if (usedFraction >= 0.9) return "warning"; +function buildUsageStatus(args: { usedFraction?: number; explicitlyAllowed: boolean }): UsageLimit["status"] { + if (args.usedFraction === undefined) return "unknown"; + if (args.usedFraction >= 1) return args.explicitlyAllowed ? "warning" : "exhausted"; + if (args.usedFraction >= 0.9) return "warning"; return "ok"; } @@ -276,6 +275,8 @@ function buildUsageLimit(args: { window: ParsedUsageWindow; accountId?: string; planType?: string; + allowed?: boolean; + limitReached?: boolean; nowMs: number; }): UsageLimit { const usageWindow = buildUsageWindow(args.window, args.key, args.nowMs); @@ -290,16 +291,14 @@ function buildUsageLimit(args: { }, window: usageWindow, amount, - // Each chat window's status reflects ONLY its own usage. The account-level - // `rate_limit.limit_reached` flag is intentionally not applied here: Codex - // returns a single shared flag for the whole account, so threading it into - // both the primary (5h) and secondary (weekly) windows marked a window with - // real headroom `exhausted` purely because a different window (or a separate - // metered feature) was at its limit, which over-blocked sibling accounts - // during credential selection. `usedFraction >= 1` already marks a window - // that is genuinely full; a real enforced limit not reflected in - // `used_percent` is caught when the live request returns usage_limit_reached. - status: buildUsageStatus(amount.usedFraction), + // The shared account-level rejection flag cannot identify which window + // is binding, but an explicit positive verdict applies to both windows. + // Preserve 100% as a warning when Codex still allows requests; live + // usage_limit_reached responses remain authoritative for blocking. + status: buildUsageStatus({ + usedFraction: amount.usedFraction, + explicitlyAllowed: args.allowed === true && args.limitReached === false, + }), }; } function additionalLimitSlug(args: { limitName?: string; meteredFeature?: string }): string { @@ -331,6 +330,8 @@ function buildAdditionalUsageLimit(args: { accountId?: string; limitName?: string; meteredFeature?: string; + allowed?: boolean; + limitReached?: boolean; nowMs: number; }): UsageLimit { const usageWindow = buildUsageWindow(args.window, args.key, args.nowMs); @@ -348,10 +349,12 @@ function buildAdditionalUsageLimit(args: { }, window: usageWindow, amount, - // The additional meter exposes one account-level flag for both windows. - // Status must follow this window's own usage or a full weekly meter marks - // the shorter window exhausted and schedules a premature retry. - status: buildUsageStatus(amount.usedFraction), + // A positive meter verdict is authoritative even when the advisory + // percentage rounds to 100; negative shared verdicts remain window-local. + status: buildUsageStatus({ + usedFraction: amount.usedFraction, + explicitlyAllowed: args.allowed === true && args.limitReached === false, + }), }; } @@ -449,6 +452,8 @@ export const openaiCodexUsageProvider: UsageProvider = { window: parsed.primary, accountId, planType, + allowed: parsed.allowed, + limitReached: parsed.limitReached, nowMs, }), ); @@ -460,6 +465,8 @@ export const openaiCodexUsageProvider: UsageProvider = { window: parsed.secondary, accountId, planType, + allowed: parsed.allowed, + limitReached: parsed.limitReached, nowMs, }), ); @@ -478,6 +485,8 @@ export const openaiCodexUsageProvider: UsageProvider = { accountId, limitName: extra.limitName, meteredFeature: extra.meteredFeature, + allowed: extra.allowed, + limitReached: extra.limitReached, nowMs, }), ); @@ -492,6 +501,8 @@ export const openaiCodexUsageProvider: UsageProvider = { accountId, limitName: extra.limitName, meteredFeature: extra.meteredFeature, + allowed: extra.allowed, + limitReached: extra.limitReached, nowMs, }), ); diff --git a/packages/ai/test/auth-retry.test.ts b/packages/ai/test/auth-retry.test.ts index e4fbdb652..e438dcf04 100644 --- a/packages/ai/test/auth-retry.test.ts +++ b/packages/ai/test/auth-retry.test.ts @@ -275,6 +275,32 @@ describe("withAuth", () => { expect(contexts.map(ctx => ctx.lastChance)).toEqual([false, true, true, true]); }); + it("leaves a 403 concurrency cap to the transient retry layer", async () => { + const keys: string[] = []; + const contexts: ApiKeyResolveContext[] = []; + const pool = ["k0", "k1", "k2", "k3"]; + let resolveIndex = 0; + const concurrencyCap = Object.assign(new Error("concurrent requests limit reached"), { status: 403 }); + + await expect( + withAuth( + ctx => { + contexts.push(ctx); + return ctx.error === undefined ? pool[0] : pool[++resolveIndex]; + }, + async key => { + keys.push(key); + throw concurrencyCap; + }, + ), + ).rejects.toBe(concurrencyCap); + + // The outer transient retry/backoff layer owns concurrency caps. The auth + // retry layer must not refresh or select a sibling credential. + expect(keys).toEqual(["k0"]); + expect(contexts.map(ctx => ctx.lastChance)).toEqual([false]); + }); + it("surfaces the last 403 when every sibling is denied", async () => { const errors = [authError(403), authError(403)]; const resolved = ["k0", "k1", "k0"]; diff --git a/packages/ai/test/auth-storage-codex-selection.test.ts b/packages/ai/test/auth-storage-codex-selection.test.ts index 365e7da0b..72656926e 100644 --- a/packages/ai/test/auth-storage-codex-selection.test.ts +++ b/packages/ai/test/auth-storage-codex-selection.test.ts @@ -418,6 +418,37 @@ describe("AuthStorage codex oauth ranking", () => { expect(apiKey).toBe("api-acct-healthy"); }); + test("selects an explicitly allowed 100% Team account over a rejected exhausted sibling", async () => { + if (!authStorage) throw new Error("test setup failed"); + + await authStorage.set("openai-codex", [ + { type: "oauth", ...createCredential("acct-exhausted", "exhausted@example.com") }, + { type: "oauth", ...createCredential("acct-team", "team@example.com") }, + ]); + + usageByAccount.set( + "acct-exhausted", + createCodexUsageReport({ + accountId: "acct-exhausted", + primary: { usedFraction: 1, resetInMs: 3 * 24 * HOUR_MS }, + secondary: { usedFraction: 1, resetInMs: 3 * 24 * HOUR_MS }, + metadata: { allowed: false, limitReached: true, planType: "prolite" }, + }), + ); + const teamReport = createCodexUsageReport({ + accountId: "acct-team", + primary: { usedFraction: 0.2, resetInMs: HOUR_MS }, + secondary: { usedFraction: 1, resetInMs: 6 * 24 * HOUR_MS }, + metadata: { allowed: true, limitReached: false, planType: "team" }, + }); + const teamSecondary = teamReport.limits.find(limit => limit.id === "openai-codex:secondary"); + if (!teamSecondary) throw new Error("expected Team weekly usage limit"); + teamSecondary.status = "warning"; + usageByAccount.set("acct-team", teamReport); + + expect(await authStorage.getApiKey("openai-codex", "allowed-team-at-100-percent")).toBe("api-acct-team"); + }); + test("temporarily blocks only the exhausted Codex OAuth credential after a quota 429", async () => { if (!authStorage) throw new Error("test setup failed"); @@ -2027,6 +2058,90 @@ describe("AuthStorage codex oauth ranking", () => { expect(apiKey).toBe("api-acct-pro"); }); + test("ignores plan-ineligible headroom when reporting Spark model health", async () => { + if (!authStorage) throw new Error("test setup failed"); + + await authStorage.set("openai-codex", [ + { type: "oauth", ...createCredential("acct-free", "free@example.com") }, + { type: "oauth", ...createCredential("acct-pro", "pro@example.com") }, + ]); + usageByAccount.set( + "acct-free", + addSparkUsage( + createCodexUsageReport({ + accountId: "acct-free", + primary: { usedFraction: 0.05, resetInMs: 30 * 60 * 1000 }, + secondary: { usedFraction: 0.05, resetInMs: 6 * 24 * 60 * 60 * 1000 }, + metadata: { planType: "free", email: "free@example.com" }, + }), + 0.05, + 0.05, + ), + ); + usageByAccount.set( + "acct-pro", + addSparkUsage( + createCodexUsageReport({ + accountId: "acct-pro", + primary: { usedFraction: 1, resetInMs: 2 * HOUR_MS }, + secondary: { usedFraction: 1, resetInMs: 6 * 24 * 60 * 60 * 1000 }, + metadata: { planType: "pro", email: "pro@example.com", limitReached: true }, + }), + 1, + 1, + ), + ); + + const health = await authStorage.getModelUsageHealth("openai-codex", { + modelId: "gpt-5.3-codex-spark", + reserveFraction: 0.1, + }); + + expect(health.state).toBe("depleted"); + expect(health.accounts).toHaveLength(1); + expect(health.accounts[0]?.state).toBe("depleted"); + }); + + test("reports an all-plan-ineligible Codex pool as depleted", async () => { + if (!authStorage) throw new Error("test setup failed"); + + await authStorage.set("openai-codex", [ + { type: "oauth", ...createCredential("acct-free", "free@example.com") }, + { type: "oauth", ...createCredential("acct-plus", "plus@example.com") }, + ]); + usageByAccount.set( + "acct-free", + createCodexUsageReport({ + accountId: "acct-free", + primary: { usedFraction: 0.05, resetInMs: 30 * 60 * 1000 }, + secondary: { usedFraction: 0.05, resetInMs: 6 * 24 * 60 * 60 * 1000 }, + metadata: { planType: "free", email: "free@example.com" }, + }), + ); + usageByAccount.set( + "acct-plus", + createCodexUsageReport({ + accountId: "acct-plus", + primary: { usedFraction: 0.05, resetInMs: 30 * 60 * 1000 }, + secondary: { usedFraction: 0.05, resetInMs: 6 * 24 * 60 * 60 * 1000 }, + metadata: { planType: "plus", email: "plus@example.com" }, + }), + ); + + const paidHealth = await authStorage.getModelUsageHealth("openai-codex", { + modelId: "gpt-5.6-sol", + reserveFraction: 0.1, + }); + const proHealth = await authStorage.getModelUsageHealth("openai-codex", { + modelId: "gpt-5.3-codex-spark", + reserveFraction: 0.1, + }); + + expect(paidHealth.state).toBe("healthy"); + expect(paidHealth.accounts).toHaveLength(1); + expect(proHealth).toEqual({ state: "depleted", accounts: [] }); + }); + test("routes codex spark to a single Plus account when no Pro is connected", async () => { if (!authStorage) throw new Error("test setup failed"); diff --git a/packages/ai/test/cursor-exec-modern.test.ts b/packages/ai/test/cursor-exec-modern.test.ts index 45947f29e..1a796958e 100644 --- a/packages/ai/test/cursor-exec-modern.test.ts +++ b/packages/ai/test/cursor-exec-modern.test.ts @@ -1680,6 +1680,31 @@ describe("Cursor legacy read frame: range reporting", () => { if (wholeAnswer.value.result.case !== "success") throw new Error(`got ${wholeAnswer.value.result.case}`); expect(wholeAnswer.value.result.value.rangeApplied).toBe(false); }); + it("treats a path-embedded selector as ranged without reporting the slice as the file total", async () => { + const slice = Array.from({ length: 55 }, (_, index) => `line ${index + 301}`).join("\n"); + const { frames } = await dispatchExec( + buildExecMessage({ + case: "readArgs", + value: create(ReadArgsSchema, { + path: "/repo/plan.md:raw:301-", + toolCallId: "c-inline", + }), + }), + { + execHandlers: { + async read() { + return toolResult(slice, { details: { fileSize: 21_015 } }); + }, + }, + }, + ); + const answer = soleResult(frames); + if (answer.case !== "readResult") throw new Error(`got ${answer.case}`); + if (answer.value.result.case !== "success") throw new Error(`got ${answer.value.result.case}`); + expect(answer.value.result.value.totalLines).toBe(0); + expect(answer.value.result.value.rangeApplied).toBe(true); + expect(answer.value.result.value.fileSize).toBe(21_015n); + }); it("carries the composed selector into the synthesized call", async () => { // A bare path beside a ranged result makes the slice look like the whole diff --git a/packages/ai/test/openai-codex-responses-lite.test.ts b/packages/ai/test/openai-codex-responses-lite.test.ts index 3f4ff7a74..222bd511b 100644 --- a/packages/ai/test/openai-codex-responses-lite.test.ts +++ b/packages/ai/test/openai-codex-responses-lite.test.ts @@ -128,71 +128,58 @@ function createCodexFetchMock(sse: string, onRequest: (captured: CapturedCodexRe }) as FetchImpl; } -describe("openai-codex reasoning.context", () => { - it("defaults to all_turns on gpt-5.4+ models and forwards explicit overrides", async () => { - const model = createCodexModel("gpt-5.4"); +describe("openai-codex optional response controls", () => { + it("omits optional controls on full requests and forwards explicit controls", async () => { + const model = createCodexModel("gpt-5.5"); const defaulted = await transformRequestBody({ model: model.id }, model, { reasoningEffort: "medium" }); - expect(defaulted.reasoning?.context).toBe("all_turns"); + expect(defaulted.reasoning).toEqual({ effort: "medium" }); + expect("summary" in (defaulted.reasoning ?? {})).toBe(false); + expect("context" in (defaulted.reasoning ?? {})).toBe(false); + expect("text" in defaulted).toBe(false); + expect("stream_options" in defaulted).toBe(false); const explicit = await transformRequestBody({ model: model.id }, model, { reasoningEffort: "medium", - reasoningContext: "current_turn", + reasoningSummary: "concise", + reasoningContext: "all_turns", + textVerbosity: "low", }); - expect(explicit.reasoning?.context).toBe("current_turn"); + expect(explicit.reasoning).toEqual({ + effort: "medium", + summary: "concise", + context: "all_turns", + }); + expect(explicit.text).toEqual({ verbosity: "low" }); + expect(explicit.stream_options).toEqual({ reasoning_summary_delivery: "sequential_cutoff" }); }); - it("keeps the all_turns default for the lite transport on supported models", async () => { + it("omits reasoning.summary when explicitly suppressed", async () => { const model = createCodexModel("gpt-5.5"); - - const lite = await transformRequestBody({ model: model.id }, model, { + const suppressed = await transformRequestBody({ model: model.id }, model, { reasoningEffort: "medium", - responsesLite: true, + reasoningSummary: null, }); - expect(lite.reasoning?.context).toBe("all_turns"); - - const overridden = await transformRequestBody({ model: model.id }, model, { - reasoningEffort: "medium", - responsesLite: true, - reasoningContext: "auto", - }); - expect(overridden.reasoning?.context).toBe("all_turns"); + expect(suppressed.reasoning).toEqual({ effort: "medium" }); + expect("summary" in (suppressed.reasoning ?? {})).toBe(false); + expect("stream_options" in suppressed).toBe(false); }); - it("enforces reasoning.context to be all_turns for the lite transport even when effort is unset or none", async () => { + it("forces reasoning.context to all_turns for Responses Lite", async () => { const model = createCodexModel("gpt-5.5"); - // Case 1: reasoningEffort is undefined (missing effort) const missingEffort = await transformRequestBody({ model: model.id }, model, { responsesLite: true, }); - expect(missingEffort.reasoning?.context).toBe("all_turns"); - expect(missingEffort.reasoning?.effort).toBeUndefined(); + expect(missingEffort.reasoning).toEqual({ context: "all_turns" }); - // Case 2: reasoningEffort is explicitly "none" (effort set to off) const noneEffort = await transformRequestBody({ model: model.id }, model, { reasoningEffort: "none", responsesLite: true, - }); - expect(noneEffort.reasoning?.context).toBe("all_turns"); - expect(noneEffort.reasoning?.effort).toBe("none"); - - // Case 3: Conflicting explicit reasoningContext with missing effort under Lite - const conflictingUnsetEffort = await transformRequestBody({ model: model.id }, model, { - responsesLite: true, reasoningContext: "current_turn", }); - expect(conflictingUnsetEffort.reasoning?.context).toBe("all_turns"); + expect(noneEffort.reasoning).toEqual({ effort: "none", context: "all_turns" }); - // Case 4: Conflicting explicit reasoningContext with "none" effort under Lite - const conflictingNoneEffort = await transformRequestBody({ model: model.id }, model, { - reasoningEffort: "none", - responsesLite: true, - reasoningContext: "current_turn", - }); - expect(conflictingNoneEffort.reasoning?.context).toBe("all_turns"); - - // Case 5: responsesLite is false and reasoningEffort is undefined (regular request with no effort) const plainRequest = await transformRequestBody({ model: model.id }, model, { responsesLite: false, }); @@ -202,73 +189,35 @@ describe("openai-codex reasoning.context", () => { // gpt-5.1-codex / gpt-5.3-codex / gpt-5.3-codex-spark reject `all_turns` // ("Unsupported value: 'all_turns' is not supported with this model"). it.each(["gpt-5.1-codex", "gpt-5.3-codex", "gpt-5.3-codex-spark"])( - "omits the all_turns default for pre-5.4 model %s", + "omits unsupported all_turns context for pre-5.4 model %s", async modelId => { const model = createCodexModel(modelId); + const forced = await transformRequestBody({ model: model.id }, model, { + reasoningEffort: "medium", + reasoningContext: "all_turns", + }); + expect(forced.reasoning).toEqual({ effort: "medium" }); - const defaulted = await transformRequestBody({ model: model.id }, model, { reasoningEffort: "medium" }); - expect(defaulted.reasoning).toBeDefined(); - expect(defaulted.reasoning?.context).toBeUndefined(); - expect("context" in (defaulted.reasoning ?? {})).toBe(false); - - // A supported override (current_turn/auto) is still honored. const overridden = await transformRequestBody({ model: model.id }, model, { reasoningEffort: "medium", reasoningContext: "current_turn", }); - expect(overridden.reasoning?.context).toBe("current_turn"); + expect(overridden.reasoning).toEqual({ effort: "medium", context: "current_turn" }); }, ); - it("suppresses an explicit all_turns override on a pre-5.4 model", async () => { - const model = createCodexModel("gpt-5.3-codex-spark"); - - const forced = await transformRequestBody({ model: model.id }, model, { - reasoningEffort: "medium", - reasoningContext: "all_turns", - }); - expect(forced.reasoning).toBeDefined(); - expect(forced.reasoning?.context).toBeUndefined(); - }); -}); - -describe("openai-codex reasoning.summary", () => { - it("sends summary on gpt-5.4+ models and honors explicit levels", async () => { - const model = createCodexModel("gpt-5.4"); - - const defaulted = await transformRequestBody({ model: model.id }, model, { reasoningEffort: "medium" }); - expect(defaulted.reasoning?.summary).toBe("detailed"); - - const explicit = await transformRequestBody({ model: model.id }, model, { - reasoningEffort: "medium", - reasoningSummary: "concise", - }); - expect(explicit.reasoning?.summary).toBe("concise"); - - const suppressed = await transformRequestBody({ model: model.id }, model, { - reasoningEffort: "medium", - reasoningSummary: null, - }); - expect("summary" in (suppressed.reasoning ?? {})).toBe(false); - }); - // gpt-5.1-codex / gpt-5.3-codex / gpt-5.3-codex-spark reject `reasoning.summary` // ("Unsupported parameter: 'reasoning.summary' is not supported with this model"). it.each(["gpt-5.1-codex", "gpt-5.3-codex", "gpt-5.3-codex-spark"])( "omits reasoning.summary for pre-5.4 model %s", async modelId => { const model = createCodexModel(modelId); - - const defaulted = await transformRequestBody({ model: model.id }, model, { reasoningEffort: "medium" }); - expect(defaulted.reasoning).toBeDefined(); - expect("summary" in (defaulted.reasoning ?? {})).toBe(false); - - // Even an explicit summary level is suppressed on unsupported ids. const forced = await transformRequestBody({ model: model.id }, model, { reasoningEffort: "medium", reasoningSummary: "detailed", }); - expect("summary" in (forced.reasoning ?? {})).toBe(false); + expect(forced.reasoning).toEqual({ effort: "medium" }); + expect("stream_options" in forced).toBe(false); }, ); }); @@ -816,7 +765,10 @@ describe("openai-codex concurrent reasoning summaries", () => { it("sends stream_options only when a summary is requested and supported", async () => { const terra = createCodexModel("gpt-5.6-terra"); - const withSummary = await transformRequestBody({ model: terra.id }, terra, { reasoningEffort: "medium" }); + const withSummary = await transformRequestBody({ model: terra.id }, terra, { + reasoningEffort: "medium", + reasoningSummary: "detailed", + }); expect(withSummary.stream_options).toEqual({ reasoning_summary_delivery: "sequential_cutoff" }); expect(withSummary.reasoning?.summary).toBe("detailed"); @@ -830,7 +782,10 @@ describe("openai-codex concurrent reasoning summaries", () => { expect(noReasoning.stream_options).toBeUndefined(); const legacy = createCodexModel("gpt-5.1-codex"); - const unsupported = await transformRequestBody({ model: legacy.id }, legacy, { reasoningEffort: "medium" }); + const unsupported = await transformRequestBody({ model: legacy.id }, legacy, { + reasoningEffort: "medium", + reasoningSummary: "detailed", + }); expect(unsupported.stream_options).toBeUndefined(); }); @@ -889,6 +844,7 @@ describe("openai-codex concurrent reasoning summaries", () => { apiKey: createCodexTestToken(), fetch: fetchMock, reasoning: "medium", + reasoningSummary: "detailed", }); const thinkingDeltas: string[] = []; for await (const event of stream) { @@ -1060,6 +1016,7 @@ describe("openai-codex concurrent reasoning summaries", () => { apiKey: createCodexTestToken(), fetch: fetchMock, reasoning: "medium", + reasoningSummary: "detailed", }); const thinkingDeltas: string[] = []; for await (const event of stream) { @@ -1215,6 +1172,7 @@ describe("openai-codex concurrent reasoning summaries", () => { apiKey: createCodexTestToken(), fetch: fetchMock, reasoning: "medium", + reasoningSummary: "detailed", }); const deltasByBlock = new Map(); for await (const event of stream) { diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index 4e4c543dd..9b79ce4bd 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -19,6 +19,7 @@ import type { } from "@oh-my-pi/pi-ai/types"; import { __resetProxyCache } from "@oh-my-pi/pi-ai/utils/proxy"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; +import { Effort } from "@oh-my-pi/pi-catalog/effort"; import * as piUtils from "@oh-my-pi/pi-utils"; import { withEnv } from "./helpers"; @@ -409,7 +410,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { this.emitCodexResponse({ messageId: "msg_opaque", responseId: "resp_opaque", text: "pong" }); } } @@ -501,6 +502,32 @@ describe("openai-codex streaming", () => { expect(capturedText).toEqual({ verbosity: "low" }); }); + it("omits optional response controls from default SimpleStreamOptions", async () => { + const tempDir = TempDir.createSync("@pi-codex-stream-"); + setAgentDir(tempDir.path()); + const token = createCodexTestToken(); + const context = createCodexTestContext(); + const model = { ...createCodexTestModel("https://chatgpt.com/backend-api"), preferWebsockets: false }; + let capturedBody: Record | undefined; + const fetchMock: FetchImpl = async (_input, init) => { + capturedBody = JSON.parse(decodeCodexRequestBody(init?.body)) as Record; + return new Response(createCompletedCodexSse("Hello"), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }; + + const result = await streamSimple(model, context, { + apiKey: token, + fetch: fetchMock, + reasoning: Effort.Medium, + }).result(); + + expect(result.stopReason).toBe("stop"); + expect(capturedBody?.reasoning).toEqual({ effort: "medium" }); + expect(capturedBody?.text).toBeUndefined(); + }); + async function runCodexSseEvents(events: unknown[]) { const token = createCodexTestToken(); const context = createCodexTestContext(); @@ -1316,7 +1343,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { const added = encodeWebSocketMessage({ type: "response.output_item.added", item: { type: "message", id: "msg_ws", role: "assistant", status: "in_progress", content: [] }, @@ -1374,7 +1401,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { this.emitCodexResponse({ messageId: "msg_obs", responseId: "resp_obs", text: "Observed" }); } } @@ -1431,7 +1458,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { this.sendJson({ type: "response.done", response: { @@ -1484,7 +1511,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { this.sendJson({ type: "response.done", response: { @@ -1638,7 +1665,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(data: string): void { + override send(data: string): void { sentRequests.push(JSON.parse(data) as Record); this.emitCodexResponse({ messageId: "msg_lite", responseId: "resp_lite", text: "Hi" }); } @@ -2876,7 +2903,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(data: string): void { + override send(data: string): void { websocketRequestCount += 1; const body: unknown = JSON.parse(data); if (websocketRequestCount === 1) { @@ -3064,7 +3091,7 @@ describe("openai-codex streaming", () => { }); } - send(_data: string): void { + override send(_data: string): void { websocketRequestCount += 1; this.emitCodexResponse({ messageId: `msg_pre_turn_${websocketRequestCount}`, @@ -3184,7 +3211,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(data: string): void { + override send(data: string): void { sentRequests.push(JSON.parse(data) as Record); this.sendJson({ type: "response.output_item.added", @@ -3269,7 +3296,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(data: string): void { + override send(data: string): void { sentRequests.push(JSON.parse(data) as Record); const responseIndex = sentRequests.length; this.emitCodexResponse({ @@ -3409,7 +3436,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(data: string): void { + override send(data: string): void { sentRequests.push(JSON.parse(data) as Record); if (sentRequests.length === 1) { this.sendJson({ @@ -3508,7 +3535,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { this.sendJson({ type: "response.completed", response: { @@ -3564,7 +3591,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { this.#sendCount += 1; if (this.#sendCount === 1) { this.emitCodexResponse({ @@ -3653,7 +3680,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(data: string): void { + override send(data: string): void { sentRequests.push(JSON.parse(data) as Record); const responseIndex = sentRequests.length; this.emitCodexResponse({ @@ -3770,7 +3797,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(data: string): void { + override send(data: string): void { const request = JSON.parse(data) as Record; sentRequests.push(request); const requestIndex = sentRequests.length; @@ -3878,7 +3905,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(data: string): void { + override send(data: string): void { const request = JSON.parse(data) as Record; sentRequests.push(request); const requestIndex = sentRequests.length; @@ -3996,7 +4023,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { this.emitCodexResponse({ messageId: "msg_v2", responseId: "resp_v2", text: "Hello v2" }); } } @@ -4053,7 +4080,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { sendCount += 1; } } @@ -4113,7 +4140,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { sendCount += 1; this.sendJson({ type: "response.output_item.added", @@ -4143,7 +4170,7 @@ describe("openai-codex streaming", () => { }, 2); } - close(): void { + override close(): void { if (interval) clearInterval(interval); super.close(); } @@ -4190,7 +4217,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { sendCount += 1; this.sendJson({ type: "response.output_item.added", @@ -4213,7 +4240,7 @@ describe("openai-codex streaming", () => { } } - close(): void { + override close(): void { closeCount += 1; super.close(); } @@ -4257,7 +4284,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { if (this.#index === 0) { // First attempt: a function call whose arguments are only whitespace. // A completed reasoning item lands in nativeOutputItems before the @@ -4313,7 +4340,7 @@ describe("openai-codex streaming", () => { }); } - close(): void { + override close(): void { closeCount += 1; super.close(); } @@ -4384,7 +4411,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { sendCount += 1; this.sendJson({ type: "response.output_item.added", @@ -4436,7 +4463,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { // Every frame lands in the connection queue synchronously, before the // consumer microtask drains any of them; the close event used to wipe // the queued terminal event and turn success into a transport error. @@ -4479,7 +4506,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { this.sendJson({ type: "response.output_item.added", item: { type: "function_call", id: "fc_limit", call_id: "call_limit", name: "todo", arguments: "" }, @@ -4534,13 +4561,13 @@ describe("openai-codex streaming", () => { this.emit("open", new Event("open")); } - close(): void { + override close(): void { const wasPending = this.readyState === MockWebSocket.CONNECTING; super.close(); if (wasPending) this.emit("close", { code: 1000 } as unknown as Event); } - send(): void { + override send(): void { this.emitCodexResponse({ messageId: "msg_join", responseId: "resp_join", text: "Joined" }); } } @@ -4597,7 +4624,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { sendCount += 1; this.sendJson({ type: "response.output_item.added", @@ -4669,7 +4696,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(data: string): void { + override send(data: string): void { const request = JSON.parse(data) as { type?: string }; const requestType = typeof request.type === "string" ? request.type : ""; sentTypesByConnection[this.#connectionIndex]?.push(requestType); @@ -4796,7 +4823,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { this.sendJson({ type: "response.output_item.added", item: { @@ -4886,7 +4913,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(data: string): void { + override send(data: string): void { this.#sendCount += 1; const request = JSON.parse(data) as { type?: string }; requestTypes.push(typeof request.type === "string" ? request.type : ""); @@ -4979,7 +5006,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(data: string): void { + override send(data: string): void { sendCount += 1; const request = JSON.parse(data) as Record; expect(typeof request.type).toBe("string"); @@ -5272,7 +5299,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(_data: string): void { + override send(_data: string): void { sendCount += 1; if (sendCount === 1) { this.emitCodexResponse({ diff --git a/packages/ai/test/openai-codex-usage.test.ts b/packages/ai/test/openai-codex-usage.test.ts index 56a83e7c4..cf3dddc3a 100644 --- a/packages/ai/test/openai-codex-usage.test.ts +++ b/packages/ai/test/openai-codex-usage.test.ts @@ -67,6 +67,24 @@ describe("openai-codex usage parser", () => { expect(main?.[0].amount.usedFraction).toBeCloseTo(0.04, 5); }); + it("keeps an explicitly allowed Team window usable at 100% reported usage", async () => { + const payload = makePayload(); + payload.plan_type = "team"; + payload.rate_limit.secondary_window.used_percent = 100; + const report = await openaiCodexUsageProvider.fetchUsage( + { + provider: "openai-codex", + credential: { type: "oauth", accessToken: accessTokenFixture, accountId: "acct-1", email: "u@example.com" }, + }, + { fetch: fakeFetch(payload) }, + ); + + const secondary = report?.limits.find(limit => limit.id === "openai-codex:secondary"); + expect(secondary?.amount.usedFraction).toBe(1); + expect(secondary?.status).toBe("warning"); + expect(report?.metadata).toMatchObject({ planType: "team", allowed: true, limitReached: false }); + }); + it("surfaces additional_rate_limits as spark UsageLimit entries the widget can detect", async () => { const report = await openaiCodexUsageProvider.fetchUsage( { diff --git a/packages/ai/test/rate-limit-utils.test.ts b/packages/ai/test/rate-limit-utils.test.ts index 6f25cad55..15e5b9bab 100644 --- a/packages/ai/test/rate-limit-utils.test.ts +++ b/packages/ai/test/rate-limit-utils.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from "bun:test"; import { ProviderHttpError } from "@oh-my-pi/pi-ai/error"; -import { isUsageLimit } from "@oh-my-pi/pi-ai/error/flags"; +import { classify, Flag, is, isUsageLimit, retriable } from "@oh-my-pi/pi-ai/error/flags"; import { calculateRateLimitBackoffMs, + isConcurrencyCapExclusion, isUsageLimitOutcome, isUsageLimitStatus, parseRateLimitReason, @@ -58,6 +59,30 @@ describe("parseRateLimitReason", () => { expect(parseRateLimitReason("Requests per minute limit reached")).toBe("RATE_LIMIT_EXCEEDED"); }); + it("classifies concurrent request caps separately from rate limits and quota exhaustion", () => { + expect(parseRateLimitReason("Number of concurrent requests exceeded")).toBe("CONCURRENT_LIMIT"); + expect(parseRateLimitReason("Maximum concurrent invocation limit reached")).toBe("CONCURRENT_LIMIT"); + expect(parseRateLimitReason("concurrent_limit_exceeded")).toBe("CONCURRENT_LIMIT"); + expect(parseRateLimitReason("concurrent_requests_limit_reached")).toBe("CONCURRENT_LIMIT"); + expect(parseRateLimitReason("concurrency_quota_exceeded")).toBe("CONCURRENT_LIMIT"); + expect(parseRateLimitReason("Too many concurrent requests")).toBe("CONCURRENT_LIMIT"); + expect(parseRateLimitReason("Too many concurrent invocations")).toBe("CONCURRENT_LIMIT"); + expect(parseRateLimitReason("Rate limit reached for gpt-4o")).toBe("RATE_LIMIT_EXCEEDED"); + expect(parseRateLimitReason("Your quota will reset at 07-28")).toBe("QUOTA_EXHAUSTED"); + }); + + // Deterministic 4xx feature rejections worded with bare concurrency nouns + // ("concurrent request/invocation is not supported") must not classify as a + // concurrency cap — doing so would set Flag.Transient and retry the rejection + // instead of surfacing it. A cap needs an explicit limit/quota/exceeded/reached + // signal near "concurrent". + it("does not classify bare concurrency feature rejections as CONCURRENT_LIMIT", () => { + expect(parseRateLimitReason("Concurrent invocation is not supported")).not.toBe("CONCURRENT_LIMIT"); + expect(parseRateLimitReason("Only one concurrent request is supported")).not.toBe("CONCURRENT_LIMIT"); + // The deterministic rejection must surface as a hard error, not be retried. + expect(is(classify("Concurrent invocation is not supported"), Flag.Transient)).toBe(false); + }); + it("classifies overloaded 529 as MODEL_CAPACITY_EXHAUSTED", () => { expect(parseRateLimitReason("Service overloaded 529")).toBe("MODEL_CAPACITY_EXHAUSTED"); }); @@ -266,6 +291,30 @@ describe("isUsageLimitOutcome", () => { expect(isUsageLimitOutcome(429, message)).toBe(true); }); + it("rotates only account-scoped cap 403s and statusless trailers", () => { + const devinTrailer = + "Devin stream error permission_denied: Reached overall message rate limit. Please try again later. Your limit will reset in 13 minutes."; + // HTTP 403 with the account-scoped body rotates. + expect(isUsageLimitOutcome(403, devinTrailer)).toBe(true); + // Devin's Connect trailer carries no HTTP status (a permission_denied + // ValidationError), so it must rotate on an undefined status too — + // otherwise the exhausted credential is retried as a transient failure. + expect(isUsageLimitOutcome(undefined, devinTrailer)).toBe(true); + expect(isUsageLimit(devinTrailer)).toBe(true); + expect(isUsageLimitOutcome(403, "Forbidden")).toBe(false); + }); + + // A statusless per-minute reset-window transient ("Rate limit will reset in + // 30 seconds") is ordinary throttling (RATE_LIMIT_EXCEEDED), not an account + // usage cap. The reset-window alternative is gated on account scope so it stays + // in the backoff lane instead of rotating the credential. + it("does not rotate on a statusless per-minute reset-window transient", () => { + const message = "Rate limit will reset in 30 seconds"; + expect(parseRateLimitReason(message)).toBe("RATE_LIMIT_EXCEEDED"); + expect(isUsageLimitOutcome(undefined, message)).toBe(false); + expect(isUsageLimit(message)).toBe(false); + }); + it("rotates on xAI Grok Build 402 usage-balance exhaustion regardless of status", () => { const message = "402 Grok Build usage balance exhausted"; expect(isUsageLimitOutcome(402, message)).toBe(true); @@ -285,6 +334,59 @@ describe("isUsageLimitOutcome", () => { expect(isUsageLimitOutcome(401, "Invalid API key")).toBe(false); expect(isUsageLimitOutcome(400, "invalid_request_error: model unsupported")).toBe(false); }); + + // Vertex returns "Online prediction concurrent requests quota exceeded" for a + // concurrent-request cap. The generic USAGE_LIMIT_PATTERN matches + // `quota.?exceeded`, but this is a concurrency cap (5s backoff, no rotation), + // not account quota exhaustion. CONCURRENT_LIMIT must take precedence so the + // credential is not burned. + it("does not rotate on Vertex quota-worded concurrency caps", () => { + const message = "Online prediction concurrent requests quota exceeded"; + expect(parseRateLimitReason(message)).toBe("CONCURRENT_LIMIT"); + expect(isUsageLimitOutcome(429, message)).toBe(false); + expect(isUsageLimit(message)).toBe(false); + }); + + it("excludes non-billing concurrency caps from credential rotation", () => { + const message = "concurrent requests limit reached"; + expect(isConcurrencyCapExclusion(403, message)).toBe(true); + expect(isConcurrencyCapExclusion(undefined, message)).toBe(true); + expect(isConcurrencyCapExclusion(402, message)).toBe(false); + expect(isConcurrencyCapExclusion(403, "Forbidden")).toBe(false); + const classified = classify(new ProviderHttpError(message, 403)); + expect(is(classified, Flag.AuthFailed)).toBe(false); + expect(is(classified, Flag.Transient)).toBe(true); + }); + + // The same bare concurrency wording can reach turn recovery without a + // preserved HTTP status (Vertex/Bedrock paths that bypass API-key + // resolution). The body misses TRANSIENT_TRANSPORT_PATTERN, so without an + // explicit Flag.Transient the temporary cap classifies as terminal and is + // never retried. It must stay shed-and-backoff (transient/retriable). + it("keeps statusless concurrency caps transient and retriable", () => { + const message = "Online prediction concurrent requests quota exceeded"; + const id = classify(message); + expect(is(id, Flag.Transient)).toBe(true); + expect(retriable(id)).toBe(true); + }); + + // HTTP 402 is categorically an account-billing cap, so a 402 whose body is + // worded as a concurrency cap still rotates — the billing-cap status wins + // over the concurrency exclusion. The identical concurrency wording on a + // quota-worded 429 stays non-rotatable (5s backoff). This pins the + // 402-billing-cap > concurrency-exclusion precedence in both the rotation + // decision (isUsageLimitOutcome) and the Flag.UsageLimit classification + // (isUsageLimit). + it("rotates on 402 concurrency-worded billing caps but not 429 concurrency caps", () => { + const message = "concurrent requests limit reached"; + expect(parseRateLimitReason(message)).toBe("CONCURRENT_LIMIT"); + // 402 billing cap wins: rotate. + expect(isUsageLimitOutcome(402, message)).toBe(true); + expect(isUsageLimit(Object.assign(new Error(message), { status: 402 }))).toBe(true); + // 429 concurrency cap: shed-and-backoff, do not rotate. + expect(isUsageLimitOutcome(429, message)).toBe(false); + expect(isUsageLimit(Object.assign(new Error(message), { status: 429 }))).toBe(false); + }); }); describe("calculateRateLimitBackoffMs", () => { @@ -295,4 +397,8 @@ describe("calculateRateLimitBackoffMs", () => { expect(ms).toBeLessThanOrEqual(75_000); } }); + + it("returns a short backoff for CONCURRENT_LIMIT", () => { + expect(calculateRateLimitBackoffMs("CONCURRENT_LIMIT")).toBe(5_000); + }); }); diff --git a/packages/ai/test/stream-auth-retry.test.ts b/packages/ai/test/stream-auth-retry.test.ts index 83e06c66d..90e782f35 100644 --- a/packages/ai/test/stream-auth-retry.test.ts +++ b/packages/ai/test/stream-auth-retry.test.ts @@ -120,6 +120,41 @@ describe("streamSimple resolver auth retry", () => { expect((contexts[1]!.error as { status?: number }).status).toBe(401); }); + it("surfaces a 403 concurrency cap for transient backoff without rotating credentials", async () => { + const keys: unknown[] = []; + const contexts: ApiKeyResolveContext[] = []; + const concurrencyCap = Object.assign(new Error("concurrent requests limit reached"), { status: 403 }); + registerCustomApi( + API, + (_model: Model, _context: Context, options?: SimpleStreamOptions) => { + pushKey(keys, options); + const stream = new AssistantMessageEventStream(); + queueMicrotask(() => stream.fail(concurrencyCap)); + return stream; + }, + SOURCE_ID, + ); + + const stream = streamSimple(model(), context, { + apiKey: async ctx => { + contexts.push(ctx); + return ctx.error === undefined ? "old-key" : ctx.lastChance ? "sibling-key" : "refresh-key"; + }, + }); + await expect( + (async () => { + for await (const _event of stream) { + // drain + } + })(), + ).rejects.toBe(concurrencyCap); + + expect(keys).toEqual(["old-key"]); + expect(contexts.map(ctx => ({ lastChance: ctx.lastChance, hasError: ctx.error !== undefined }))).toEqual([ + { lastChance: false, hasError: false }, + ]); + }); + it("buffers the start event and retries on a 401 error event before content", async () => { const keys: unknown[] = []; const eventTypes: string[] = []; diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index bd574f3a8..09170ec15 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,10 +2,51 @@ ## [Unreleased] +### Breaking Changes + +- Renamed `compareVersions` to `compareChangelogEntries` in `@oh-my-pi/pi-coding-agent/utils/changelog`. The function signature and behavior are unchanged; update imports to use the new name. + +### Added + +- Added automatic detection of common Ungoogled Chromium Linux installations for the browser tool. + ### Changed - Reworked the Ctrl+S Agent Hub into a responsive fullscreen roster and selected-agent inspector with aggregate status/usage, per-agent task/model/activity/usage/lineage details, roster and spawn-tree views, stable ordering, bounded large-roster rendering, asynchronous persisted-session discovery, restored task/timestamp metadata for historical agents, and consistent keyboard and mouse navigation. - Restored the legacy project-scoped session directory naming scheme and removed its automatic migration ([#7646](https://github.com/can1357/oh-my-pi/issues/7646)). +- Routed Bun install-cache pruning in `update-cli` through the shared `compareVersions` utility (`@oh-my-pi/pi-utils`), removing a duplicate local comparator that rounded large numeric version identifiers via `Number`. + +### Fixed + +- Retried concurrent-request caps with a short backoff without deleting valid Copilot credentials or rotating through sibling accounts. +- Fixed the default `textVerbosity` setting being forwarded to OpenAI Codex requests unless the user explicitly configures it, preserving Codex's native response-control defaults. ([#4949](https://github.com/can1357/oh-my-pi/issues/4949)) +- Reduced streaming CPU usage by coalescing the cumulative `message_update` deltas of a turn at the event-controller dispatch boundary: at most one streaming-state rebuild runs per ~33ms window instead of one per token, cutting the per-token handler work that dominated the CPU profile of streaming sessions (especially at high token rates) while preserving per-delta speech output. Subscriber dispatch is serialized so a rapid stream tail (`message_update` → `message_end` → `agent_end`) cannot overtake the coalesced flush. ([#7443](https://github.com/can1357/oh-my-pi/issues/7443)) +- Fixed translated MCP importers (Claude Code, Cursor, Gemini CLI, Windsurf, VS Code) silently dropping a server's `enabled: false` flag, so a server disabled at the source config stayed mounted; the flag is now propagated and honored like Codex, OpenCode, and native `mcp.json`. These importers now also load project entries before same-named user entries (matching native/Codex) so a project `enabled: false` suppresses a same-named user server ([#7652](https://github.com/can1357/oh-my-pi/issues/7652)). +- Removed the per-call `model` override from the eval `agent()` helper (all runtimes), completing the earlier task-tool removal (`9f8aa87dbf`). Subagents always use their selected agent's frontmatter model and settings; a legacy `model` argument is silently ignored, so an explicit `model: "default"` can no longer route children onto the parent session model ([#6438](https://github.com/can1357/oh-my-pi/issues/6438)). +- Fixed legacy Pi extension validation rejecting plugins such as `remote-pi` that import the package-root `convertToPng` image helper. ([#7610](https://github.com/can1357/oh-my-pi/issues/7610)) +- Fixed the legacy session-directory migration silently deleting a live session's transcript when its filename collided with an existing entry in the destination: colliding entries are now preserved in place, the legacy directory is only removed when empty, and collisions/migration failures are logged ([#7593](https://github.com/can1357/oh-my-pi/issues/7593)). +- Fixed `PUPPETEER_EXECUTABLE_PATH` being ignored when a system Chrome installation was detected, preventing Windows users from selecting a compatible headless browser for the shared browser daemon ([#7601](https://github.com/can1357/oh-my-pi/issues/7601)). +- Fixed `openai-models-list` discovery ignoring server-advertised input modalities, so custom virtual tier IDs absent from the bundled catalog showed `images: no` even when the `/v1/models` response reported `input: ["text","image"]` ([#7583](https://github.com/can1357/oh-my-pi/issues/7583)). +- Exposed exact source line counts in read results when selector-based reads reach EOF, allowing protocol bridges to distinguish a returned slice from the complete file ([#7590](https://github.com/can1357/oh-my-pi/issues/7590)). +- Fixed `grep`/`glob` silently collapsing a semicolon-delimited `path` list to one literal path when the joined string was too long for the OS to name (`ENAMETOOLONG`) — a list of bare filenames past `NAME_MAX` or absolute paths past `PATH_MAX` failed with `Path not found: ` even though every entry existed. The multipath probe now treats `ENAMETOOLONG` as a definitively non-existent single path so the split proceeds, and `glob` surfaces a clean `Path not found` instead of leaking the raw errno ([#7597](https://github.com/can1357/oh-my-pi/issues/7597)). +- Fixed `--mode json` (and text) print mode truncating a large final record (e.g. a multi-MB `agent_end`) when the process exited before stdout drained, while still exiting 0. Per-event writes are now serialized on their own completion callbacks and shutdown blocks on the last one, so the terminal record is delivered in full ([#7635](https://github.com/can1357/oh-my-pi/issues/7635)). +- Fixed text print mode treating buffered partial responses as replay-unsafe, allowing transient mid-stream connection failures to retry without exposing duplicated output ([#7625](https://github.com/can1357/oh-my-pi/issues/7625)). +- Fixed Hindsight `autoRecall` intermittently not reaching the model: two recall paths shared the `hasRecalledForFirstTurn` flag, and the `agent_start` event path could consume it first and inject only via an unawaited background prompt rebuild that a fast turn outran. `beforeAgentStartPrompt` (awaited before the turn builds) is now the sole injection path ([#7568](https://github.com/can1357/oh-my-pi/issues/7568)). +- Fixed `read memory://` returning a confusing "Unknown memory namespace" error under `memory.backend=hindsight` (Hindsight stores memories server-side and has no `memory://` addressing); the handler now returns a corrective pointer to `recall`/`reflect` so a stray read — steered by the shared `recall` tool description — self-corrects in one turn ([#7587](https://github.com/can1357/oh-my-pi/issues/7587)). +- Fixed extension/custom/hook tool wrappers stripping schema methods off `parameters`: `applyToolProxy` bound every callable property, and binding a schema (a plain function carrying `toJsonSchema`/`assert`) dropped those properties, breaking wire-schema detection and crashing the status-line token estimator with `JSON.stringify(schema) === undefined`. Prototype methods are still bound; own data properties and schema callables now pass through untouched. +- Fixed bug where `agent()` calls in eval cells ignored turn cancellation and continued running indefinitely +- Fixed the built-in `tail` printing `tail: Broken pipe` and failing when a downstream pipeline reader exited early (e.g. `tail -c N file.jsonl | jq …` with jq aborting on a parse error); it now exits silently with 141 (128+SIGPIPE) like a real tail, in every output path including `--follow`. +- Fixed the in-process ps shell builtin rejecting common procps/BSD format specifiers (`ps -o tpgid,...` failed with `unknown output format specifier`); added `tpgid`, `pri`, `flags`, real/effective user and group columns, `wchan`, fault counters, `sz`, and the STAT `+` foreground flag. +- Fixed Herdr rejecting the macOS development launcher because its foreground process was reported as `bun` instead of `omp`. +- Completed usage-aware model fallback across startup, queued turns, same-turn tool continuations, ACP/TUI confirmation cancellation, eligible account reselection, cooldown restoration, and isolated subagent settings so low-usage handoffs remain lossless and cannot consume cancelled queued work. +- Fixed Agent Hub opening and selection becoming O(all rows) on large rosters: row rendering is now lazy around the selected viewport, and observer lookup is O(1) by id instead of copy-sorting every session per row. +- Fixed the bash interceptor blocking `grep`/`cat`/`find` used as a downstream pipeline stage (e.g. `printf 'x\n' | grep x`); a stage consuming piped stdin cannot be replaced by a path-based dedicated tool, so it is no longer matched, while standalone and first-stage searches stay intercepted ([#7496](https://github.com/can1357/oh-my-pi/issues/7496)). +- Fixed floating rejections from cmux browser guest JavaScript terminating the main process and every active session; attributable rejections now fail the browser run as tool errors while unrelated process rejections retain the fatal path ([#7365](https://github.com/can1357/oh-my-pi/issues/7365)). +- Fixed the Windows bash tool silently taking down the whole omp process when a command blocked until its timeout: cancelling a timed-out run walked the spawned child's descendant tree from raw `th32ParentProcessID` links, and a recycled pid matching the harness's stale recorded parent pid could enumerate omp as a false descendant and `TerminateProcess` it, killing the session with no `session_exit` record. Run-cancellation sweeps now refuse to signal the harness or any process collected beneath it, while still reaping the timed-out target when it owns a recycled ancestor pid ([#7452](https://github.com/can1357/oh-my-pi/issues/7452)). +- Fixed the unexpected-stop guard (`features.unexpectedStopDetection`) never firing for thinking-only stops: `isUnexpectedStopCandidate` only counted non-whitespace `text` blocks, so a `stopReason: "stop"` turn whose sole content was a signed `thinking` block (a trapped response or a truncated reasoning fragment from reasoning models) bypassed classification and silently ended the turn mid-task. Such stops are now candidates and are classified on their thinking text ([#7499](https://github.com/can1357/oh-my-pi/issues/7499)). +- Fixed Task cancellation hanging forever when a child ignored abort or stalled during cleanup ([#7483](https://github.com/can1357/oh-my-pi/issues/7483)). +- Fixed LSP diagnostics being dropped when servers normalize file URI percent-encoding or Windows path casing. +- Fixed WSL sessions missing Agent Skills stored in the Windows host profile's `.agents/skills` directory. ([#3779](https://github.com/can1357/oh-my-pi/issues/3779)) ## [17.2.8] - 2026-08-04 @@ -99,14 +140,7 @@ - Fixed plugin installation validation failures for legacy compatibility shims. - Removed hard-coded references to disabled or absent agents in system and tool prompts. - Fixed `omp setup python` to validate the same configured or discovered interpreter used by the Python eval runtime. - - -### Fixed - - Fixed self-update misclassifying glibc Linux hosts with an installed musl loader as musl hosts, which could download an unusable musl binary instead of the glibc release. - -### Fixed - - Fixed a crash where opening the Agent Hub after a resume and moving the selection triggered an unbounded `ExtensionExitError` unhandled-rejection storm and exit 129. The postmortem module bound the native hard-exit at first evaluation; when the bundler deferred that evaluation into a `withHostGuard` window it froze the guard's throwing replacement, poisoning every later signal/fatal exit. The native exit is now resolved per call, and the guard stamps its replacement with the native primitive it shadows so mid-guard signals still exit ([#7393](https://github.com/can1357/oh-my-pi/issues/7393)). ## [17.2.4] - 2026-08-01 diff --git a/packages/coding-agent/scripts/omp b/packages/coding-agent/scripts/omp index ef7a82b96..82721c719 100755 --- a/packages/coding-agent/scripts/omp +++ b/packages/coding-agent/scripts/omp @@ -36,7 +36,16 @@ mkdir -p "$launch_dir" OMP_LAUNCH_CWD=$PWD export OMP_LAUNCH_CWD cd "$launch_dir" +# Herdr 0.7.5 validates the foreground process name. The macOS shell can +# preserve OMP's identity while still running the development CLI through Bun. +run_bun() { + if [ "${HERDR_ENV:-}" = 1 ] && [ "$(uname -s)" = Darwin ]; then + exec -a omp bun "$@" + fi + exec bun "$@" +} + if [ -n "${PI_TIMING:-}" ]; then - exec bun --preload "$preload" --preload "$timing_preload" "$cli" "$@" + run_bun --preload "$preload" --preload "$timing_preload" "$cli" "$@" fi -exec bun --preload "$preload" "$cli" "$@" +run_bun --preload "$preload" "$cli" "$@" diff --git a/packages/coding-agent/src/async/job-manager.ts b/packages/coding-agent/src/async/job-manager.ts index 71d498c01..1fc9e6535 100644 --- a/packages/coding-agent/src/async/job-manager.ts +++ b/packages/coding-agent/src/async/job-manager.ts @@ -94,6 +94,12 @@ export interface AsyncJobDeliveryState { pendingJobIds: string[]; } +export interface AsyncJobReapResult { + settled: boolean; + pendingJobIds: string[]; + completion: Promise; +} + export interface AsyncJobRegisterOptions { id?: string; /** Registry id of the agent that owns this job; used to scope cancelAll. */ @@ -490,6 +496,26 @@ export class AsyncJobManager { } } + /** + * Cancel every job owned by `ownerId`, then wait only until `deadlineAt`. + * The returned completion keeps waiting for actual process settlement when + * the deadline expires, so callers can move that cleanup out of the + * user-visible Task wait without losing ownership of the live work. + */ + async cancelAndReapOwnerJobs(ownerId: string, deadlineAt: number): Promise { + this.cancelAll({ ownerId }); + const timeoutMs = Math.max(0, deadlineAt - Date.now()); + const settled = await this.waitForOwnerJobs(ownerId, { timeoutMs }); + if (settled) { + return { settled: true, pendingJobIds: [], completion: Promise.resolve() }; + } + const pendingJobIds = this.getAllJobs({ ownerId }) + .filter(job => job.status === "running" || job.status === "cancelled") + .map(job => job.id); + const completion = this.waitForOwnerJobs(ownerId).then(() => {}); + return { settled: false, pendingJobIds, completion }; + } + async #waitForAllUntil(deadline: number): Promise { const promises = Array.from(this.#jobs.values()).map(job => job.promise); if (promises.length === 0) return true; diff --git a/packages/coding-agent/src/cli/stats-cli.ts b/packages/coding-agent/src/cli/stats-cli.ts index 31177ab71..84279da3f 100644 --- a/packages/coding-agent/src/cli/stats-cli.ts +++ b/packages/coding-agent/src/cli/stats-cli.ts @@ -136,11 +136,11 @@ export async function runStatsCommand(cmd: StatsCommandArgs): Promise { } // Start the dashboard server - const { port } = await startServer(cmd.port); - console.log(chalk.green(`Dashboard available at: http://localhost:${port}`)); + const { hostname, port } = await startServer(cmd.port); + const url = `http://${hostname}:${port}`; + console.log(chalk.green(`Dashboard available at: ${url}`)); // Open browser - const url = `http://localhost:${port}`; openPath(url); console.log("Press Ctrl+C to stop\n"); diff --git a/packages/coding-agent/src/cli/update-cli.ts b/packages/coding-agent/src/cli/update-cli.ts index f106e72d1..9cfc95413 100644 --- a/packages/coding-agent/src/cli/update-cli.ts +++ b/packages/coding-agent/src/cli/update-cli.ts @@ -10,7 +10,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { Transform } from "node:stream"; import { pipeline } from "node:stream/promises"; -import { $env, $which, APP_NAME, isEnoent, VERSION } from "@oh-my-pi/pi-utils"; +import { $env, $which, APP_NAME, compareVersions, isEnoent, VERSION } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; import chalk from "chalk"; import { theme } from "../modes/theme/theme"; @@ -498,24 +498,6 @@ async function getLatestRelease(): Promise { }; } -/** - * Compare semver versions. Returns: - * - negative if a < b - * - 0 if a == b - * - positive if a > b - */ -function compareVersions(a: string, b: string): number { - const pa = a.split(".").map(Number); - const pb = b.split(".").map(Number); - - for (let i = 0; i < Math.max(pa.length, pb.length); i++) { - const na = pa[i] || 0; - const nb = pb[i] || 0; - if (na !== nb) return na - nb; - } - return 0; -} - interface BunInstallCachePruneResult { scannedPackages: number; removedEntries: number; @@ -532,42 +514,6 @@ function stripBunCacheVersionSuffix(name: string): string { return metadataIndex === -1 ? name : name.slice(0, metadataIndex); } -function compareSemverIdentifier(a: string, b: string): number { - const aNumber = /^\d+$/.test(a); - const bNumber = /^\d+$/.test(b); - if (aNumber && bNumber) return Number(a) - Number(b); - if (aNumber) return -1; - if (bNumber) return 1; - return a.localeCompare(b); -} - -function compareSemverLikeVersions(a: string, b: string): number { - const [aCoreWithPrerelease] = a.split("+", 1); - const [bCoreWithPrerelease] = b.split("+", 1); - const [aCore, aPrerelease] = aCoreWithPrerelease.split("-", 2); - const [bCore, bPrerelease] = bCoreWithPrerelease.split("-", 2); - const aParts = aCore.split("."); - const bParts = bCore.split("."); - for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) { - const diff = Number(aParts[i] ?? 0) - Number(bParts[i] ?? 0); - if (diff !== 0 && Number.isFinite(diff)) return diff; - } - if (!aPrerelease && !bPrerelease) return 0; - if (!aPrerelease) return 1; - if (!bPrerelease) return -1; - const aPrereleaseParts = aPrerelease.split("."); - const bPrereleaseParts = bPrerelease.split("."); - for (let i = 0; i < Math.max(aPrereleaseParts.length, bPrereleaseParts.length); i++) { - const aPart = aPrereleaseParts[i]; - const bPart = bPrereleaseParts[i]; - if (aPart === undefined) return -1; - if (bPart === undefined) return 1; - const diff = compareSemverIdentifier(aPart, bPart); - if (diff !== 0) return diff; - } - return 0; -} - async function readdirIfExists(dir: string): Promise { try { return await fs.promises.readdir(dir, { withFileTypes: true }); @@ -689,7 +635,7 @@ export async function pruneBunInstallCache( scannedPackages++; let latestVersion: string | undefined; for (const version of group.actualDirs.keys()) { - if (!latestVersion || compareSemverLikeVersions(version, latestVersion) > 0) latestVersion = version; + if (!latestVersion || compareVersions(version, latestVersion) > 0) latestVersion = version; } if (!latestVersion) continue; for (const [version, paths] of group.actualDirs) { diff --git a/packages/coding-agent/src/config/config-file.ts b/packages/coding-agent/src/config/config-file.ts index d36916870..87a4cfe2d 100644 --- a/packages/coding-agent/src/config/config-file.ts +++ b/packages/coding-agent/src/config/config-file.ts @@ -109,11 +109,11 @@ export class ConfigError extends Error { this.#message = message; } - get message(): string { + override get message(): string { return this.#message; } - toString(): string { + override toString(): string { return this.message; } } diff --git a/packages/coding-agent/src/config/keybindings.ts b/packages/coding-agent/src/config/keybindings.ts index 6836f1eda..6408cfad9 100644 --- a/packages/coding-agent/src/config/keybindings.ts +++ b/packages/coding-agent/src/config/keybindings.ts @@ -607,12 +607,12 @@ export class KeybindingsManager extends TuiKeybindingsManager { this.setUserBindings(mergeKeybindingsConfig(inheritedConfig, profileConfig)); } - setUserBindings(userBindings: KeybindingsConfig): void { + override setUserBindings(userBindings: KeybindingsConfig): void { this.#userBindings = userBindings; super.setUserBindings(userBindings); } - getKeys(keybinding: Keybinding): KeyId[] { + override getKeys(keybinding: Keybinding): KeyId[] { const keys = super.getKeys(keybinding); const fallbackKey = getFallbackKey(keybinding); if (fallbackKey === undefined || this.#userBindings[keybinding] !== undefined) return keys; @@ -620,7 +620,7 @@ export class KeybindingsManager extends TuiKeybindingsManager { return removeKey(keys, fallbackKey); } - getResolvedBindings(): KeybindingsConfig { + override getResolvedBindings(): KeybindingsConfig { const resolved = super.getResolvedBindings(); resolved[FOLLOW_UP_KEYBINDING] = keyConfigValue(this.getKeys(FOLLOW_UP_KEYBINDING)); return resolved; diff --git a/packages/coding-agent/src/config/model-discovery.ts b/packages/coding-agent/src/config/model-discovery.ts index 7e30f0fd7..8ea4b99b0 100644 --- a/packages/coding-agent/src/config/model-discovery.ts +++ b/packages/coding-agent/src/config/model-discovery.ts @@ -724,6 +724,31 @@ export async function discoverLlamaCppModelRuntimeMetadata( } } +/** + * Read image-input support from an OpenAI-compatible `/v1/models` row. Handles + * direct `input` arrays, Synthetic-style top-level `input_modalities`, and + * OpenRouter-style `architecture.input_modalities`; returns undefined when none + * is present so the bundled reference (or the `["text"]` default) can take over. + */ +function extractOpenAIModelsListInputCapabilities(item: { + input?: unknown; + input_modalities?: unknown; + architecture?: unknown; +}): ("text" | "image")[] | undefined { + const modalities = new Set(); + const collect = (value: unknown): void => { + if (!Array.isArray(value)) return; + for (const entry of value) { + if (typeof entry === "string") modalities.add(entry.toLowerCase()); + } + }; + collect(item.input); + collect(item.input_modalities); + if (isRecord(item.architecture)) collect(item.architecture.input_modalities); + if (modalities.size === 0) return undefined; + return modalities.has("image") ? ["text", "image"] : ["text"]; +} + export async function discoverOpenAIModelsList( providerConfig: DiscoveryProviderConfig, ctx: DiscoveryContext, @@ -752,7 +777,14 @@ export async function discoverOpenAIModelsList( } headers = h; return (await res.json()) as { - data?: Array<{ id?: string; max_model_len?: unknown; context_length?: unknown }>; + data?: Array<{ + id?: string; + max_model_len?: unknown; + context_length?: unknown; + input?: unknown; + input_modalities?: unknown; + architecture?: unknown; + }>; }; }), nativeMetadataPromise, @@ -796,7 +828,9 @@ export async function discoverOpenAIModelsList( baseUrl, reasoning: reference?.reasoning ?? false, thinking: inheritReferenceThinking(undefined, reference, providerConfig.provider), - input: nativeMetadataForModel?.input ?? reference?.input ?? ["text"], + input: nativeMetadataForModel?.input ?? + extractOpenAIModelsListInputCapabilities(item) ?? + reference?.input ?? ["text"], ...(providerConfig.discovery.type === "lm-studio" ? { imageInputDecoder: "stb" as const } : {}), // Proxy/gateway pricing is provider-specific and rarely matches // upstream bundled catalogs, so keep costs local-unknown even diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index 1662b2fed..45a87627b 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -1715,7 +1715,10 @@ export class ModelRegistry { return resolveOllamaModelCacheProviderId(providerConfig.provider, providerConfig.baseUrl); } if (providerConfig.discovery.type === "openai-models-list") { - return `${providerConfig.provider}:openai-models-list-context-v2`; + // context-v3 invalidates rows cached before server-advertised input + // modalities were parsed from `/v1/models`; warm v2 rows pinned + // vision-capable ids at `input: ["text"]` until a forced refresh. + return `${providerConfig.provider}:openai-models-list-context-v3`; } if (providerConfig.discovery.type === "litellm") { // rich-v2 invalidates rows cached before reseller usage-suffix stripping diff --git a/packages/coding-agent/src/discovery/agents.ts b/packages/coding-agent/src/discovery/agents.ts index 4496b87c0..27d5f3880 100644 --- a/packages/coding-agent/src/discovery/agents.ts +++ b/packages/coding-agent/src/discovery/agents.ts @@ -28,9 +28,77 @@ const DISPLAY_NAME = "Agent Dirs (.agent/.agents)"; const PRIORITY = 70; const AGENT_DIR_CANDIDATES = [".agent", ".agents"] as const; -/** User-level paths: ~/.agent/ and ~/.agents/. */ -function getUserPathCandidates(ctx: LoadContext, ...segments: string[]): string[] { - return AGENT_DIR_CANDIDATES.map(baseDir => path.join(ctx.home, baseDir, ...segments)); +interface UserPathCandidateOptions { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + windowsUserProfile?: () => string | undefined; + wslPath?: (windowsPath: string) => string | undefined; +} + +const WINDOWS_DRIVE_PROFILE_PATTERN = /^([A-Za-z]):[\\/](.*)$/; + +function isWsl(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): boolean { + return platform === "linux" && Boolean(env.WSL_DISTRO_NAME || env.WSL_INTEROP); +} + +function convertWindowsPathToDefaultWslMount(windowsPath: string): string | undefined { + const trimmed = windowsPath.trim(); + if (trimmed.length === 0) return undefined; + if (path.isAbsolute(trimmed)) return path.normalize(trimmed); + const match = WINDOWS_DRIVE_PROFILE_PATTERN.exec(trimmed); + if (!match) return undefined; + const [, drive, rest] = match; + const segments = rest.replace(/\\/g, "/").split("/").filter(Boolean); + return path.join("/mnt", drive.toLowerCase(), ...segments); +} + +function resolveWithWslPath(windowsPath: string): string | undefined { + try { + const result = Bun.spawnSync(["wslpath", "-u", windowsPath], { stdout: "pipe", stderr: "ignore" }); + if (result.exitCode !== 0) return undefined; + const resolved = result.stdout.toString().trim(); + return resolved.length > 0 ? resolved : undefined; + } catch { + return undefined; + } +} + +function resolveWindowsUserProfile(): string | undefined { + try { + const result = Bun.spawnSync(["cmd.exe", "/d", "/c", "echo", "%USERPROFILE%"], { + stdout: "pipe", + stderr: "ignore", + }); + if (result.exitCode !== 0) return undefined; + const resolved = result.stdout.toString().trim(); + return resolved.length > 0 && resolved !== "%USERPROFILE%" ? resolved : undefined; + } catch { + return undefined; + } +} + +/** Resolve the Windows host profile home exposed to WSL, if available. */ +export function getWslWindowsHomeCandidate(options: UserPathCandidateOptions = {}): string | undefined { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + if (!isWsl(platform, env)) return undefined; + const userProfile = env.USERPROFILE ?? (options.windowsUserProfile ?? resolveWindowsUserProfile)(); + if (!userProfile) return undefined; + return (options.wslPath ?? resolveWithWslPath)(userProfile) ?? convertWindowsPathToDefaultWslMount(userProfile); +} + +function getUserHomeCandidates(ctx: LoadContext): string[] { + const homes = [ctx.home]; + const wslHome = getWslWindowsHomeCandidate(); + if (wslHome && !homes.includes(wslHome)) homes.push(wslHome); + return homes; +} + +/** User-level paths: ~/.agent[s]/, plus the Windows host profile under WSL. */ +export function getUserPathCandidates(ctx: LoadContext, ...segments: string[]): string[] { + return getUserHomeCandidates(ctx).flatMap(home => + AGENT_DIR_CANDIDATES.map(baseDir => path.join(home, baseDir, ...segments)), + ); } /** diff --git a/packages/coding-agent/src/discovery/claude.ts b/packages/coding-agent/src/discovery/claude.ts index ab4637cf1..df7f16881 100644 --- a/packages/coding-agent/src/discovery/claude.ts +++ b/packages/coding-agent/src/discovery/claude.ts @@ -90,6 +90,7 @@ async function loadMCPServers(ctx: LoadContext): Promise> const serverConfig = config as Record; return { name, + enabled: typeof serverConfig.enabled === "boolean" ? serverConfig.enabled : undefined, timeout: typeof serverConfig.timeout === "number" ? serverConfig.timeout : undefined, command: serverConfig.command as string | undefined, args: serverConfig.args as string[] | undefined, @@ -102,17 +103,19 @@ async function loadMCPServers(ctx: LoadContext): Promise> }); }; - for (let i = 0; i < userPaths.length; i++) { - const servers = parseMcpServers(contents[i], userPaths[i].path, userPaths[i].level); + // Load project entries before user entries so a project `enabled: false` + // claims its dedupe key before a same-named user server can survive (#7654). + const projectOffset = userPaths.length; + for (let i = 0; i < projectPaths.length; i++) { + const servers = parseMcpServers(contents[projectOffset + i], projectPaths[i].path, projectPaths[i].level); if (servers.length > 0) { items.push(...servers); break; } } - const projectOffset = userPaths.length; - for (let i = 0; i < projectPaths.length; i++) { - const servers = parseMcpServers(contents[projectOffset + i], projectPaths[i].path, projectPaths[i].level); + for (let i = 0; i < userPaths.length; i++) { + const servers = parseMcpServers(contents[i], userPaths[i].path, userPaths[i].level); if (servers.length > 0) { items.push(...servers); break; diff --git a/packages/coding-agent/src/discovery/cursor.ts b/packages/coding-agent/src/discovery/cursor.ts index 2dcc23e1c..840f8d478 100644 --- a/packages/coding-agent/src/discovery/cursor.ts +++ b/packages/coding-agent/src/discovery/cursor.ts @@ -57,6 +57,7 @@ function parseMCPServers( const serverConfig = config as Record; items.push({ name, + enabled: typeof serverConfig.enabled === "boolean" ? serverConfig.enabled : undefined, command: serverConfig.command as string | undefined, args: serverConfig.args as string[] | undefined, env: serverConfig.env as Record | undefined, @@ -86,15 +87,17 @@ async function loadMCPServers(ctx: LoadContext): Promise> const projectContentPromise = projectPath ? readFile(projectPath) : Promise.resolve(null); - if (userContent && userPath) { - const result = parseMCPServers(userContent, userPath, "user"); + // Load project entries before user entries so a project `enabled: false` + // claims its dedupe key before a same-named user server can survive (#7654). + const projectContent = await projectContentPromise; + if (projectContent && projectPath) { + const result = parseMCPServers(projectContent, projectPath, "project"); items.push(...result.items); if (result.warning) warnings.push(result.warning); } - const projectContent = await projectContentPromise; - if (projectContent && projectPath) { - const result = parseMCPServers(projectContent, projectPath, "project"); + if (userContent && userPath) { + const result = parseMCPServers(userContent, userPath, "user"); items.push(...result.items); if (result.warning) warnings.push(result.warning); } diff --git a/packages/coding-agent/src/discovery/gemini.ts b/packages/coding-agent/src/discovery/gemini.ts index c7333ba20..dcb64ec24 100644 --- a/packages/coding-agent/src/discovery/gemini.ts +++ b/packages/coding-agent/src/discovery/gemini.ts @@ -48,14 +48,8 @@ async function loadMCPServers(ctx: LoadContext): Promise> const items: MCPServer[] = []; const warnings: string[] = []; - // User-level: ~/.gemini/settings.json → mcpServers - const userPath = getUserPath(ctx, "gemini", "settings.json"); - if (userPath) { - const result = await loadMCPFromSettings(ctx, userPath, "user"); - items.push(...result.items); - if (result.warnings) warnings.push(...result.warnings); - } - + // Load project entries before user entries so a project `enabled: false` + // claims its dedupe key before a same-named user server can survive (#7654). // Project-level: .gemini/settings.json → mcpServers const projectPath = getProjectPath(ctx, "gemini", "settings.json"); if (projectPath) { @@ -64,6 +58,14 @@ async function loadMCPServers(ctx: LoadContext): Promise> if (result.warnings) warnings.push(...result.warnings); } + // User-level: ~/.gemini/settings.json → mcpServers + const userPath = getUserPath(ctx, "gemini", "settings.json"); + if (userPath) { + const result = await loadMCPFromSettings(ctx, userPath, "user"); + items.push(...result.items); + if (result.warnings) warnings.push(...result.warnings); + } + return { items, warnings }; } @@ -102,6 +104,7 @@ async function loadMCPFromSettings( items.push({ name, + enabled: typeof raw.enabled === "boolean" ? raw.enabled : undefined, command: typeof raw.command === "string" ? raw.command : undefined, args: Array.isArray(raw.args) ? (raw.args as string[]) : undefined, env: raw.env && typeof raw.env === "object" ? (raw.env as Record) : undefined, diff --git a/packages/coding-agent/src/discovery/vscode.ts b/packages/coding-agent/src/discovery/vscode.ts index 9239e953b..36116b3a1 100644 --- a/packages/coding-agent/src/discovery/vscode.ts +++ b/packages/coding-agent/src/discovery/vscode.ts @@ -83,6 +83,7 @@ async function loadMCPConfig( const server: MCPServer = { name, + enabled: typeof expanded.enabled === "boolean" ? expanded.enabled : undefined, command: typeof expanded.command === "string" ? expanded.command : undefined, args: Array.isArray(expanded.args) ? (expanded.args as string[]) : undefined, env: expanded.env && typeof expanded.env === "object" ? (expanded.env as Record) : undefined, diff --git a/packages/coding-agent/src/discovery/windsurf.ts b/packages/coding-agent/src/discovery/windsurf.ts index 0034467e1..2672cb0e6 100644 --- a/packages/coding-agent/src/discovery/windsurf.ts +++ b/packages/coding-agent/src/discovery/windsurf.ts @@ -48,6 +48,7 @@ function parseServerConfig( return { server: { name, + enabled: typeof server.enabled === "boolean" ? server.enabled : undefined, command: server.command as string | undefined, args: server.args as string[] | undefined, env: server.env as Record | undefined, @@ -71,10 +72,11 @@ async function loadMCPServers(ctx: LoadContext): Promise> ]); const projectContent = projectPath ? await readFile(projectPath) : null; - + // Load project entries before user entries so a project `enabled: false` + // claims its dedupe key before a same-named user server can survive (#7654). const configs: Array<{ content: string | null; path: string | null; scope: "user" | "project" }> = [ - { content: userContent, path: userPath, scope: "user" }, { content: projectContent, path: projectPath, scope: "project" }, + { content: userContent, path: userPath, scope: "user" }, ]; for (const { content, path, scope } of configs) { diff --git a/packages/coding-agent/src/edit/hashline/filesystem.ts b/packages/coding-agent/src/edit/hashline/filesystem.ts index b8743be2e..10dbdd283 100644 --- a/packages/coding-agent/src/edit/hashline/filesystem.ts +++ b/packages/coding-agent/src/edit/hashline/filesystem.ts @@ -87,11 +87,11 @@ export class HashlineFilesystem extends Filesystem { return resolvePlanPath(this.session, relativePath); } - canonicalPath(relativePath: string): string { + override canonicalPath(relativePath: string): string { return canonicalSnapshotKey(this.resolveAbsolute(relativePath)); } - allowTagPathRecovery(authoredPath: string, resolvedPath: string): boolean { + override allowTagPathRecovery(authoredPath: string, resolvedPath: string): boolean { // Internal-URL authored targets (`local://`, `vault://`, …) are approved // at the lower "read" privilege; never let one redirect onto a "write". if (isInternalUrlPath(authoredPath)) return false; @@ -125,7 +125,7 @@ export class HashlineFilesystem extends Filesystem { return content; } - async readBinary(relativePath: string): Promise { + override async readBinary(relativePath: string): Promise { const absolutePath = this.resolveAbsolute(relativePath); if (isNotebookPath(absolutePath)) return undefined; try { @@ -136,7 +136,7 @@ export class HashlineFilesystem extends Filesystem { } } - async preflightWrite(relativePath: string, options?: PreflightWriteOptions): Promise { + override async preflightWrite(relativePath: string, options?: PreflightWriteOptions): Promise { const fileOp = options?.fileOp; if (fileOp?.kind === "rem") { enforcePlanModeWrite(this.session, relativePath, { op: "delete" }); @@ -149,7 +149,7 @@ export class HashlineFilesystem extends Filesystem { enforcePlanModeWrite(this.session, relativePath, { op: "update" }); } - async delete(relativePath: string): Promise { + override async delete(relativePath: string): Promise { enforcePlanModeWrite(this.session, relativePath, { op: "delete" }); const absolutePath = this.resolveAbsolute(relativePath); try { @@ -168,7 +168,7 @@ export class HashlineFilesystem extends Filesystem { invalidateFsScanAfterWrite(absolutePath); } - async move(fromRelative: string, toRelative: string, content?: string): Promise { + override async move(fromRelative: string, toRelative: string, content?: string): Promise { enforcePlanModeWrite(this.session, fromRelative, { op: "update", move: toRelative }); const fromAbsolute = this.resolveAbsolute(fromRelative); const toAbsolute = this.resolveAbsolute(toRelative); @@ -240,7 +240,7 @@ export class HashlineFilesystem extends Filesystem { return { text: content }; } - async exists(relativePath: string): Promise { + override async exists(relativePath: string): Promise { const absolutePath = this.resolveAbsolute(relativePath); return Bun.file(absolutePath).exists(); } diff --git a/packages/coding-agent/src/eval/agent-bridge.ts b/packages/coding-agent/src/eval/agent-bridge.ts index 07483da39..296e7726b 100644 --- a/packages/coding-agent/src/eval/agent-bridge.ts +++ b/packages/coding-agent/src/eval/agent-bridge.ts @@ -23,7 +23,6 @@ export const EVAL_AGENT_BRIDGE_NAME = "__agent__"; const agentArgsSchema = type({ prompt: "string>0", "agent?": "string>0", - "model?": "string>0|string>0[]", "label?": "string", "schema?": "unknown", "schemaMode?": "'permissive' | 'strict'", @@ -31,12 +30,12 @@ const agentArgsSchema = type({ "apply?": "boolean", "merge?": "boolean", "handle?": "boolean", + "+": "delete", }); interface EvalAgentArgs { prompt: string; agent?: string; - model?: string | string[]; label?: string; schema?: unknown; schemaMode?: StructuredSubagentSchemaMode; @@ -148,7 +147,6 @@ export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOption invocationKind: "eval", assignment: parsed.prompt, ...(parsed.agent !== undefined ? { agent: parsed.agent } : {}), - ...(parsed.model !== undefined ? { model: parsed.model } : {}), ...(Object.hasOwn(parsed, "schema") ? { outputSchema: parsed.schema } : {}), ...(parsed.schemaMode !== undefined ? { schemaMode: parsed.schemaMode } : {}), ...(parsed.label !== undefined ? { identity: { label: parsed.label } } : {}), diff --git a/packages/coding-agent/src/eval/jl/prelude.jl b/packages/coding-agent/src/eval/jl/prelude.jl index 13dcfd9c7..1adcdbcf7 100644 --- a/packages/coding-agent/src/eval/jl/prelude.jl +++ b/packages/coding-agent/src/eval/jl/prelude.jl @@ -519,14 +519,11 @@ function completion(prompt::String; model="default", system=nothing, schema=noth return schema === nothing ? text : Main.json_parse(string(text)) end -function agent(prompt::String; agent="task", model=nothing, label=nothing, schema=nothing, schema_mode=nothing, isolated=nothing, apply=nothing, merge=nothing, handle=false, kwargs...) +function agent(prompt::String; agent="task", label=nothing, schema=nothing, schema_mode=nothing, isolated=nothing, apply=nothing, merge=nothing, handle=false, kwargs...) args_dict = Dict{String, Any}("prompt" => prompt) if agent !== nothing args_dict["agent"] = agent end - if model !== nothing - args_dict["model"] = model - end if label !== nothing args_dict["label"] = label end @@ -545,6 +542,9 @@ function agent(prompt::String; agent="task", model=nothing, label=nothing, schem if merge !== nothing args_dict["merge"] = Bool(merge) end + if haskey(kwargs, :model) + error("agent() no longer accepts a per-call model override; the selected agent's frontmatter model is used") + end handle_result = handle for (k, v) in kwargs args_dict[string(k)] = v diff --git a/packages/coding-agent/src/eval/js/shared/prelude.txt b/packages/coding-agent/src/eval/js/shared/prelude.txt index c29df1c9c..9acdf51a5 100644 --- a/packages/coding-agent/src/eval/js/shared/prelude.txt +++ b/packages/coding-agent/src/eval/js/shared/prelude.txt @@ -104,8 +104,8 @@ if (!globalThis.__omp_js_prelude_loaded__) { "agent", opts, rest, - ["agent", "model", "label", "schema", "isolated", "apply", "merge", "schemaMode"], - "{ agent, model, label, schema, isolated, apply, merge, schemaMode, handle }", + ["agent", "label", "schema", "isolated", "apply", "merge", "schemaMode"], + "{ agent, label, schema, isolated, apply, merge, schemaMode, handle }", ); const { handle, ...callArgs } = o; const res = await globalThis.__omp_call_tool__("__agent__", { prompt, ...callArgs, handle: Boolean(handle) }); diff --git a/packages/coding-agent/src/eval/py/prelude.py b/packages/coding-agent/src/eval/py/prelude.py index 8d6c8fd51..491eac219 100644 --- a/packages/coding-agent/src/eval/py/prelude.py +++ b/packages/coding-agent/src/eval/py/prelude.py @@ -488,7 +488,6 @@ if "__omp_prelude_loaded__" not in globals(): prompt, *, agent="task", - model=None, label=None, schema=None, schema_mode=None, @@ -506,8 +505,6 @@ if "__omp_prelude_loaded__" not in globals(): args = {"prompt": prompt} if agent is not None: args["agent"] = agent - if model is not None: - args["model"] = model if label is not None: args["label"] = label if schema is not None: diff --git a/packages/coding-agent/src/eval/rb/prelude.rb b/packages/coding-agent/src/eval/rb/prelude.rb index ddb8991ae..c6ae9c321 100644 --- a/packages/coding-agent/src/eval/rb/prelude.rb +++ b/packages/coding-agent/src/eval/rb/prelude.rb @@ -392,10 +392,9 @@ unless defined?($__omp_prelude_loaded) && $__omp_prelude_loaded schema.nil? ? text : JSON.parse(text) end - def agent(prompt, agent: "task", model: nil, label: nil, schema: nil, schema_mode: nil, isolated: nil, apply: nil, merge: nil, handle: false) + def agent(prompt, agent: "task", label: nil, schema: nil, schema_mode: nil, isolated: nil, apply: nil, merge: nil, handle: false) args = { "prompt" => prompt } args["agent"] = agent unless agent.nil? - args["model"] = model unless model.nil? args["label"] = label unless label.nil? args["schema"] = schema unless schema.nil? args["schemaMode"] = schema_mode unless schema_mode.nil? diff --git a/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts b/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts index 064d79dc4..30f483184 100644 --- a/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts +++ b/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts @@ -54,6 +54,7 @@ import { ReadTool } from "../tools/read"; import { formatBytes } from "../tools/render-utils"; import { WriteTool } from "../tools/write"; import { EventBus } from "../utils/event-bus"; +import { convertImageToPng } from "../utils/image-loading"; import { discoverExtensionPaths, loadExtensionFromFactory, loadExtensions } from "./extensions"; import { ExtensionRuntime } from "./extensions/loader"; import type { ExtensionFactory, ToolDefinition } from "./extensions/types"; @@ -384,6 +385,28 @@ async function executeLegacyBashOperations( } } +/** + * Convert an image attachment to PNG using the legacy package-root contract. + * + * Invalid or unsupported image data returns `null`, matching Pi's historical + * helper instead of surfacing Bun's decoder error to extensions. + */ +export async function convertToPng( + base64Data: string, + mimeType: string, +): Promise<{ data: string; mimeType: string } | null> { + if (mimeType === "image/png") { + return { data: base64Data, mimeType }; + } + + try { + const converted = await convertImageToPng({ type: "image", data: base64Data, mimeType }); + return { data: converted.data, mimeType: converted.mimeType }; + } catch { + return null; + } +} + /** Format the active shortcut for legacy extensions that render keybinding hints. */ export function keyText(action: Keybinding): string { return formatKeyHints(getKeybindings().getKeys(action)); diff --git a/packages/coding-agent/src/hindsight/state.ts b/packages/coding-agent/src/hindsight/state.ts index ae39a90e1..80fa6036d 100644 --- a/packages/coding-agent/src/hindsight/state.ts +++ b/packages/coding-agent/src/hindsight/state.ts @@ -411,24 +411,6 @@ export class HindsightSessionState { } } - async maybeRecallOnAgentStart(): Promise { - if (!this.config.autoRecall || this.hasRecalledForFirstTurn) return; - const messages = extractMessages(this.session.sessionManager); - const lastUser = messages.findLast(m => m.role === "user"); - if (!lastUser) return; - - const query = composeRecallQuery(lastUser.content, messages, this.config.recallContextTurns); - const truncated = truncateRecallQuery(query, lastUser.content, this.config.recallMaxQueryChars); - const { context, ok } = await this.recallForContext(truncated); - if (!ok) return; - - this.hasRecalledForFirstTurn = true; - if (!context) return; - - this.lastRecallSnippet = context; - await this.#refreshBaseSystemPromptAfter("recall"); - } - async beforeAgentStartPrompt(promptText: string): Promise { if (this.config.mentalModelsEnabled && this.mentalModelsLoadPromise && this.mentalModelsLoadedAt === undefined) { await Promise.race([this.mentalModelsLoadPromise, Bun.sleep(MENTAL_MODEL_FIRST_TURN_DEADLINE_MS)]); @@ -509,9 +491,7 @@ export class HindsightSessionState { attachSessionListeners(): void { this.unsubscribe?.(); this.unsubscribe = this.session.subscribe(event => { - if (event.type === "agent_start") { - void this.maybeRecallOnAgentStart(); - } else if (event.type === "agent_end") { + if (event.type === "agent_end") { void this.maybeRetainOnAgentEnd(); // Drain any queued tool-initiated retain calls now that the turn // is settled. The queue is also debounced/size-bounded, but @@ -540,7 +520,7 @@ export class HindsightSessionState { this.retainQueue.dispose(); } - async #refreshBaseSystemPromptAfter(reason: "recall" | "MM load" | "MM reload" | "MM TTL reload"): Promise { + async #refreshBaseSystemPromptAfter(reason: "MM load" | "MM reload" | "MM TTL reload"): Promise { try { await this.session.refreshBaseSystemPrompt(); } catch (err) { diff --git a/packages/coding-agent/src/internal-urls/memory-protocol.ts b/packages/coding-agent/src/internal-urls/memory-protocol.ts index 6b8915957..36e8315bb 100644 --- a/packages/coding-agent/src/internal-urls/memory-protocol.ts +++ b/packages/coding-agent/src/internal-urls/memory-protocol.ts @@ -222,6 +222,18 @@ function mnemopiSessionStatesFromRegistry(): MnemopiSessionState[] { return states; } +function memoryBackendFromContext(context?: ResolveContext): string | undefined { + if (!context?.settings || typeof context.settings !== "object") return undefined; + try { + const get = Reflect.get(context.settings, "get"); + if (typeof get !== "function") return undefined; + const backend = Reflect.apply(get, context.settings, ["memory.backend"]); + return typeof backend === "string" ? backend : undefined; + } catch { + return undefined; + } +} + /** * Look up a mnemopi memory row by id across every live session's scoped banks. * First hit wins; returns `null` when the id is not stored anywhere in scope. @@ -290,6 +302,23 @@ export class MemoryProtocolHandler implements ProtocolHandler { // clipped recall preview before overwriting it (issue #4443). if (namespace !== MEMORY_NAMESPACE) { const mnemopiStates = mnemopiSessionStatesFromRegistry(); + const hindsightActive = + memoryBackendFromContext(context) === "hindsight" || + (mnemopiStates.length === 0 && + AgentRegistry.global() + .list() + .some(ref => ref.session?.getHindsightSessionState?.())); + if (hindsightActive) { + // Hindsight keeps memories server-side and exposes no + // `memory://` addressing, yet the shared `recall` tool + // description still steers a follow-up `read memory://`. + // Return a corrective pointer so that stray read self-corrects in + // one turn instead of derailing on the generic namespace error + // (issue #7587). + throw new Error( + "Hindsight memories are not addressable via memory://. Recall results are final — use `recall` to search or `reflect` to synthesize. `read memory://` is only available with memory.backend=mnemopi.", + ); + } if (mnemopiStates.length === 0) { throw new Error( `Unknown memory namespace: ${namespace}. Supported: ${MEMORY_NAMESPACE} (file-backed memory summary), or a mnemopi memory id when memory.backend=mnemopi is active.`, diff --git a/packages/coding-agent/src/lsp/client.ts b/packages/coding-agent/src/lsp/client.ts index f4df6cb7b..a90b4fd02 100644 --- a/packages/coding-agent/src/lsp/client.ts +++ b/packages/coding-agent/src/lsp/client.ts @@ -17,7 +17,7 @@ import type { ServerConfig, WorkspaceEdit, } from "./types"; -import { detectLanguageId, fileToUri } from "./utils"; +import { detectLanguageId, EquivalentUriMap, fileToUri } from "./utils"; // ============================================================================= // Client State @@ -787,7 +787,7 @@ export async function getOrCreateClient( proc, config, requestId: 0, - diagnostics: new Map(), + diagnostics: new EquivalentUriMap(), diagnosticsVersion: 0, dynamicCapabilityRegistrations: new Map(), openFiles: new Map(), diff --git a/packages/coding-agent/src/lsp/utils.ts b/packages/coding-agent/src/lsp/utils.ts index d9eeac7ac..d96f7f97b 100644 --- a/packages/coding-agent/src/lsp/utils.ts +++ b/packages/coding-agent/src/lsp/utils.ts @@ -78,6 +78,35 @@ function laxUriToFile(uri: string): string { return filePath; } +/** Map that treats equivalent file URI spellings as the same key. */ +export class EquivalentUriMap extends Map { + #key(uri: string): string { + if (!uri.startsWith("file://")) return uri; + const filePath = path.normalize(uriToFile(uri)); + return process.platform === "win32" ? filePath.toLowerCase() : filePath; + } + + override delete(uri: string): boolean { + const key = this.#key(uri); + return super.delete(key); + } + + override get(uri: string): Value | undefined { + const key = this.#key(uri); + return super.get(key); + } + + override has(uri: string): boolean { + const key = this.#key(uri); + return super.has(key); + } + + override set(uri: string, value: Value): this { + const key = this.#key(uri); + return super.set(key, value); + } +} + // ============================================================================= // Diagnostic Formatting // ============================================================================= diff --git a/packages/coding-agent/src/modes/acp/acp-agent.ts b/packages/coding-agent/src/modes/acp/acp-agent.ts index 8ee0db5ee..d3d480564 100644 --- a/packages/coding-agent/src/modes/acp/acp-agent.ts +++ b/packages/coding-agent/src/modes/acp/acp-agent.ts @@ -2312,7 +2312,7 @@ export class AcpAgent implements Agent { this.#clientCapabilities, ); if (this.#clientCapabilities?.elicitation?.form != null) { - record.session.setUsageFallbackConfirmer(confirmation => { + record.session.setUsageFallbackConfirmer((confirmation, signal) => { const reserve = confirmation.remainingPercent === undefined ? "inside the configured reserve margin" @@ -2320,6 +2320,7 @@ export class AcpAgent implements Agent { return uiContext.confirm( "Coding-plan reserve reached", `${confirmation.from} has ${reserve}. Switch to ${confirmation.to}? Choose No to keep using the current plan.`, + { signal }, ); }); } diff --git a/packages/coding-agent/src/modes/components/agent-hub.ts b/packages/coding-agent/src/modes/components/agent-hub.ts index 06dbdfd76..e0ff47538 100644 --- a/packages/coding-agent/src/modes/components/agent-hub.ts +++ b/packages/coding-agent/src/modes/components/agent-hub.ts @@ -162,6 +162,7 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou // Table state #rows: AgentRef[] = []; + #statusCounts: Record = { running: 0, idle: 0, parked: 0, aborted: 0 }; #selectedRow = 0; #hoveredRow: number | null = null; /** Per-render screen-line to agent-row map, shared by click and hover routing. */ @@ -189,7 +190,6 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou reportedAgents: 0, activeDurationAgents: 0, }; - #statusCounts: Record = { running: 0, idle: 0, parked: 0, aborted: 0 }; #childrenByParent = new Map(); /** Transcript-derived fallback stats are sampled only on the bounded age cadence. */ #sessionMetrics = new WeakMap(); @@ -283,7 +283,7 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou } /** Tear down every subscription and timer. Called by the overlay owner on close. */ - dispose(): void { + override dispose(): void { if (this.#disposed) return; this.#disposed = true; for (const unsubscribe of this.#unsubscribers.splice(0)) unsubscribe(); @@ -607,7 +607,7 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou #renderRosterWindow( width: number, budget: number, - observedById: ReadonlyMap, + _observedById: ReadonlyMap, ): RosterRender { const lines: string[] = []; const hitRows: Array = []; @@ -619,7 +619,7 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou this.#rows[index], index === this.#selectedRow, width, - observedById.get(this.#rows[index].id), + this.#observableFor(this.#rows[index].id), index === this.#hoveredRow, ); rendered.set(index, entry); @@ -749,14 +749,18 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou this.#hasFallbackLiveSessions = result.hasFallbackLiveSessions; } + #observableFor(id: string): ObservableSession | undefined { + return this.#observedById.get(id) ?? this.#observers.getSession(id); + } + #renderDetailPanel( ref: AgentRef | undefined, width: number, rows: number, - observedById: ReadonlyMap, + _observedById: ReadonlyMap, ): string[] { if (!ref) return [theme.fg("dim", "Select an agent to inspect"), ...Array.from({ length: rows - 1 }, () => "")]; - const observed = observedById.get(ref.id); + const observed = this.#observableFor(ref.id); const progress = observed?.progress; const metrics = this.#metricsFor(ref, observed); const children = this.#childrenByParent.get(ref.id) ?? []; diff --git a/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts b/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts index baabe2bb2..0b8b8c428 100644 --- a/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts +++ b/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts @@ -613,9 +613,7 @@ export class AgentTranscriptViewer implements Component { } #statsLine(): string { - const observed: ObservableSession | undefined = this.deps.observers - ?.getSessions() - .find(s => s.id === this.deps.agentId); + const observed: ObservableSession | undefined = this.deps.observers?.getSession(this.deps.agentId); const progress = observed?.progress; if (!progress) return ""; const stats: string[] = []; diff --git a/packages/coding-agent/src/modes/components/bordered-loader.ts b/packages/coding-agent/src/modes/components/bordered-loader.ts index a3aaa81bf..861cbbf54 100644 --- a/packages/coding-agent/src/modes/components/bordered-loader.ts +++ b/packages/coding-agent/src/modes/components/bordered-loader.ts @@ -35,7 +35,7 @@ export class BorderedLoader extends Container { this.#loader.handleInput(data); } - dispose(): void { + override dispose(): void { this.#loader.dispose(); } } diff --git a/packages/coding-agent/src/modes/components/custom-editor.ts b/packages/coding-agent/src/modes/components/custom-editor.ts index 315c58245..9110fc6fe 100644 --- a/packages/coding-agent/src/modes/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/components/custom-editor.ts @@ -466,7 +466,7 @@ export class CustomEditor extends Editor { /** Decorate magic keywords, attachments, and the queue-composer header/list markers. * Queue shorthand reserves its first logical line as a dim `Queueing` label; sequential * item markers use the accent color so separate follow-ups remain visible while composing. */ - decorateText = (text: string): string => { + override decorateText = (text: string): string => { const editorText = this.getText(); const animated = this.focused && this.#shimmerEnabled() && hasMagicKeyword(editorText); const phase = animated ? (Date.now() % CustomEditor.SHIMMER_PERIOD_MS) / CustomEditor.SHIMMER_PERIOD_MS : 0; @@ -775,7 +775,7 @@ export class CustomEditor extends Editor { void promise.then(this.#onPasteSettled, this.#onPasteSettled); } - handleInput(data: string): void { + override handleInput(data: string): void { // Serialize behind any in-flight async paste so a trailing Enter / follow-up key can't // submit before the clipboard image reaches `pendingImages` (Codex PR #3602 review). if (this.#pasteInFlight > 0) { diff --git a/packages/coding-agent/src/modes/components/hook-input.ts b/packages/coding-agent/src/modes/components/hook-input.ts index e1fc3a930..203c13b1f 100644 --- a/packages/coding-agent/src/modes/components/hook-input.ts +++ b/packages/coding-agent/src/modes/components/hook-input.ts @@ -81,7 +81,7 @@ export class HookInputComponent extends Container { this.#input.pasteText(text); } - dispose(): void { + override dispose(): void { this.#countdown?.dispose(); } } diff --git a/packages/coding-agent/src/modes/components/hook-selector.ts b/packages/coding-agent/src/modes/components/hook-selector.ts index fa91932ef..2833cb99a 100644 --- a/packages/coding-agent/src/modes/components/hook-selector.ts +++ b/packages/coding-agent/src/modes/components/hook-selector.ts @@ -135,7 +135,7 @@ class OutlinedList extends Container { this.invalidate(); } - render(width: number): readonly string[] { + override render(width: number): readonly string[] { const borderColor = (text: string) => theme.fg("border", text); const horizontal = borderColor(theme.boxRound.horizontal.repeat(Math.max(1, width))); const innerWidth = Math.max(1, width - 2); @@ -685,7 +685,7 @@ export class HookSelectorComponent extends Container { return super.render(renderWidth); } - dispose(): void { + override dispose(): void { this.#countdown?.dispose(); } } diff --git a/packages/coding-agent/src/modes/components/session-selector.ts b/packages/coding-agent/src/modes/components/session-selector.ts index e0d555882..ccad9309c 100644 --- a/packages/coding-agent/src/modes/components/session-selector.ts +++ b/packages/coding-agent/src/modes/components/session-selector.ts @@ -905,7 +905,7 @@ export class SessionSelectorComponent extends Container { * is mounted the list is detached from the child tree, so Container's * child-walking dispose would miss its pending history-merge timer. */ - dispose(): void { + override dispose(): void { this.#sessionList.dispose(); super.dispose(); } @@ -971,7 +971,7 @@ export class SessionSelectorComponent extends Container { * footer is always visible and never drifts as the list window resizes. The * in-editor selector just appends the footer directly. */ - render(width: number): readonly string[] { + override render(width: number): readonly string[] { const lines: string[] = []; for (const child of this.children) { const childLines = child.render(width); diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index 242196fae..0be56fdaf 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -170,6 +170,26 @@ export class EventController { #prevHideThinking = false; #handlers: AgentSessionEventHandlers; #terminalProgressActive = false; + // Coalescing window for `message_update` events at the subscription boundary. + // `message_update` carries the CUMULATIVE assistant message (every update + // re-lists all content blocks), so when a burst of deltas arrives faster than + // this window only the latest snapshot needs to rebuild streaming state — the + // intermediate rebuilds are redundant work. The TUI already caps the paint + // rate via its own render cadence; this caps the per-token handler work that + // feeds it. Speech stays intact: `#vocalizeDelta` runs at ARRIVAL for every + // delta before the snapshot is coalesced away. + #pendingMessageUpdate: Extract | undefined = undefined; + #messageUpdateTimer: NodeJS.Timeout | undefined = undefined; + /** Tail of the serialized dispatch chain; see #runSerialized. */ + #dispatchTail: Promise = Promise.resolve(); + /** Whether a chained run is currently in flight (awaiting its own awaits). */ + #dispatchInFlight = false; + // Deltas already fed to speech at arrival by the coalescer. `#handleMessageUpdate` + // also vocalizes so the direct `handleEvent` path (tests, session focus replay) + // keeps working — the WeakSet makes the coalesced path speak each delta exactly + // once instead of twice. + #vocalizedMessageUpdates = new WeakSet(); + static readonly #MESSAGE_UPDATE_COALESCE_MS = 33; constructor(private ctx: InteractiveModeContext) { // Enhanced speech (`speech.enhanced`) rewrites blocks through the @@ -253,6 +273,11 @@ export class EventController { } dispose(): void { + if (this.#messageUpdateTimer) { + clearTimeout(this.#messageUpdateTimer); + this.#messageUpdateTimer = undefined; + } + this.#pendingMessageUpdate = undefined; this.#streamingReveal.stop(); this.#toolArgsReveal.stop(); this.#cancelIdleCompaction(); @@ -425,10 +450,135 @@ export class EventController { } subscribeToAgent(): void { + // Serialize non-update dispatch behind any in-flight handler run: + // AgentSession.#emit fires listeners fire-and-forget (it does not await + // listener promises), so without this a rapid stream tail + // (message_update → message_end → agent_end) could let a later callback + // overtake the coalesced flush's handler mid-await — agent_end removing + // `streamingComponent` before #handleMessageEnd finalizes and records + // the final message (issue #7443 follow-up). When the tail has settled, + // dispatch stays synchronous: the flush's streaming rebuild runs before + // the listener's first await, preserving the timing the coalescing + // tests assert on. `message_update` enqueue is itself synchronous and + // needs no serialization. this.ctx.unsubscribe = this.ctx.session.subscribe(async (event: AgentSessionEvent) => { - await this.handleEvent(event); + // Coalesce the cumulative `message_update` deltas of a streaming turn + // into at most one handler run per window. `#handleMessageUpdate` is + // synchronous, so without this every token re-runs the whole + // streaming rebuild (splitAssistantMessageToolTimeline, reveal + // setTarget, per-block tool-call reconciliation) even though the TUI + // paints at most ~30fps — at 40-100 tps the handler work then + // dominates the CPU profile of an idle-looking streaming session + // (issue #7443). Only the latest snapshot is meaningful; non-update + // events flush the pending snapshot first so ordering is preserved. + if (event.type === "message_update") { + this.#enqueueMessageUpdate(event); + return; + } + await this.#runSerialized(async () => { + await this.#flushPendingMessageUpdate(); + await this.handleEvent(event); + }); }); } + + /** + * Run `run` in the serialized dispatch chain: every run is its own link on + * the tail, so a burst of events queued behind an in-flight run start one + * after the other, never concurrently. This closes two races (issue #7443 + * follow-up): a rapid stream tail (message_update → message_end → + * agent_end) cannot overtake the coalesced flush mid-await — agent_end + * removing `streamingComponent` before #handleMessageEnd finalizes and + * records the final message — and two+ events landing in the same window + * cannot all resume from one shared await and dispatch in parallel. When + * the chain is drained, `run` starts synchronously (no intermediate + * microtask), preserving the synchronous-flush timing the coalescing + * tests assert on. A rejection propagates to the caller (the session's + * fire-and-forget emit) and the next event starts a fresh chain link + * instead of being dropped. + */ + async #runSerialized(run: () => Promise): Promise { + if (this.#dispatchInFlight) { + // Queue behind the CURRENT tail: the next run starts only after + // the previous one settles. Each waiter gets its own link, so a + // burst cannot fan out from the same shared await. + const link = this.#dispatchTail.then( + () => run(), + () => run(), + ); + this.#dispatchTail = link; + void link.then( + () => { + // Only the tail owner clears the flag: a later chained + // link clears it when it settles as the tail. + if (this.#dispatchTail === link) this.#dispatchInFlight = false; + }, + () => { + if (this.#dispatchTail === link) this.#dispatchInFlight = false; + }, + ); + await link; + return; + } + this.#dispatchInFlight = true; + const link = run(); + this.#dispatchTail = link; + void link.then( + () => { + if (this.#dispatchTail === link) this.#dispatchInFlight = false; + }, + () => { + if (this.#dispatchTail === link) this.#dispatchInFlight = false; + }, + ); + await link; + } + + /** + * Queue a streaming `message_update` for the next coalesced handler run. + * Speech is per-delta, so the delta is vocalized at arrival before the + * snapshot is (possibly) superseded by a newer one. + */ + #enqueueMessageUpdate(event: Extract): void { + // Speech is per-delta: every delta is spoken at arrival even when its + // cumulative snapshot is later superseded and never rebuilt. + this.#vocalizeDelta(event); + this.#vocalizedMessageUpdates.add(event); + this.#pendingMessageUpdate = event; + if (this.#messageUpdateTimer) return; + this.#messageUpdateTimer = setTimeout(() => { + this.#messageUpdateTimer = undefined; + // Mirror AgentSession.#emit: attach a catch so a streaming rebuild + // failure surfaces as a logged warning instead of a process-level + // unhandled rejection (the timer path has no listener to attach one). + // Runs inside the serialized dispatch chain so a message_end / + // agent_end landing mid-window cannot overtake this flush (issue + // #7443 follow-up). + void this.#runSerialized(async () => { + await this.#flushPendingMessageUpdate(); + }).catch(err => { + logger.warn("Message update flush rejected", { + error: err instanceof Error ? err.message : String(err), + }); + }); + }, EventController.#MESSAGE_UPDATE_COALESCE_MS); + } + + /** + * Run the coalesced `message_update` handler on the latest pending snapshot + * (dropping any superseded intermediates) and clear the queue. Safe to call + * more than once; no-ops when nothing is pending. + */ + async #flushPendingMessageUpdate(): Promise { + if (this.#messageUpdateTimer) { + clearTimeout(this.#messageUpdateTimer); + this.#messageUpdateTimer = undefined; + } + const event = this.#pendingMessageUpdate; + if (!event) return; + this.#pendingMessageUpdate = undefined; + await this.handleEvent(event); + } /** * Clear every transcript-anchored/turn-scoped piece of state. Used by the * session focus proxy when re-pointing the transcript at another session: @@ -436,6 +586,11 @@ export class EventController { * session's transcript and must not bleed into the new one. */ resetTranscriptAnchors(): void { + if (this.#messageUpdateTimer) { + clearTimeout(this.#messageUpdateTimer); + this.#messageUpdateTimer = undefined; + } + this.#pendingMessageUpdate = undefined; this.#resetReadGroup(); this.#lastVisibleBlockCount = 0; this.#renderedCustomMessages.clear(); @@ -814,7 +969,9 @@ export class EventController { async #handleMessageUpdate(event: Extract): Promise { this.#ensureWorkingLoaderWhileStreaming(); - this.#vocalizeDelta(event); + if (!this.#vocalizedMessageUpdates.delete(event)) { + this.#vocalizeDelta(event); + } if (this.ctx.streamingComponent && event.message.role === "assistant") { const unlockedThinkingVisibility = this.ctx.noteDisplayableThinkingContent(event.message); if (unlockedThinkingVisibility) { diff --git a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts index 750bdd193..942b03da8 100644 --- a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts +++ b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts @@ -128,7 +128,7 @@ export class ExtensionUiController { }; this.ctx.setToolUIContext(uiContext, true); this.#toolUIContext = uiContext; - this.ctx.session.setUsageFallbackConfirmer?.(confirmation => { + this.ctx.session.setUsageFallbackConfirmer?.((confirmation, signal) => { const reserve = confirmation.remainingPercent === undefined ? "inside the configured reserve margin" @@ -136,6 +136,7 @@ export class ExtensionUiController { return this.showHookConfirm( "Coding-plan reserve reached", `${confirmation.from} has ${reserve}. Switch to ${confirmation.to}? Choose No to keep using the current plan.`, + { signal }, ); }); diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index 3da757e7d..8c2c30a01 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -91,11 +91,29 @@ export function printableEvent(event: AgentSessionEvent): unknown { export async function runPrintMode(session: AgentSession, options: PrintModeOptions): Promise { const { mode, messages = [], initialMessage, initialImages, printThoughts } = options; + // process.stdout.write is fire-and-forget: a large final record (e.g. a + // multi-MB agent_end) can be dropped when the process exits before the pipe + // drains, truncating the record mid-line while the process still exits 0. + // Serialize every stdout write on the previous write's completion callback so + // records stay ordered and honor backpressure, then block shutdown on the + // tail before dispose/exit. Same truncation class as issue #5309 (issue #7635). + let stdoutTail: Promise = Promise.resolve(); + const writeStdoutLine = (text: string): void => { + stdoutTail = stdoutTail.then(() => { + const { promise, resolve, reject } = Promise.withResolvers(); + process.stdout.write(text, err => { + if (err) reject(err); + else resolve(); + }); + return promise; + }); + }; + // Emit session header for JSON mode if (mode === "json") { const header = session.sessionManager.getHeader(); if (header) { - process.stdout.write(`${JSON.stringify(header)}\n`); + writeStdoutLine(`${JSON.stringify(header)}\n`); } } // Set up extensions for print mode (no UI, no command context) @@ -172,7 +190,7 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti } // In JSON mode, output all events if (mode === "json") { - process.stdout.write(`${JSON.stringify(printableEvent(event))}\n`); + writeStdoutLine(`${JSON.stringify(printableEvent(event))}\n`); } }); @@ -186,12 +204,14 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti // Send initial message with attachments if (initialMessage !== undefined) { writeTextWorkingIndicator(); + if (mode === "text") session.setTextOutputCommitted(false); await logger.time("print:prompt:initial", () => session.prompt(initialMessage, { images: initialImages })); } // Send remaining messages for (const message of messages) { writeTextWorkingIndicator(); + if (mode === "text") session.setTextOutputCommitted(false); await logger.time("print:prompt:next", () => session.prompt(message)); } @@ -243,23 +263,20 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti // Output text content for (const content of assistantMsg.content) { if (content.type === "text") { - process.stdout.write(`${sanitizeText(content.text)}\n`); + writeStdoutLine(`${sanitizeText(content.text)}\n`); } else if (printThoughts && content.type === "thinking" && content.thinking.trim().length > 0) { - process.stdout.write(`${sanitizeText(content.thinking)}\n`); + writeStdoutLine(`${sanitizeText(content.thinking)}\n`); } } } + session.setTextOutputCommitted(true); } await session.waitForAdvisorCatchup(PRINT_MODE_ADVISOR_DRAIN_TIMEOUT_MS); - // Ensure stdout, including late JSON advisor events, is fully flushed before returning. - // This prevents race conditions where the process exits before all output is written. - await new Promise((resolve, reject) => { - process.stdout.write("", err => { - if (err) reject(err); - else resolve(); - }); - }); + // Block shutdown until every serialized stdout write (including the final + // agent_end and late JSON advisor events) has drained; process.exit would + // otherwise discard the buffered tail and truncate the last record. + await stdoutTail; await session.dispose({ mnemopiConsolidateTimeoutMs: SHUTDOWN_CONSOLIDATE_BUDGET_MS }); } diff --git a/packages/coding-agent/src/modes/session-observer-registry.ts b/packages/coding-agent/src/modes/session-observer-registry.ts index e94ffa9d9..3074f073b 100644 --- a/packages/coding-agent/src/modes/session-observer-registry.ts +++ b/packages/coding-agent/src/modes/session-observer-registry.ts @@ -91,6 +91,11 @@ export class SessionObserverRegistry { this.#notifyListeners("main"); } + /** Return one tracked session without copying or sorting the registry. */ + getSession(id: string): ObservableSession | undefined { + return this.#sessions.get(id); + } + getSessions(): ObservableSession[] { const sessions = [...this.#sessions.values()]; sessions.sort((a, b) => { diff --git a/packages/coding-agent/src/registry/agent-lifecycle.ts b/packages/coding-agent/src/registry/agent-lifecycle.ts index e5d0f03ff..ae8dd13e6 100644 --- a/packages/coding-agent/src/registry/agent-lifecycle.ts +++ b/packages/coding-agent/src/registry/agent-lifecycle.ts @@ -21,8 +21,9 @@ */ import * as fs from "node:fs/promises"; -import { logger } from "@oh-my-pi/pi-utils"; +import { logger, untilAborted } from "@oh-my-pi/pi-utils"; import type { AgentSession } from "../session/agent-session"; +import { trackLateCleanup } from "../utils/late-cleanup"; import { type AgentRef, type AgentRefExpectation, @@ -34,6 +35,8 @@ import { export type AgentReviver = (expected: AgentRef) => Promise; +const AGENT_RELEASE_GRACE_MS = 5000; + async function persistAgentTombstone(sessionFile: string): Promise { try { await fs.writeFile(getAgentTombstonePath(sessionFile), "", { encoding: "utf8", flag: "wx", mode: 0o600 }); @@ -409,11 +412,26 @@ export class AgentLifecycleManager { } /** Teardown everything (process exit / main session dispose). */ - async dispose(): Promise { + async dispose(deadlineAt: number = Date.now() + AGENT_RELEASE_GRACE_MS): Promise { this.#unsubscribe?.(); this.#unsubscribe = undefined; const ids = [...new Set([...this.#adopted.keys(), ...this.#parks.keys()])]; - await Promise.all(ids.map(id => this.release(id))); + await Promise.all( + ids.map(async id => { + const release = this.release(id).then(() => {}); + try { + await untilAborted(AbortSignal.timeout(Math.max(0, deadlineAt - Date.now())), () => release); + } catch (error) { + if (Date.now() >= deadlineAt) { + trackLateCleanup(release, { id, resource: "adopted-agent" }); + } + logger.warn("Agent cleanup exceeded its deadline", { + id, + error: error instanceof Error ? error.message : String(error), + }); + } + }), + ); this.#revivals.clear(); this.#parks.clear(); this.#persistedReviverFactory = undefined; diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 4ee9a24c5..3f3ae2bcc 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -2283,9 +2283,10 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro } } const usageReservePolicy = settings.get("retry.usageReservePolicy"); + const modelFallbackEnabled = settings.get("retry.modelFallback"); if ( - (hasUsageFallbackCandidate || usageReservePolicy === "fail-closed") && - settings.get("retry.modelFallback") && + ((modelFallbackEnabled && (hasUsageFallbackCandidate || usageFallbackTriggered)) || + usageReservePolicy === "fail-closed") && settings.get("retry.usageAwareFallback") ) { let usageHealth: ModelUsageHealth | undefined; @@ -2308,8 +2309,10 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro `Usage depleted for ${primary.model.provider}/${primary.model.id}; reserve policy is fail-closed.`, ); } - usageFallbackTriggered = true; - continue; + if (modelFallbackEnabled) { + usageFallbackTriggered = true; + continue; + } } if (usageHealth?.state === "reserve") { if (usageReservePolicy === "fail-closed") { @@ -2317,7 +2320,10 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro `Usage reserve reached for ${primary.model.provider}/${primary.model.id}; reserve policy is fail-closed.`, ); } - if (usageReservePolicy === "auto" || (!options.hasUI && !options.deferUsageReserveConfirmation)) { + if ( + modelFallbackEnabled && + (usageReservePolicy === "auto" || (!options.hasUI && !options.deferUsageReserveConfirmation)) + ) { usageFallbackTriggered = true; continue; } diff --git a/packages/coding-agent/src/session/agent-session-types.ts b/packages/coding-agent/src/session/agent-session-types.ts index 51a2e6fc8..6d7241b3a 100644 --- a/packages/coding-agent/src/session/agent-session-types.ts +++ b/packages/coding-agent/src/session/agent-session-types.ts @@ -92,6 +92,14 @@ export interface UsageFallbackConfirmation { remainingPercent: number | undefined; } +/** + * Confirms whether a reserve-triggered model fallback may proceed. + * + * Interactive callers use the confirmation details to present the pending + * route change; aborting `signal` cancels that pending confirmation. + */ +export type UsageFallbackConfirmer = (confirmation: UsageFallbackConfirmation, signal: AbortSignal) => Promise; + /** Identifies a retry fallback chain already entered during startup model resolution. */ export interface InitialRetryFallbackState { /** Role whose configured primary was unavailable. */ diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index b86ad9bdb..7573608a9 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -58,7 +58,6 @@ import type { ImageContent, Message, Model, - ModelUsageHealth, OAuthAccountIdentity, ProviderSessionState, ResetCreditAccountStatus, @@ -101,7 +100,7 @@ import { type AdvisorConfig, type AdvisorRuntimeStatus, loadAdvisorTranscriptCos import { type AsyncJob, AsyncJobManager } from "../async"; import { shouldEnableAppendOnlyContext } from "../config/append-only-context-mode"; import type { ModelRegistry } from "../config/model-registry"; -import { type ResolvedModelRoleValue, resolveModelOverride } from "../config/model-resolver"; +import type { ResolvedModelRoleValue } from "../config/model-resolver"; import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates"; import { buildServiceTierByFamily } from "../config/service-tier"; import type { Settings, SkillsSettings } from "../config/settings"; @@ -230,7 +229,7 @@ import type { SessionHandoffOptions, SessionOAuthAccountList, SessionStats, - UsageFallbackConfirmation, + UsageFallbackConfirmer, } from "./agent-session-types"; import { ASYNC_INLINE_RESULT_MAX_CHARS, @@ -312,7 +311,6 @@ import { queueChipText, toRestoredQueuedMessage, } from "./queued-messages"; -import { formatRetryFallbackSelector, type RetryFallbackSelector } from "./retry-fallback-chains"; import { type AdvisorStats, SessionAdvisors, type SessionAdvisorsHost } from "./session-advisors"; import type { BuildSessionContextOptions, SessionContext } from "./session-context"; import { getRestorableSessionModels } from "./session-context"; @@ -485,6 +483,7 @@ export class AgentSession { // Retry state readonly #recovery: TurnRecovery; + #textOutputCommitted = true; #planModeReminderCount = 0; #planModeReminderAwaitingProgress = false; readonly #todo: TodoTracker; @@ -558,9 +557,13 @@ export class AgentSession { // Model registry for API key resolution #modelRegistry: ModelRegistry; - #usageFallbackConfirmer: ((confirmation: UsageFallbackConfirmation) => Promise) | undefined; - #usageReserveApprovedSelector: string | undefined; + #usageFallbackConfirmer: UsageFallbackConfirmer | undefined; #usagePreflightAbortControllers = new Set(); + #queuedMessageDrainBlocked = false; + #usagePreflightReadyForNextModelCall = false; + #usagePreflightReadyModel: Model | undefined; + #detachUsageBeforeQueueDequeue: (() => void) | undefined; + #detachUsageBeforeModelCall: (() => void) | undefined; #transformContext: (messages: AgentMessage[], signal?: AbortSignal) => AgentMessage[] | Promise; #onPayload: SimpleStreamOptions["onPayload"] | undefined; @@ -768,8 +771,10 @@ export class AgentSession { !this.#canAutoContinueForFollowUp() ? [...this.agent.peekFollowUpQueue()] : []; + const parkedQueueDrainBlocked = parkedFollowUps.length > 0 && this.#queuedMessageDrainBlocked; if (parkedFollowUps.length > 0) { this.agent.replaceQueues([...this.agent.peekSteeringQueue()], []); + if (parkedQueueDrainBlocked) this.#queuedMessageDrainBlocked = false; } let finishObservation: ((error?: unknown) => void | Promise) | undefined; try { @@ -805,6 +810,7 @@ export class AgentSession { [...this.agent.peekSteeringQueue()], [...parkedFollowUps, ...this.agent.peekFollowUpQueue()], ); + this.#queuedMessageDrainBlocked ||= parkedQueueDrainBlocked; } this.#endInFlight(async () => { try { @@ -830,6 +836,7 @@ export class AgentSession { steering.filter(m => !isAdvisorCard(m)), followUp.filter(m => !isAdvisorCard(m)), ); + this.#reconcileQueuedMessageDrain(); return cards; } @@ -1025,6 +1032,7 @@ export class AgentSession { modelRegistry: this.#modelRegistry, configWarnings: this.configWarnings, model: () => this.model, + textOutputCommitted: () => this.#textOutputCommitted, thinkingLevel: () => this.thinkingLevel, configuredThinkingLevel: () => this.configuredThinkingLevel(), setThinkingLevel: level => this.setThinkingLevel(level), @@ -1050,6 +1058,31 @@ export class AgentSession { withBashBranchTransition: operation => this.#bash.withBranchTransition(operation), }; this.#recovery = new TurnRecovery(recoveryHost, { initialRetryFallback: config.initialRetryFallback }); + this.#detachUsageBeforeQueueDequeue = this.agent.addBeforeQueuedMessageDequeueHook(async signal => { + if ( + !this.settings.get("retry.usageAwareFallback") || + (this.#usagePreflightReadyForNextModelCall && this.#usagePreflightReadyModel === this.model) + ) { + return; + } + if (!(await this.#runQueuedUsageAwarePreflight(signal))) { + signal?.throwIfAborted(); + throw new DOMException("Usage preflight cancelled", "AbortError"); + } + }); + this.#detachUsageBeforeModelCall = this.agent.addBeforeModelCallHook(async signal => { + if (!this.settings.get("retry.usageAwareFallback")) return; + if (this.#usagePreflightReadyForNextModelCall) { + const checkedModel = this.#usagePreflightReadyModel; + this.#usagePreflightReadyForNextModelCall = false; + this.#usagePreflightReadyModel = undefined; + if (checkedModel === this.model) return; + } + if (!(await this.#runUsageAwarePreflight(signal))) { + signal?.throwIfAborted(); + throw new DOMException("Usage preflight cancelled", "AbortError"); + } + }); const statsHost: SessionStatsTrackerHost = { session: this, agent: this.agent, @@ -2658,14 +2691,21 @@ export class AgentSession { // outside the session transcript (issue #6177). logProviderTurnError(msg); - // Invalidate GitHub Copilot credentials on auth failure so stale tokens - // aren't reused on the next request - if ( - msg.stopReason === "error" && - msg.provider === "github-copilot" && - AIError.is(AIError.classifyMessage(msg), AIError.Flag.AuthFailed) - ) { - await this.#modelRegistry.authStorage.remove("github-copilot"); + // Invalidate GitHub Copilot credentials on a hard auth failure (401, or an + // expired/revoked token) so stale tokens aren't reused on the next request. + // Account usage caps and concurrency caps leave the credential valid: the + // former rotates until its reset window, while the latter is retried after + // a short backoff without touching the credential pool. + if (msg.stopReason === "error" && msg.provider === "github-copilot") { + const errorId = AIError.classifyMessage(msg); + const isConcurrencyCap = AIError.parseRateLimitReason(msg.errorMessage ?? "") === "CONCURRENT_LIMIT"; + if ( + AIError.is(errorId, AIError.Flag.AuthFailed) && + !AIError.is(errorId, AIError.Flag.UsageLimit) && + !isConcurrencyCap + ) { + await this.#modelRegistry.authStorage.remove("github-copilot"); + } } if (this.#maintenance.skipPostTurnMaintenanceAssistantTimestamp === msg.timestamp) { @@ -2967,19 +3007,17 @@ export class AgentSession { this.#beginInFlight(); try { await this.#recovery.maybeRestoreRetryFallbackPrimary(); - if ( - this.settings.get("retry.modelFallback") && - this.settings.get("retry.usageAwareFallback") && - !(await this.#runUsageAwarePreflight()) - ) { - this.#skipAgentContinue("session-unavailable", options); - return; - } if (signal.aborted || this.#isDisposed) { this.#skipAgentContinue("post-restore-unavailable", options); return; } - await this.agent.continue(); + if (this.settings.get("retry.usageAwareFallback")) { + if (!(await this.#runQueuedUsageAwarePreflight(signal))) { + this.#skipAgentContinue("session-unavailable", options); + return; + } + } + await this.agent.continue(signal); } catch (error) { logger.warn("agent.continue failed after scheduling", { error: error instanceof Error ? error.message : String(error), @@ -2987,6 +3025,7 @@ export class AgentSession { }); options?.onError?.(error); } finally { + this.#usagePreflightReadyForNextModelCall = false; this.#endInFlight(); } }, @@ -3605,6 +3644,12 @@ export class AgentSession { */ beginDispose(): void { this.#isDisposed = true; + this.#queuedMessageDrainBlocked = false; + this.#usagePreflightReadyForNextModelCall = false; + this.#detachUsageBeforeQueueDequeue?.(); + this.#detachUsageBeforeQueueDequeue = undefined; + this.#detachUsageBeforeModelCall?.(); + this.#detachUsageBeforeModelCall = undefined; this.#memory.cancelLocalMemoryStartup(); this.#titleGenerationAbortController.abort(); this.#abortAutolearnCapture(); @@ -3926,176 +3971,71 @@ export class AgentSession { } /** Install the interactive decision surface for reserve-triggered model changes. */ - setUsageFallbackConfirmer( - confirmer: ((confirmation: UsageFallbackConfirmation) => Promise) | undefined, - ): void { + setUsageFallbackConfirmer(confirmer: UsageFallbackConfirmer | undefined): void { this.#usageFallbackConfirmer = confirmer; } - async #runUsageAwarePreflight(): Promise { + #allowQueuedMessageDrainRetry(): void { + this.#queuedMessageDrainBlocked = false; + } + + #reconcileQueuedMessageDrain(): void { + if (!this.agent.hasQueuedMessages()) { + this.#queuedMessageDrainBlocked = false; + } + } + + async #runQueuedUsageAwarePreflight(signal?: AbortSignal): Promise { + try { + const allowed = await this.#runUsageAwarePreflight(signal); + this.#usagePreflightReadyForNextModelCall = allowed; + this.#usagePreflightReadyModel = allowed ? this.model : undefined; + this.#queuedMessageDrainBlocked = !allowed && this.agent.hasQueuedMessages(); + return allowed; + } catch (error) { + this.#queuedMessageDrainBlocked = this.agent.hasQueuedMessages(); + throw error; + } + } + + async #runUsageAwarePreflightForNextModelCall(signal?: AbortSignal): Promise { + const allowed = await this.#runUsageAwarePreflight(signal); + this.#usagePreflightReadyForNextModelCall = allowed; + this.#usagePreflightReadyModel = allowed ? this.model : undefined; + return allowed; + } + + async #runUsageAwarePreflight(signal?: AbortSignal): Promise { + if (signal?.aborted) return false; const generation = this.#promptGeneration; + const controller = new AbortController(); + const onAbort = () => controller.abort(signal?.reason); + signal?.addEventListener("abort", onAbort, { once: true }); this.#usagePreflightAbortControllers.add(controller); try { - await this.#maybeApplyUsageAwareFallback(controller.signal); - return !controller.signal.aborted && this.#promptGeneration === generation; - } catch (error) { - if (controller.signal.aborted || this.#promptGeneration !== generation) return false; - throw error; + while (true) { + const model = this.model; + try { + const fallbackCommitted = await this.#recovery.maybeApplyUsageAwareFallback( + controller.signal, + this.#usageFallbackConfirmer, + ); + if (fallbackCommitted) return true; + if (controller.signal.aborted || this.#promptGeneration !== generation) return false; + if (this.model === model || modelsAreEqual(this.model, model)) return true; + } catch (error) { + if (controller.signal.aborted || this.#promptGeneration !== generation) return false; + if (this.model !== model && !modelsAreEqual(this.model, model)) continue; + throw error; + } + } } finally { + signal?.removeEventListener("abort", onAbort); this.#usagePreflightAbortControllers.delete(controller); } } - async #confirmUsageFallback(confirmation: UsageFallbackConfirmation, signal: AbortSignal): Promise { - const confirmer = this.#usageFallbackConfirmer; - if (!confirmer || signal.aborted) return false; - const aborted = Promise.withResolvers(); - const onAbort = () => aborted.resolve(false); - signal.addEventListener("abort", onAbort, { once: true }); - try { - return await Promise.race([confirmer(confirmation), aborted.promise]); - } finally { - signal.removeEventListener("abort", onAbort); - } - } - - async #maybeApplyUsageAwareFallback(signal: AbortSignal): Promise { - if (!this.settings.get("retry.modelFallback") || !this.settings.get("retry.usageAwareFallback")) return; - const currentModel = this.model; - if (!currentModel) return; - const currentSelector = formatRetryFallbackSelector(currentModel, this.thinkingLevel); - let health: ModelUsageHealth; - try { - health = await this.#modelRegistry.authStorage.getModelUsageHealth(currentModel.provider, { - modelId: currentModel.id, - sessionId: this.sessionId, - baseUrl: currentModel.baseUrl, - reserveFraction: this.settings.get("retry.usageReservePct") / 100, - signal, - }); - } catch (error) { - logger.debug("Usage-aware runtime preflight failed open", { - provider: currentModel.provider, - model: currentModel.id, - error: String(error), - }); - return; - } - if (signal.aborted) return; - - if (health.state === "healthy") { - this.#usageReserveApprovedSelector = undefined; - const selected = health.accounts.find(account => account.selected); - if (selected && selected.state !== "healthy" && health.accounts.some(account => account.state === "healthy")) { - this.#modelRegistry.authStorage.releaseSessionCredentialForReselection( - currentModel.provider, - this.sessionId, - ); - } - return; - } - if (health.state === "unknown") { - this.#usageReserveApprovedSelector = undefined; - return; - } - const reservePolicy = this.settings.get("retry.usageReservePolicy"); - if (reservePolicy === "fail-closed") { - const condition = health.state === "reserve" ? "reserve reached" : "usage depleted"; - throw new Error(`${condition} for ${currentSelector}; reserve policy is fail-closed.`); - } - - const role = this.#recovery.resolveRetryFallbackRole(currentSelector, currentModel); - if (!role) return; - let fallback: { selector: RetryFallbackSelector; apiKey: string } | undefined; - for (const candidate of this.#recovery.findRetryFallbackCandidates(role, currentSelector, currentModel)) { - if (this.#recovery.isRetryFallbackSelectorSuppressed(candidate)) continue; - const resolved = resolveModelOverride([candidate.raw], this.#modelRegistry, this.settings); - const candidateModel = resolved.model ?? this.#modelRegistry.find(candidate.provider, candidate.id); - if (!candidateModel) continue; - if (!this.#modelRegistry.hasConfiguredAuth(candidateModel)) continue; - try { - const candidateHealth = await this.#modelRegistry.authStorage.getModelUsageHealth(candidateModel.provider, { - modelId: candidateModel.id, - sessionId: this.sessionId, - baseUrl: candidateModel.baseUrl, - reserveFraction: this.settings.get("retry.usageReservePct") / 100, - signal, - }); - if (signal.aborted) return; - if (candidateHealth.state === "depleted" || candidateHealth.state === "reserve") continue; - if (candidateHealth.state === "healthy") { - const selected = candidateHealth.accounts.find(account => account.selected); - if ( - selected && - selected.state !== "healthy" && - candidateHealth.accounts.some(account => account.state === "healthy") - ) { - this.#modelRegistry.authStorage.releaseSessionCredentialForReselection( - candidateModel.provider, - this.sessionId, - ); - } - } - } catch { - if (signal.aborted) return; - // Unknown usage fails open for an otherwise valid fallback. - } - if (signal.aborted) return; - let apiKey: string | undefined; - try { - apiKey = await this.#modelRegistry.getApiKey(candidateModel, this.sessionId, { signal }); - } catch { - if (signal.aborted) return; - continue; - } - if (signal.aborted) return; - if (!apiKey) continue; - fallback = { selector: candidate, apiKey }; - break; - } - if (!fallback) return; - - if (health.state === "reserve") { - if (reservePolicy === "confirm" && this.#usageFallbackConfirmer) { - if (this.#usageReserveApprovedSelector === currentSelector) return; - const selected = health.accounts.find(account => account.selected); - const remainingFraction = - selected?.remainingFraction ?? - health.accounts.reduce( - (minimum, account) => - account.remainingFraction === undefined - ? minimum - : minimum === undefined - ? account.remainingFraction - : Math.min(minimum, account.remainingFraction), - undefined, - ); - const shouldFallback = await this.#confirmUsageFallback( - { - from: currentSelector, - to: fallback.selector.raw, - remainingPercent: remainingFraction === undefined ? undefined : Math.max(0, remainingFraction * 100), - }, - signal, - ); - if (signal.aborted) return; - if (!shouldFallback) { - this.#usageReserveApprovedSelector = currentSelector; - return; - } - } - } - - if (signal.aborted) return; - this.#usageReserveApprovedSelector = undefined; - await this.#recovery.applyRetryFallbackCandidate(role, fallback.selector, currentSelector, { - pinFallback: true, - apiKey: fallback.apiKey, - signal, - }); - } - /** Effective thinking level applied to the agent (the resolved level when `auto`). */ get thinkingLevel(): ThinkingLevel | undefined { return this.#models.thinkingLevel; @@ -4184,6 +4124,11 @@ export class AgentSession { return this.agent.state.systemPrompt; } + /** Marks streamed text as committed or buffered for turn-recovery replay decisions. */ + setTextOutputCommitted(committed: boolean): void { + this.#textOutputCommitted = committed; + } + /** Current retry attempt (0 if not retrying) */ get retryAttempt(): number { return this.#recovery.attempt; @@ -5026,7 +4971,7 @@ export class AgentSession { if (this.isStreaming) { const streamingBehavior = options?.streamingBehavior; if (!streamingBehavior) throw new AgentBusyError(); - if (!(await this.#runUsageAwarePreflight())) return false; + // Steer/follow-up the keyword notices BEFORE the queued user message so the // model reads the steering notice ahead of the prompt it modifies. for (const notice of keywordNotices) { @@ -5121,7 +5066,7 @@ export class AgentSession { if (options?.queueOnly) { const streamingBehavior = options?.streamingBehavior; if (!streamingBehavior) throw new AgentBusyError(); - if (!(await this.#runUsageAwarePreflight())) return; + for (const notice of keywordNotices) { await this.#queueCustomMessage(notice, streamingBehavior); } @@ -5131,7 +5076,7 @@ export class AgentSession { if (this.isStreaming) { const streamingBehavior = options?.streamingBehavior; if (!streamingBehavior) throw new AgentBusyError(); - if (!(await this.#runUsageAwarePreflight())) return; + for (const notice of keywordNotices) { await this.#queueCustomMessage(notice, streamingBehavior); } @@ -5168,7 +5113,7 @@ export class AgentSession { const generation = this.#promptGeneration; try { await this.#recovery.maybeRestoreRetryFallbackPrimary(); - if (!(await this.#runUsageAwarePreflight())) return; + if (!(await this.#runUsageAwarePreflightForNextModelCall())) return; // Flush any pending bash messages before the new prompt await this.#bash.flushPending(); this.#eval.flushPending(); @@ -5379,6 +5324,7 @@ export class AgentSession { await this.#waitForPostPromptRecovery(generation); } } finally { + this.#usagePreflightReadyForNextModelCall = false; this.#endInFlight(); } } @@ -5544,7 +5490,6 @@ export class AgentSession { } const expandedText = expandPromptTemplate(text, [...this.#promptTemplates]); - if (!(await this.#runUsageAwarePreflight())) return; await this.#queueUserMessage(expandedText, images, "steer"); } @@ -5562,7 +5507,6 @@ export class AgentSession { const expandedText = options?.expandPromptTemplates === false ? text : expandPromptTemplate(text, [...this.#promptTemplates]); - if (!(await this.#runUsageAwarePreflight())) return; if (!options?.synthetic) { await this.#queueUserMessage(expandedText, images, "followUp"); return; @@ -5579,6 +5523,7 @@ export class AgentSession { const imageDescriptionNotice = normalizedImages?.length ? await this.#buildImageDescriptionNotice(normalizedImages) : undefined; + this.#allowQueuedMessageDrainRetry(); if (imageDescriptionNotice) this.agent.followUp(imageDescriptionNotice); this.agent.followUp({ role: "developer", @@ -5608,6 +5553,7 @@ export class AgentSession { const imageDescriptionNotice = normalizedImages?.length ? await this.#buildImageDescriptionNotice(normalizedImages) : undefined; + this.#allowQueuedMessageDrainRetry(); if (mode === "followUp") { if (imageDescriptionNotice) this.agent.followUp(imageDescriptionNotice); this.agent.followUp({ @@ -5634,7 +5580,12 @@ export class AgentSession { } #scheduleQueuedMessageDrain(): void { - if (this.#queuedMessageDrainScheduled || !this.#canAutoContinueForFollowUp() || !this.agent.hasQueuedMessages()) { + if ( + this.#queuedMessageDrainScheduled || + this.#queuedMessageDrainBlocked || + !this.#canAutoContinueForFollowUp() || + !this.agent.hasQueuedMessages() + ) { return; } this.#queuedMessageDrainScheduled = true; @@ -5648,6 +5599,7 @@ export class AgentSession { }, onError: () => { this.#queuedMessageDrainScheduled = false; + this.#queuedMessageDrainBlocked = this.agent.hasQueuedMessages(); }, }); } @@ -5784,7 +5736,7 @@ export class AgentSession { ): Promise { this.#beginInFlight(); try { - if (!(await this.#runUsageAwarePreflight())) return; + if (!(await this.#runUsageAwarePreflightForNextModelCall())) return; const acceptTerminalEmptyStop = options?.acceptTerminalEmptyStop === true; if (acceptTerminalEmptyStop) { this.#resetPromptMaintenanceState(); @@ -5793,6 +5745,7 @@ export class AgentSession { await this.agent.prompt(message); await this.#waitForPostPromptRecovery(); } finally { + this.#usagePreflightReadyForNextModelCall = false; this.#recovery.setAcceptTerminalEmptyStop(false); this.#endInFlight(); } @@ -5824,6 +5777,7 @@ export class AgentSession { timestamp: Date.now(), }; const normalizedAppMessage = await this.#normalizeAgentMessageImages(appMessage); + this.#allowQueuedMessageDrainRetry(); if (deliverAs === "followUp") { this.agent.followUp(normalizedAppMessage); } else { @@ -5880,7 +5834,7 @@ export class AgentSession { this.#queueHiddenNextTurnMessage(normalizedAppMessage, options?.triggerTurn ?? false); return false; } - if (!(await this.#runUsageAwarePreflight())) return false; + this.#allowQueuedMessageDrainRetry(); if (options?.deliverAs === "followUp") { this.agent.followUp(normalizedAppMessage); @@ -5963,8 +5917,6 @@ export class AgentSession { if (images.length === 0) images = undefined; } - if (options?.deliverAs && !(await this.#runUsageAwarePreflight())) return; - if (options?.deliverAs === "followUp") { await this.#queueUserMessage(text, images, "followUp"); return; @@ -6004,6 +5956,7 @@ export class AgentSession { ? isAdvisorCard : m => !isUserQueuedMessage(m) && !isHiddenUserCompanion(m); this.agent.replaceQueues(steeringAll.filter(keep), followUpAll.filter(keep)); + this.#reconcileQueuedMessageDrain(); return { steering, followUp }; } @@ -6053,12 +6006,14 @@ export class AgentSession { if (fromSteer >= 0) { const removed = steering[fromSteer]; this.agent.replaceQueues(removeWithCompanions(steering, fromSteer), followUp.slice()); + this.#reconcileQueuedMessageDrain(); return toRestoredQueuedMessage(removed); } const fromFollowUp = lastUserIndex(followUp); if (fromFollowUp >= 0) { const removed = followUp[fromFollowUp]; this.agent.replaceQueues(steering.slice(), removeWithCompanions(followUp, fromFollowUp)); + this.#reconcileQueuedMessageDrain(); return toRestoredQueuedMessage(removed); } return undefined; @@ -6348,6 +6303,8 @@ export class AgentSession { await this.#memory.resetContextForNewTranscript(); this.#pendingNextTurnMessages = []; this.#scheduledHiddenNextTurnGeneration = undefined; + this.#queuedMessageDrainBlocked = false; + this.#usagePreflightReadyForNextModelCall = false; this.sessionManager.appendThinkingLevelChange(this.thinkingLevel, this.configuredThinkingLevel()); this.sessionManager.appendServiceTierChange(this.#models.serviceTierEntry()); @@ -7372,6 +7329,9 @@ export class AgentSession { const previousFollowUpMessages = [...this.agent.peekFollowUpQueue()]; const previousPendingNextTurnMessages = [...this.#pendingNextTurnMessages]; const previousScheduledHiddenNextTurnGeneration = this.#scheduledHiddenNextTurnGeneration; + const previousQueuedMessageDrainBlocked = this.#queuedMessageDrainBlocked; + const previousUsagePreflightReadyForNextModelCall = this.#usagePreflightReadyForNextModelCall; + const previousUsagePreflightReadyModel = this.#usagePreflightReadyModel; const previousModel = this.model; const previousThinkingLevel = this.thinkingLevel; const previousAutoThinking = this.isAutoThinking; @@ -7396,6 +7356,9 @@ export class AgentSession { this.agent.clearAllQueues(); this.#pendingNextTurnMessages = []; this.#scheduledHiddenNextTurnGeneration = undefined; + this.#queuedMessageDrainBlocked = false; + this.#usagePreflightReadyForNextModelCall = false; + this.#usagePreflightReadyModel = undefined; try { if (switchingToDifferentSession) { @@ -7564,6 +7527,9 @@ export class AgentSession { this.agent.replaceQueues(previousSteeringMessages, previousFollowUpMessages); this.#pendingNextTurnMessages = previousPendingNextTurnMessages; this.#scheduledHiddenNextTurnGeneration = previousScheduledHiddenNextTurnGeneration; + this.#queuedMessageDrainBlocked = previousQueuedMessageDrainBlocked; + this.#usagePreflightReadyForNextModelCall = previousUsagePreflightReadyForNextModelCall; + this.#usagePreflightReadyModel = previousUsagePreflightReadyModel; this.#inheritedProviderPromptCacheKey = previousInheritedProviderPromptCacheKey; this.#checkpointState = previousCheckpointState; this.#pendingRewindReport = previousPendingRewindReport; @@ -7652,6 +7618,8 @@ export class AgentSession { // Clear pending messages (bound to old session state) this.#pendingNextTurnMessages = []; this.#scheduledHiddenNextTurnGeneration = undefined; + this.#queuedMessageDrainBlocked = false; + this.#usagePreflightReadyForNextModelCall = false; await this.#bash.flushPending(); // Flush pending writes before branching @@ -7779,6 +7747,8 @@ export class AgentSession { this.#pendingNextTurnMessages = []; this.#scheduledHiddenNextTurnGeneration = undefined; this.agent.replaceQueues([], []); + this.#queuedMessageDrainBlocked = false; + this.#usagePreflightReadyForNextModelCall = false; await this.#bash.flushPending(); await this.sessionManager.flush(); const bashTransition = this.#bash.beginSessionTransition(); diff --git a/packages/coding-agent/src/session/session-paths.ts b/packages/coding-agent/src/session/session-paths.ts index 237aa013e..f76d6e183 100644 --- a/packages/coding-agent/src/session/session-paths.ts +++ b/packages/coding-agent/src/session/session-paths.ts @@ -17,11 +17,13 @@ function migrateSessionDirPath(oldPath: string, newPath: string): void { for (const file of fs.readdirSync(oldPath)) { const src = path.join(oldPath, file); const dst = path.join(newPath, file); - if (!fs.existsSync(dst)) { - fs.renameSync(src, dst); + if (fs.existsSync(dst)) { + logger.warn("Session directory migration collision; preserving legacy entry", { src, dst }); + continue; } + fs.renameSync(src, dst); } - fs.rmSync(oldPath, { recursive: true, force: true }); + fs.rmdirSync(oldPath); return; } if (existing) { @@ -94,8 +96,12 @@ function migrateHomeSessionDirs(sessionsRoot: string): void { try { migrateSessionDirPath(oldPath, newPath); - } catch { - // Best effort + } catch (error) { + logger.warn("Failed to migrate legacy home session directory", { + oldPath, + newPath, + error: String(error), + }); } } } @@ -106,8 +112,12 @@ function migrateLegacyAbsoluteSessionDir(cwd: string, sessionDir: string, sessio try { migrateSessionDirPath(legacyDir, sessionDir); - } catch { - // Best effort + } catch (error) { + logger.warn("Failed to migrate legacy session directory", { + oldPath: legacyDir, + newPath: sessionDir, + error: String(error), + }); } } diff --git a/packages/coding-agent/src/session/settings-stream-fn.ts b/packages/coding-agent/src/session/settings-stream-fn.ts index be58243ed..41dcdc386 100644 --- a/packages/coding-agent/src/session/settings-stream-fn.ts +++ b/packages/coding-agent/src/session/settings-stream-fn.ts @@ -34,9 +34,13 @@ export function createSettingsAwareStreamFn(settings: Settings, base: StreamFn = openrouterRoutingPreset && openrouterRoutingPreset !== "default" ? openrouterRoutingPreset : undefined; const antigravityEndpointMode = settings.get("providers.antigravityEndpoint"); const textVerbosity = - model.api === "openai-codex-responses" || model.api === "openai-responses" - ? settings.get("textVerbosity") - : undefined; + model.api === "openai-codex-responses" + ? settings.isConfigured("textVerbosity") + ? settings.get("textVerbosity") + : undefined + : model.api === "openai-responses" + ? settings.get("textVerbosity") + : undefined; const streamFirstEventTimeoutMs = timeoutSecondsToMs(settings.get("providers.streamFirstEventTimeoutSeconds")); const streamIdleTimeoutMs = timeoutSecondsToMs(settings.get("providers.streamIdleTimeoutSeconds")); // Server-side fallback (opt-in): when the user enables it AND the diff --git a/packages/coding-agent/src/session/turn-recovery.ts b/packages/coding-agent/src/session/turn-recovery.ts index 6f5d15156..1efab6ccf 100644 --- a/packages/coding-agent/src/session/turn-recovery.ts +++ b/packages/coding-agent/src/session/turn-recovery.ts @@ -13,16 +13,20 @@ import type { CodexCompactionContext, Effort, Model, + ModelUsageHealth, TextContent, + ThinkingContent, ToolChoice, } from "@oh-my-pi/pi-ai"; import { calculateRateLimitBackoffMs, parseRateLimitReason } from "@oh-my-pi/pi-ai"; import * as AIError from "@oh-my-pi/pi-ai/error"; import { kCursorExecResolved } from "@oh-my-pi/pi-ai/utils/block-symbols"; import { isFireworksFastModelId, toFireworksBaseModelId } from "@oh-my-pi/pi-catalog/fireworks-model-id"; +import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models"; import { extractRetryHint, logger, prompt } from "@oh-my-pi/pi-utils"; import type { ModelRegistry } from "../config/model-registry"; import { formatModelStringWithRouting, resolveModelOverride } from "../config/model-resolver"; + import type { Settings } from "../config/settings"; import type { RecoveredRetryError } from "../extensibility/shared-events"; import emptyStopRetryTemplate from "../prompts/system/empty-stop-retry.md" with { type: "text" }; @@ -35,7 +39,11 @@ import { modelSupportsEffortCeiling, } from "../thinking"; import type { AgentSessionEvent } from "./agent-session-events"; -import type { InitialRetryFallbackState } from "./agent-session-types"; +import type { + InitialRetryFallbackState, + UsageFallbackConfirmation, + UsageFallbackConfirmer, +} from "./agent-session-types"; import { isEmptyErrorTurn } from "./messages"; import { type ActiveRetryFallbackState, @@ -64,6 +72,7 @@ const UNEXPECTED_STOP_TIMEOUT_MS = 4000; const EMPTY_STOP_MAX_RETRIES = 3; const SIBLING_UNBLOCK_BUFFER_MS = 1_000; const NON_WHITESPACE_RE = /\S/; +const USAGE_PREFLIGHT_BLOCKED_PREFIX = "Usage preflight blocked:"; function hasNonWhitespace(value: string): boolean { return NON_WHITESPACE_RE.test(value); @@ -101,6 +110,8 @@ export interface TurnRecoveryHost { modelRegistry: ModelRegistry; configWarnings: string[]; model(): Model | undefined; + /** Whether streamed text has already been committed to the active output sink. */ + textOutputCommitted(): boolean; thinkingLevel(): ThinkingLevel | undefined; configuredThinkingLevel(): ConfiguredThinkingLevel | undefined; setThinkingLevel(level: ConfiguredThinkingLevel | undefined): void; @@ -172,6 +183,7 @@ export class TurnRecovery { #retryPromise: Promise | undefined; #retryResolve: (() => void) | undefined; #activeRetryFallback: ActiveRetryFallbackState | undefined; + #usageReserveApprovedSelector: string | undefined; #pendingRecoveredRetryErrors: PendingRecoveredRetryError[] = []; #usageLimitOutcomes = new WeakMap>(); #emptyStopRetryCount = 0; @@ -298,6 +310,11 @@ export class TurnRecovery { return this.#maybeRestoreRetryFallbackPrimary(); } + /** Applies model fallback policy from live usage health before a turn starts. */ + maybeApplyUsageAwareFallback(signal: AbortSignal, confirmer?: UsageFallbackConfirmer): Promise { + return this.#maybeApplyUsageAwareFallback(signal, confirmer); + } + /** Applies automatic retry, credential rotation, and model fallback policy. */ handleRetryableError( message: AssistantMessage, @@ -591,11 +608,19 @@ export class TurnRecovery { return false; } - const text = assistantMessage.content + let text = assistantMessage.content .filter((content): content is TextContent => content.type === "text") .map(content => content.text) .join("\n"); - if (!/\S/.test(text)) { + // Thinking-only stops carry their signal in the thinking block (a trapped + // response or a truncated fragment); classify on that when there is no text. + if (!hasNonWhitespace(text)) { + text = assistantMessage.content + .filter((content): content is ThinkingContent => content.type === "thinking") + .map(content => content.thinking) + .join("\n"); + } + if (!hasNonWhitespace(text)) { this.#unexpectedStopRetryCount = 0; return false; } @@ -840,6 +865,9 @@ export class TurnRecovery { return id; } + #isUsagePreflightBlocked(message: AssistantMessage): boolean { + return message.errorMessage?.startsWith(USAGE_PREFLIGHT_BLOCKED_PREFIX) === true; + } /** * Retry an empty, reason-less provider abort: a turn with no content that * carries the generic sentinel (bare `abort()`), whether the provider @@ -883,6 +911,7 @@ export class TurnRecovery { */ isRetryableError(message: AssistantMessage): boolean { if (message.stopReason !== "error") return false; + if (this.#isUsagePreflightBlocked(message)) return false; const id = this.#classifyRetryMessage(message); // Context overflow is handled by compaction, not retry @@ -890,8 +919,8 @@ export class TurnRecovery { if (AIError.isContextOverflow(message, contextWindow)) return false; // A classifier refusal/sensitivity stop is the model's decision, not a route - // failure, but only after we confirm no user-visible output has already been - // streamed. Visible text, images, tool calls, or server tools must not be + // failure, but only after we confirm no replay-unsafe output has already + // streamed. Committed text, images, tool calls, or server tools must not be // discarded and replayed. if (this.#hasReplayUnsafeOutput(message)) return false; if (this.isClassifierRefusal(message)) return true; @@ -966,10 +995,10 @@ export class TurnRecovery { * Thinking-only partials are safe to discard and replay: reasoning models * routinely stall after long thinking with no visible output, and duplicated * thinking display is materially lower harm than duplicated final text. - * Whitespace-only text is likewise safe since nothing meaningful reached the - * user. Visible text, generated images, server tools, and retained tool calls - * are NOT safe: each has already rendered or may have side effects, so replaying - * the turn can duplicate user-visible output or work. + * Whitespace-only and buffered text are likewise safe since nothing meaningful + * reached the user. Committed text, generated images, server tools, and retained + * tool calls are NOT safe: each has already rendered or may have side effects, + * so replaying the turn can duplicate user-visible output or work. */ #hasReplayUnsafeOutput(message: AssistantMessage): boolean { return message.content.some( @@ -977,7 +1006,7 @@ export class TurnRecovery { block.type === "toolCall" || block.type === "image" || block.type === "anthropicServerTool" || - (block.type === "text" && block.text.trim().length > 0), + (block.type === "text" && this.#host.textOutputCommitted() && block.text.trim().length > 0), ); } @@ -1008,7 +1037,6 @@ export class TurnRecovery { modelLookup: this.#host.modelRegistry, }; } - #getRetryFallbackChains(): RetryFallbackChains { return getRetryFallbackChains(this.#host.settings); } @@ -1071,23 +1099,187 @@ export class TurnRecovery { ); } + async #maybeApplyUsageAwareFallback(signal: AbortSignal, confirmer?: UsageFallbackConfirmer): Promise { + if (!this.#host.settings.get("retry.usageAwareFallback")) return false; + const currentModel = this.#host.model(); + if (!currentModel) return false; + const currentSelector = formatRetryFallbackSelector(currentModel, this.#host.thinkingLevel()); + let health: ModelUsageHealth; + try { + health = await this.#host.modelRegistry.authStorage.getModelUsageHealth(currentModel.provider, { + modelId: currentModel.id, + sessionId: this.#host.sessionId(), + baseUrl: currentModel.baseUrl, + reserveFraction: this.#host.settings.get("retry.usageReservePct") / 100, + signal, + }); + } catch (error) { + if (signal.aborted || !modelsAreEqual(this.#host.model(), currentModel)) return false; + logger.debug("Usage-aware runtime preflight failed open", { + provider: currentModel.provider, + model: currentModel.id, + error: String(error), + }); + return false; + } + if (signal.aborted || !modelsAreEqual(this.#host.model(), currentModel)) return false; + const selectedAccount = health.accounts.find(account => account.selected); + if (health.state === "healthy") { + this.#usageReserveApprovedSelector = undefined; + if ( + selectedAccount && + selectedAccount.state !== "healthy" && + health.accounts.some(account => account.state === "healthy") + ) { + this.#host.modelRegistry.authStorage.releaseSessionCredentialForReselection( + currentModel.provider, + this.#host.sessionId(), + ); + } + return false; + } + if (health.state === "unknown") { + this.#usageReserveApprovedSelector = undefined; + return false; + } + if (health.state !== "reserve") this.#usageReserveApprovedSelector = undefined; + + const reservePolicy = this.#host.settings.get("retry.usageReservePolicy"); + if (reservePolicy === "fail-closed") { + const condition = health.state === "reserve" ? "reserve reached" : "usage depleted"; + throw new Error( + `${USAGE_PREFLIGHT_BLOCKED_PREFIX} ${condition} for ${currentSelector}; reserve policy is fail-closed.`, + ); + } + if ( + reservePolicy === "confirm" && + health.state === "reserve" && + this.#usageReserveApprovedSelector === currentSelector + ) { + return false; + } + if (!this.#host.settings.get("retry.modelFallback")) return false; + + const role = this.#activeRetryFallback?.role ?? this.resolveRetryFallbackRole(currentSelector, currentModel); + if (!role) return false; + let fallback: { selector: RetryFallbackSelector; apiKey: string } | undefined; + const ceiling = this.#host.thinkingLevelCeiling(); + for (const candidate of this.findRetryFallbackCandidates(role, currentSelector, currentModel)) { + if (this.isRetryFallbackSelectorSuppressed(candidate)) continue; + const resolved = resolveModelOverride([candidate.raw], this.#host.modelRegistry, this.#host.settings); + const candidateModel = resolved.model ?? this.#host.modelRegistry.find(candidate.provider, candidate.id); + if (!candidateModel || !this.#host.modelRegistry.hasConfiguredAuth(candidateModel)) continue; + if (ceiling !== undefined && !modelSupportsEffortCeiling(candidateModel, ceiling)) continue; + try { + const candidateHealth = await this.#host.modelRegistry.authStorage.getModelUsageHealth( + candidateModel.provider, + { + modelId: candidateModel.id, + sessionId: this.#host.sessionId(), + baseUrl: candidateModel.baseUrl, + reserveFraction: this.#host.settings.get("retry.usageReservePct") / 100, + signal, + }, + ); + if (signal.aborted || !modelsAreEqual(this.#host.model(), currentModel)) return false; + if (candidateHealth.state === "depleted" || candidateHealth.state === "reserve") continue; + if (candidateHealth.state === "healthy") { + const selected = candidateHealth.accounts.find(account => account.selected); + if ( + selected && + selected.state !== "healthy" && + candidateHealth.accounts.some(account => account.state === "healthy") + ) { + this.#host.modelRegistry.authStorage.releaseSessionCredentialForReselection( + candidateModel.provider, + this.#host.sessionId(), + ); + } + } + } catch { + if (signal.aborted || !modelsAreEqual(this.#host.model(), currentModel)) return false; + // Unknown usage fails open for an otherwise valid fallback. + } + if (signal.aborted || !modelsAreEqual(this.#host.model(), currentModel)) return false; + let apiKey: string | undefined; + try { + apiKey = await this.#host.modelRegistry.getApiKey(candidateModel, this.#host.sessionId(), { signal }); + } catch { + if (signal.aborted || !modelsAreEqual(this.#host.model(), currentModel)) return false; + continue; + } + if (signal.aborted || !modelsAreEqual(this.#host.model(), currentModel)) return false; + if (!apiKey) continue; + fallback = { selector: candidate, apiKey }; + break; + } + if (!fallback) return false; + + let shouldFallback = health.state === "depleted" || reservePolicy === "auto" || !confirmer; + if (!shouldFallback && health.state === "reserve" && confirmer) { + const remainingFraction = + selectedAccount?.remainingFraction ?? + health.accounts.reduce((minimum, account) => { + if (account.remainingFraction === undefined) return minimum; + return minimum === undefined ? account.remainingFraction : Math.min(minimum, account.remainingFraction); + }, undefined); + shouldFallback = await this.#confirmUsageFallback( + confirmer, + { + from: currentSelector, + to: fallback.selector.raw, + remainingPercent: remainingFraction === undefined ? undefined : Math.max(0, remainingFraction * 100), + }, + signal, + ); + if (signal.aborted || !modelsAreEqual(this.#host.model(), currentModel)) return false; + } + if (!shouldFallback) { + this.#usageReserveApprovedSelector = currentSelector; + return false; + } + this.#usageReserveApprovedSelector = undefined; + return this.applyRetryFallbackCandidate(role, fallback.selector, currentSelector, { + pinFallback: true, + apiKey: fallback.apiKey, + signal, + }); + } + + async #confirmUsageFallback( + confirmer: UsageFallbackConfirmer, + confirmation: UsageFallbackConfirmation, + signal: AbortSignal, + ): Promise { + if (signal.aborted) return false; + const aborted = Promise.withResolvers(); + const onAbort = () => aborted.resolve(false); + signal.addEventListener("abort", onAbort, { once: true }); + try { + return await Promise.race([confirmer(confirmation, signal), aborted.promise]); + } finally { + signal.removeEventListener("abort", onAbort); + } + } + async applyRetryFallbackCandidate( role: string, selector: RetryFallbackSelector, currentSelector: string, options?: { pinFallback?: boolean; apiKey?: string; signal?: AbortSignal }, - ): Promise { + ): Promise { const resolved = resolveModelOverride([selector.raw], this.#host.modelRegistry, this.#host.settings); const candidate = resolved.model ?? this.#host.modelRegistry.find(selector.provider, selector.id); if (!candidate) { throw new Error(`Retry fallback model not found: ${selector.raw}`); } const apiKey = - options?.apiKey ?? (await this.#host.modelRegistry.getApiKey(candidate, this.#host.sessionId(), options)); + options?.apiKey ?? + (await this.#host.modelRegistry.getApiKey(candidate, this.#host.sessionId(), { signal: options?.signal })); if (!apiKey) { throw new Error(`No API key for retry fallback ${selector.raw}`); } - if (options?.signal?.aborted) return; + if (options?.signal?.aborted) return false; // Capture the configured selector (auto-aware) so a fallback chain preserves // `auto` instead of collapsing it to the level it resolved to this turn. @@ -1101,7 +1293,15 @@ export class TurnRecovery { ? requestedThinkingLevel : clampThinkingLevelToCeiling(candidate, requestedThinkingLevel, this.#host.thinkingLevelCeiling()); const candidateSelector = formatModelStringWithRouting(candidate); + const previousModel = this.#host.model(); await this.#host.setModelWithProviderSessionReset(candidate); + if (options?.signal?.aborted) { + if (previousModel && this.#host.model() === candidate) { + await this.#host.setModelWithProviderSessionReset(previousModel); + } + return false; + } + if (this.#host.model() !== candidate) return false; this.#host.sessionManager.appendModelChange(candidateSelector, EPHEMERAL_MODEL_CHANGE_ROLE, true); this.#host.settings.getStorage()?.recordModelUsage(candidateSelector); this.#host.setThinkingLevel(nextThinkingLevel); @@ -1123,6 +1323,7 @@ export class TurnRecovery { to: selector.raw, role, }); + return true; } async #tryRetryModelFallback(currentSelector: string, options?: { pinFallback?: boolean }): Promise { @@ -1140,8 +1341,7 @@ export class TurnRecovery { if (ceiling !== undefined && !modelSupportsEffortCeiling(candidate, ceiling)) continue; const apiKey = await this.#host.modelRegistry.getApiKey(candidate, this.#host.sessionId()); if (!apiKey) continue; - await this.applyRetryFallbackCandidate(role, selector, currentSelector, options); - return true; + return this.applyRetryFallbackCandidate(role, selector, currentSelector, options); } return false; @@ -1167,6 +1367,7 @@ export class TurnRecovery { const model = this.#activeFireworksFastModel(); if (!model) return false; if (message.stopReason !== "error") return false; + if (this.#isUsagePreflightBlocked(message)) return false; if (this.#hasReplayUnsafeOutput(message)) return false; // A content refusal/sensitivity stop is the model's decision, not a route // failure — switching to the base model would just re-trigger it. @@ -1190,6 +1391,7 @@ export class TurnRecovery { */ isHardErrorFallbackEligible(message: AssistantMessage): boolean { if (message.stopReason !== "error") return false; + if (this.#isUsagePreflightBlocked(message)) return false; const model = this.#host.model(); if (!model) return false; const retrySettings = this.#host.settings.getGroup("retry"); @@ -1385,6 +1587,22 @@ export class TurnRecovery { let delayMs = staleOpenAIResponsesReplayError ? 0 : calculateRetryBackoffDelayMs(retrySettings.baseDelayMs, this.#retryAttempt); + // Concurrency caps shed-and-backoff (5s) rather than burning a sibling + // credential, so the usage-limit rotation branch below is deliberately + // skipped for them. Apply the reason-based backoff to the transient + // same-model retry path too — otherwise the default exponential base + // (≈500ms) re-hits the cap immediately and burns the retry budget while + // the concurrency slot stays occupied. A categorical 402 billing cap whose + // body merely mentions concurrency is still a usage limit (handled below), + // so gate on the flag matching the rotation decision. + if ( + !staleOpenAIResponsesReplayError && + !AIError.is(id, AIError.Flag.UsageLimit) && + parseRateLimitReason(errorMessage) === "CONCURRENT_LIMIT" + ) { + const concurrentBackoffMs = calculateRateLimitBackoffMs("CONCURRENT_LIMIT"); + if (concurrentBackoffMs > delayMs) delayMs = concurrentBackoffMs; + } let switchedCredential = false; let switchedModel = false; // Set when a usage-limit error pinned the wait to credential diff --git a/packages/coding-agent/src/session/unexpected-stop-classifier.ts b/packages/coding-agent/src/session/unexpected-stop-classifier.ts index 5bf7c3456..bb3525eab 100644 --- a/packages/coding-agent/src/session/unexpected-stop-classifier.ts +++ b/packages/coding-agent/src/session/unexpected-stop-classifier.ts @@ -34,14 +34,23 @@ export interface ClassifyUnexpectedStopDeps { export function isUnexpectedStopCandidate(message: AssistantMessage): boolean { if (message.stopReason !== "stop") return false; - let hasText = false; + let hasContent = false; for (const content of message.content) { if (content.type === "toolCall") return false; if (content.type === "text" && /\S/.test(content.text)) { - hasText = true; + hasContent = true; + } + // A signed thinking-only stop is still a candidate: reasoning models can + // trap the intended response (or a truncated fragment) in a thinking block + // with no text. #isEmptyAssistantStop treats a non-whitespace signature as + // terminal (not empty), so such stops bypass the empty-stop path entirely. + // Match that predicate here — unsigned thinking-only stops stay with the + // empty-stop retry path (and its cap) rather than being re-handled here. + if (content.type === "thinking" && /\S/.test(content.thinking) && /\S/.test(content.thinkingSignature ?? "")) { + hasContent = true; } } - return hasText; + return hasContent; } export async function classifyUnexpectedStop( diff --git a/packages/coding-agent/src/slash-commands/helpers/stats-dashboard.ts b/packages/coding-agent/src/slash-commands/helpers/stats-dashboard.ts index 8cb1c944f..a22da28a6 100644 --- a/packages/coding-agent/src/slash-commands/helpers/stats-dashboard.ts +++ b/packages/coding-agent/src/slash-commands/helpers/stats-dashboard.ts @@ -4,6 +4,7 @@ import * as openUtils from "../../utils/open"; export const DEFAULT_STATS_DASHBOARD_PORT = 3847; interface StatsDashboardServer { + hostname: string; port: number; stop: () => void; } @@ -64,7 +65,7 @@ export async function launchStatsDashboard(args: StatsDashboardArgs): Promise(); + const disabledProviders = new Set(settings.get("disabledProviders")); for (const pattern of modelPatterns) { const resolved = resolveModelOverride([pattern], modelRegistry, settings); if (!resolved.model) continue; + if (disabledProviders.has(resolved.model.provider)) continue; const selector = resolved.explicitThinkingLevel ? formatModelSelectorValue(formatModelStringWithRouting(resolved.model), resolved.thinkingLevel) : formatModelStringWithRouting(resolved.model); @@ -181,7 +185,10 @@ function resolveSubagentRetryFallbackCandidates( return candidates; } -function resolveSubagentDefaultRetryFallbackChain(settings: Settings): string[] | undefined { +function resolveSubagentDefaultRetryFallbackChain( + settings: Settings, + modelRegistry: ModelRegistry, +): string[] | undefined { const fallbackChain = settings.get("retry.fallbackChains")?.default; if ( !Array.isArray(fallbackChain) || @@ -190,7 +197,11 @@ function resolveSubagentDefaultRetryFallbackChain(settings: Settings): string[] ) { return undefined; } - return fallbackChain; + const disabledProviders = new Set(settings.get("disabledProviders")); + return fallbackChain.filter(entry => { + const resolved = resolveModelOverride([entry], modelRegistry, settings); + return !resolved.model || !disabledProviders.has(resolved.model.provider); + }); } function installSubagentRetryFallbackChain(args: { @@ -459,6 +470,8 @@ export interface ExecutorOptions { * set this false so disposal unregisters them instead of leaving idle peers. */ keepAlive?: boolean; + /** Internal ownership handoff for cleanup that outlives the visible Task result. */ + onCleanupDeferred?: (completion: Promise) => void; } function parseStringifiedJson(value: unknown): unknown { @@ -1971,9 +1984,7 @@ async function driveSessionToYield( // yield: the next iteration's ladder demands a fresh one. } - if (monitor.yieldCalled()) { - await session.waitForIdle(); - } else { + if (!monitor.yieldCalled()) { await awaitAbortable(session.waitForIdle()); } @@ -2149,14 +2160,17 @@ async function finalizeRunResult(args: FinalizeRunArgs): Promise { exitCode = 1; } const wasAborted = - runtimeLimitExceeded || abortedViaYield || (!hasYield && (done.aborted || signal?.aborted || false)); + runtimeLimitExceeded || Boolean(done.aborted) || abortedViaYield || (!hasYield && Boolean(signal?.aborted)); const finalAbortReason = wasAborted ? runtimeLimitExceeded ? monitor.resolveAbortReasonText() - : abortedViaYield - ? yieldAbortReason - : (done.abortReason ?? - (signal?.aborted ? monitor.resolveSignalAbortReason() : monitor.resolveAbortReasonText())) + : done.aborted + ? (done.abortReason ?? monitor.resolveAbortReasonText()) + : abortedViaYield + ? yieldAbortReason + : signal?.aborted + ? monitor.resolveSignalAbortReason() + : monitor.resolveAbortReasonText() : undefined; progress.status = wasAborted ? "aborted" : exitCode === 0 ? "completed" : "failed"; monitor.scheduleProgress(true); @@ -2360,15 +2374,27 @@ export async function finalizeSubagentLifecycle(args: { isolated: boolean; agentIdleTtlMs: number; reviveSession: AgentReviver | null; + cleanupDeadlineAt?: number; + onCleanupDeferred?: (completion: Promise) => void; }): Promise { const registry = AgentRegistry.global(); const ref = registry.get(args.id); const ownsRef = Boolean(ref && ref.session === args.session); + const cleanupDeadlineAt = args.cleanupDeadlineAt ?? Date.now() + 5000; const disposeSession = async (): Promise => { + const disposal = args.session.dispose(); + const remainingMs = Math.max(0, cleanupDeadlineAt - Date.now()); try { - await untilAborted(AbortSignal.timeout(5000), () => args.session.dispose()); - } catch { - // Ignore cleanup errors + await untilAborted(AbortSignal.timeout(remainingMs), () => disposal); + } catch (error) { + if (Date.now() >= cleanupDeadlineAt) { + args.onCleanupDeferred?.(disposal); + return; + } + logger.warn("Subagent session cleanup failed", { + id: args.id, + error: error instanceof Error ? error.message : String(error), + }); } }; @@ -2794,7 +2820,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise[] = []; + let deferredSessionShutdown: Promise | undefined; + const deferCleanup = (completion: Promise): void => { + lateCleanups.push(completion); + exitCode = 1; + aborted = true; + abortReasonText = `cleanup exceeded ${TASK_ABORT_CLEANUP_GRACE_MS} ms`; + error ??= `Task aborted. Cleanup did not finish within ${TASK_ABORT_CLEANUP_GRACE_MS} ms. ${cleanupChangeStatus}`; + }; if (abortSignal.aborted) { aborted = monitor.isAbortedRun(); if (aborted) { @@ -3226,10 +3266,21 @@ export async function runSubprocess(options: ExecutorOptions): Promise monitor.waitForActiveSessionAbort()); - } catch { - // Ignore abort cleanup timeouts/errors; terminal disposal below is still best-effort. + await untilAborted( + AbortSignal.timeout(Math.max(0, cleanupDeadlineAt - Date.now())), + () => activeSessionAbort, + ); + } catch (cleanupError) { + if (Date.now() >= cleanupDeadlineAt) { + deferCleanup(activeSessionAbort); + } else { + logger.warn("Subagent abort cleanup failed", { + id, + error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + }); + } } if (unsubscribe) { try { @@ -3239,6 +3290,17 @@ export async function runSubprocess(options: ExecutorOptions): Promise { + deferredSessionShutdown = completion; + deferCleanup(completion); + }, }); } - // Structured-concurrency reap: cancel and await ALL surviving owner - // jobs (abort paths; suppressed/watched jobs the model left behind) - // so isolation capture/cleanup never races a live process writing - // into the worktree. This never proceeds while an owner process is - // live: cancellation SIGKILL-escalates, so settlement is expected - // within one interval — an unkillable process blocks here visibly - // (with periodic warnings) instead of silently racing teardown. - const jobManager = AsyncJobManager.instance(); if (jobManager) { - jobManager.cancelAll({ ownerId: id }); - while (!(await jobManager.waitForOwnerJobs(id, { timeoutMs: 10_000 }))) { - logger.warn("Subagent async jobs still settling; delaying teardown until process exit", { id }); + if (deferredSessionShutdown) { + const finalReap = Promise.allSettled([deferredSessionShutdown]).then(async () => { + const reap = await jobManager.cancelAndReapOwnerJobs(id, Date.now()); + await reap.completion; + }); + lateCleanups.push(finalReap); + } else { + const reap = await jobManager.cancelAndReapOwnerJobs(id, cleanupDeadlineAt); + if (!reap.settled) { + deferCleanup(reap.completion); + logger.warn("Subagent async job cleanup exceeded its deadline after session shutdown", { + id, + pendingJobIds: reap.pendingJobIds, + }); + } } } + if (lateCleanups.length > 0) { + const completion = Promise.allSettled(lateCleanups).then(() => {}); + trackLateCleanup(completion, { id, resource: "subagent" }); + options.onCleanupDeferred?.(completion); + } } // Launch-latency breakdown (subagent invocation → first chat dispatch). diff --git a/packages/coding-agent/src/task/isolation-runner.ts b/packages/coding-agent/src/task/isolation-runner.ts index 9debeb5d7..4d492bf90 100644 --- a/packages/coding-agent/src/task/isolation-runner.ts +++ b/packages/coding-agent/src/task/isolation-runner.ts @@ -24,6 +24,7 @@ import { AgentRegistry } from "../registry/agent-registry"; import type { ToolSession } from "../tools"; import { generateCommitMessage } from "../utils/commit-message-generator"; import * as git from "../utils/git"; +import { trackLateCleanup } from "../utils/late-cleanup"; import type { ExecutorOptions } from "./executor"; import { runSubprocess } from "./executor"; import type { SingleResult } from "./types"; @@ -156,6 +157,7 @@ async function writeIsolationPatch( */ export async function runIsolatedSubprocess(opts: IsolatedRunOptions): Promise { let handle: IsolationHandle | undefined; + let deferredCleanup: Promise | undefined; try { const taskBaseline = structuredClone(opts.context.baseline); handle = await ensureIsolation(opts.context.repoRoot, opts.agentId, opts.preferredBackend); @@ -165,7 +167,12 @@ export async function runIsolatedSubprocess(opts: IsolatedRunOptions): Promise { + deferredCleanup = completion; + opts.baseOptions.onCleanupDeferred?.(completion); + }, }); + if (deferredCleanup) return result; if (opts.mergeMode === "branch" && result.exitCode === 0) { try { const commitResult = await commitToBranch( @@ -226,7 +233,18 @@ export async function runIsolatedSubprocess(opts: IsolatedRunOptions): Promise cleanupIsolation(isolationHandle)), + { + agentId: opts.agentId, + resource: "isolation", + }, + ); + } else { + await cleanupIsolation(isolationHandle); + } } } } diff --git a/packages/coding-agent/src/task/structured-subagent.ts b/packages/coding-agent/src/task/structured-subagent.ts index 836fa73c7..a1e29ffb3 100644 --- a/packages/coding-agent/src/task/structured-subagent.ts +++ b/packages/coding-agent/src/task/structured-subagent.ts @@ -20,6 +20,7 @@ import type { TaskEffort } from "../thinking"; import type { ToolSession } from "../tools"; import { isIrcEnabled } from "../tools/hub"; import { buildOutputValidator } from "../tools/output-schema-validator"; +import { trackLateCleanup } from "../utils/late-cleanup"; import { type DiscoveryResult, discoverAgents, getAgent } from "./discovery"; import { type ExecutorOptions, runSubprocess } from "./executor"; import { @@ -550,12 +551,16 @@ export async function runStructuredSubagent(request: StructuredSubagentRequest): let mergeSummary = ""; let requiresRecoveryArtifacts = false; let completedSuccessfully = false; + let deferredCleanup: Promise | undefined; try { const id = await reserveStructuredSubagentId(request.session, { ...request.identity, label: request.identity?.label ?? (request.invocationKind === "eval" ? "EvalAgent" : undefined), }); const baseOptions = buildExecutorOptions(request, policy, lease, id); + baseOptions.onCleanupDeferred = completion => { + deferredCleanup = completion; + }; baseOptions.planReference = await loadPlanReference(request, policy); let isolationContext: IsolationContext | null = null; if (policy.isIsolated) { @@ -649,8 +654,18 @@ export async function runStructuredSubagent(request: StructuredSubagentRequest): (policy.isIsolated && (!policy.applyChanges || changesApplied === false || requiresRecoveryArtifacts)); const shouldCleanup = lease.temporary && !shouldRetainArtifacts; if (shouldCleanup) { - await fs.rm(lease.artifactsDir, { recursive: true, force: true }); - lease.unregister?.(); + const cleanupArtifacts = async (): Promise => { + await fs.rm(lease.artifactsDir, { recursive: true, force: true }); + lease.unregister?.(); + }; + if (deferredCleanup) { + trackLateCleanup(deferredCleanup.then(cleanupArtifacts), { + resource: "artifacts", + artifactsDir: lease.artifactsDir, + }); + } else { + await cleanupArtifacts(); + } } } } diff --git a/packages/coding-agent/src/tiny/worker.ts b/packages/coding-agent/src/tiny/worker.ts index 0f60183ea..ca056baa6 100644 --- a/packages/coding-agent/src/tiny/worker.ts +++ b/packages/coding-agent/src/tiny/worker.ts @@ -94,7 +94,7 @@ function createStopOnTextCriteria( this.#text = text; } - _call(inputIds: number[][]): boolean[] { + override _call(inputIds: number[][]): boolean[] { return inputIds.map(ids => { const tail = ids.slice(-STOP_DECODE_WINDOW_TOKENS); const decoded = this.#tokenizer.decode(tail, { diff --git a/packages/coding-agent/src/tools/bash-interceptor.ts b/packages/coding-agent/src/tools/bash-interceptor.ts index 6d2e83613..baa6cfbdc 100644 --- a/packages/coding-agent/src/tools/bash-interceptor.ts +++ b/packages/coding-agent/src/tools/bash-interceptor.ts @@ -96,10 +96,14 @@ function withoutLeadingEnvironmentAssignments(command: string): string | null { function interceptionCandidates(command: string): string[] { const candidates = [command.trim()]; - const segments = extractFlatShellCommandSegments(command); - candidates.push(...segments.map(segment => segment.trim())); - for (const segment of segments) { - const withoutAssignments = withoutLeadingEnvironmentAssignments(segment); + for (const segment of extractFlatShellCommandSegments(command)) { + // A segment that consumes the previous stage's stdout via `|` reads piped + // stdin, which no path-based dedicated tool (read/grep/glob) — nor any + // other dedicated tool — can replace, so it is not an interception + // candidate. Standalone and first-stage commands still match. + if (segment.pipedStdin) continue; + candidates.push(segment.text); + const withoutAssignments = withoutLeadingEnvironmentAssignments(segment.text); if (withoutAssignments) candidates.push(withoutAssignments); } return candidates; diff --git a/packages/coding-agent/src/tools/browser/launch.ts b/packages/coding-agent/src/tools/browser/launch.ts index c3bb19b05..c371e5eac 100644 --- a/packages/coding-agent/src/tools/browser/launch.ts +++ b/packages/coding-agent/src/tools/browser/launch.ts @@ -121,9 +121,9 @@ async function loadBrowsers(): Promise { } /** - * Resolve the Chromium executable puppeteer will launch, lazily downloading it - * on first use via @puppeteer/browsers. Skipped when a system Chromium (NixOS) - * or PUPPETEER_EXECUTABLE_PATH is set. The browser is cached under + * Resolve the Chromium executable puppeteer will launch, honoring + * PUPPETEER_EXECUTABLE_PATH before system browser detection and lazily + * downloading Chromium otherwise. The browser is cached under * ~/.omp/puppeteer (getPuppeteerDir). Returns undefined when platform * detection fails (puppeteer default resolution takes over). Exported so * real-browser tests can probe launchability and skip on hosts missing @@ -131,10 +131,10 @@ async function loadBrowsers(): Promise { */ let chromiumExecutablePromise: Promise | undefined; export async function ensureChromiumExecutable(): Promise { - const sysChrome = resolveSystemChromium(); - if (sysChrome) return sysChrome; const envPath = process.env.PUPPETEER_EXECUTABLE_PATH; if (envPath) return envPath; + const sysChrome = resolveSystemChromium(); + if (sysChrome) return sysChrome; if (chromiumExecutablePromise) return chromiumExecutablePromise; chromiumExecutablePromise = (async () => { @@ -199,10 +199,16 @@ function isExecutableFile(p: string): boolean { } } -function systemChromiumCandidates(): string[] { - const home = os.homedir(); +/** Flatpak application id published by the Ungoogled Chromium project. */ +const UNGOOGLED_CHROMIUM_FLATPAK_ID = "io.github.ungoogled_software.ungoogled_chromium"; + +function systemChromiumCandidates( + platform: NodeJS.Platform = process.platform, + home = os.homedir(), + which: (name: string) => string | null | undefined = $which, +): string[] { const candidates: string[] = []; - switch (process.platform) { + switch (platform) { case "darwin": { for (const root of ["/Applications", path.join(home, "Applications")]) { candidates.push( @@ -219,7 +225,7 @@ function systemChromiumCandidates(): string[] { case "linux": { const names = ["google-chrome-stable", "google-chrome", "chromium", "chromium-browser", "chrome"]; for (const name of names) { - const found = $which(name); + const found = which(name); if (found) candidates.push(found); } candidates.push( @@ -238,6 +244,19 @@ function systemChromiumCandidates(): string[] { if (onNixos) { candidates.push(path.join(home, ".nix-profile/bin/chromium"), "/run/current-system/sw/bin/chromium"); } + for (const name of ["ungoogled-chromium", "ungoogled-chromium-browser"]) { + const found = which(name); + if (found) candidates.push(found); + } + candidates.push( + // Ungoogled Chromium. Distro and AUR packages that keep the plain + // `chromium` name are already covered above; these are the paths + // unique to it, including the system and per-user Flatpak shims. + "/usr/bin/ungoogled-chromium", + "/usr/bin/ungoogled-chromium-browser", + `/var/lib/flatpak/exports/bin/${UNGOOGLED_CHROMIUM_FLATPAK_ID}`, + path.join(home, ".local/share/flatpak/exports/bin", UNGOOGLED_CHROMIUM_FLATPAK_ID), + ); break; } case "win32": { @@ -866,6 +885,15 @@ export async function applyStealthPatches( await injectStealthScripts(page); } +/** Exposes executable candidates for detection tests. */ +export function systemChromiumCandidatesForTest( + platform: NodeJS.Platform = process.platform, + home?: string, + which?: (name: string) => string | null | undefined, +): string[] { + return systemChromiumCandidates(platform, home, which); +} + export function stealthIgnoreDefaultArgsForTest(executablePath: string | undefined): string[] { return stealthIgnoreDefaultArgs(executablePath); } diff --git a/packages/coding-agent/src/tools/glob.ts b/packages/coding-agent/src/tools/glob.ts index 882205191..7a8f797ad 100644 --- a/packages/coding-agent/src/tools/glob.ts +++ b/packages/coding-agent/src/tools/glob.ts @@ -6,7 +6,7 @@ import type { ToolExample } from "@oh-my-pi/pi-ai"; import * as natives from "@oh-my-pi/pi-natives"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; -import { formatGroupedPaths, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils"; +import { formatGroupedPaths, hasFsCode, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import { InternalUrlRouter } from "../internal-urls"; import { splitMemoryGlobPattern } from "../internal-urls/memory-protocol"; @@ -415,7 +415,9 @@ export class GlobTool implements AgentTool { try { stat = await fs.promises.stat(target.searchPath); } catch (err) { - if (isEnoent(err)) { + // ENAMETOOLONG can never name a real target; surface a clean + // "Path not found" instead of leaking the raw errno (issue #7597). + if (isEnoent(err) || hasFsCode(err, "ENAMETOOLONG")) { if (isSingle) throw new ToolError(`Path not found: ${scopePath}`); return []; } diff --git a/packages/coding-agent/src/tools/path-utils.ts b/packages/coding-agent/src/tools/path-utils.ts index aea44a068..a69bfb2e2 100644 --- a/packages/coding-agent/src/tools/path-utils.ts +++ b/packages/coding-agent/src/tools/path-utils.ts @@ -3,7 +3,7 @@ import * as os from "node:os"; import * as path from "node:path"; import * as url from "node:url"; import { glob } from "@oh-my-pi/pi-natives"; -import { isEnoent, isEnotdir, stripWindowsExtendedLengthPathPrefix, untilAborted } from "@oh-my-pi/pi-utils"; +import { hasFsCode, isEnoent, isEnotdir, stripWindowsExtendedLengthPathPrefix, untilAborted } from "@oh-my-pi/pi-utils"; import type { Skill } from "../extensibility/skills"; import { InternalUrlRouter, type LocalProtocolOptions } from "../internal-urls"; import { ToolAbortError, ToolError } from "./tool-errors"; @@ -337,6 +337,13 @@ export function splitPathAndSel(rawPath: string): { path: string; sel?: string } * plus selector `1-2` (issue #4618). `lstat` inspects the entry itself, so a * dangling symlink is still detected as present; ambiguous errors resolve to * `"unknown"` so callers keep the raw path instead of guessing. + * + * `ENAMETOOLONG` resolves to `"missing"` rather than `"unknown"`: a path whose + * component or whole length exceeds the OS limit can never name a real single + * entry, so it is strictly stronger evidence of non-existence than `ENOENT`. + * Without this, a semicolon-joined `path` list long enough to trip the limit + * (bare filenames past `NAME_MAX`, or a total past `PATH_MAX`) was read as one + * literal path and the delimited split was suppressed (issue #7597). */ export async function probeLiteralPathExists(filePath: string, cwd: string): Promise<"exists" | "missing" | "unknown"> { const resolved = resolveReadPath(filePath, cwd); @@ -344,7 +351,7 @@ export async function probeLiteralPathExists(filePath: string, cwd: string): Pro await fs.promises.lstat(resolved); return "exists"; } catch (err) { - if (isEnoent(err) || isEnotdir(err)) return "missing"; + if (isEnoent(err) || isEnotdir(err) || hasFsCode(err, "ENAMETOOLONG")) return "missing"; return "unknown"; } } @@ -762,7 +769,10 @@ async function delimitedPathPartResolves(entry: string, cwd: string, splitter: P await fs.promises.stat(absoluteBasePath); return true; } catch (err) { - if (isEnoent(err)) return false; + // ENOENT and ENAMETOOLONG both mean this string cannot name an existing + // path, so the whole entry does not resolve and the delimited split may + // proceed (issue #7597). Other errors (EACCES, transient I/O) stay fatal. + if (isEnoent(err) || hasFsCode(err, "ENAMETOOLONG")) return false; throw err; } } diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index c2d98dbff..1a9140064 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -737,6 +737,8 @@ export interface ReadToolDetails { meta?: OutputMeta; /** Full on-disk byte size recorded before applying a file range. */ fileSize?: number; + /** Full source line count when the read reached EOF and the count is exact. */ + totalLines?: number; /** Raw text + start line for user-visible TUI rendering, set when content is text-like. * Mirrors the same lines the model receives but without hashline/line-number prefixes, * so the TUI can render the file content with its own gutter without re-parsing the formatted text. */ @@ -1397,6 +1399,7 @@ export class ReadTool implements AgentTool { const details = options.details ?? {}; const allLines = text.split("\n"); const totalLines = allLines.length; + details.totalLines = totalLines; // User-requested 0-indexed range start. Lines BEFORE this are leading // context (added below if offset is explicit). const requestedStart = offset ? Math.max(0, offset - 1) : 0; @@ -1592,6 +1595,7 @@ export class ReadTool implements AgentTool { const details = options.details ?? {}; const allLines = text.split("\n"); const totalLines = allLines.length; + details.totalLines = totalLines; const shouldAddHashLines = displayMode.hashLines; const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers; const hashContext = @@ -2908,6 +2912,7 @@ export class ReadTool implements AgentTool { details = {}; sourcePath = absolutePath; } + if (reachedEof) details.totalLines = totalFileLines; if (hashContext?.tag) { recordSeenLinesFromBody(this.session, absolutePath, hashContext.tag, outputText); @@ -3239,6 +3244,7 @@ export class ReadTool implements AgentTool { if (!rawSelector && artifact.size > MAX_ARTIFACT_RAW_INLINE_BYTES) { outputText += `\n\n[${this.#formatArtifactWorkflowNotice(artifact, artifactUrl)}]`; } + if (reachedEof) details.totalLines = totalFileLines; if (displayContent) details.displayContent = displayContent; if (truncationInfo) details.truncation = truncationInfo.result; const resultBuilder = toolResult(details) diff --git a/packages/coding-agent/src/tools/shell-tokenize.ts b/packages/coding-agent/src/tools/shell-tokenize.ts index faa50bb72..f93f3e276 100644 --- a/packages/coding-agent/src/tools/shell-tokenize.ts +++ b/packages/coding-agent/src/tools/shell-tokenize.ts @@ -83,25 +83,47 @@ export function tokenizeShellSegments(command: string): string[][] { } /** - * Returns the original text of flat shell command segments. Unlike + * A flat shell command segment with the context needed to decide interception. + * + * @see extractFlatShellCommandSegments + */ +export interface FlatShellCommandSegment { + /** Original segment text with quoting and escaping preserved. */ + text: string; + /** + * True when this segment consumes the previous stage's stdout via an + * unquoted `|` or `|&`. Blank and comment-only continuation lines preserve + * the pending pipe state. Such a stage reads piped stdin, so path-based + * dedicated tools (read/grep/glob) cannot replace it. `||`, `;`, `&`, and + * `&&` start an independent command and leave this false. + */ + pipedStdin: boolean; +} + +/** + * Returns the flat shell command segments with the original text of each. Unlike * `tokenizeShellSegments`, this preserves quoting and escaping so the results - * are safe to match against user-configured regular expressions. + * are safe to match against user-configured regular expressions, and flags + * segments that receive piped stdin. * * The extractor deliberately declines to split syntax whose execution context * cannot be determined with this small scanner (heredocs, command substitution, * backticks, grouping, and malformed quoting). Callers must still check the * complete input in that case. */ -export function extractFlatShellCommandSegments(command: string): string[] { - const segments: string[] = []; +export function extractFlatShellCommandSegments(command: string): FlatShellCommandSegment[] { + const segments: FlatShellCommandSegment[] = []; let segmentStart = 0; let inSingle = false; let inDouble = false; let atWordStart = true; + let currentPiped = false; - const pushSegment = (end: number) => { + const pushSegment = (end: number): boolean => { const segment = command.slice(segmentStart, end).trim(); - if (segment.length > 0) segments.push(segment); + if (segment.length === 0) return false; + segments.push({ text: segment, pipedStdin: currentPiped }); + return true; }; for (let i = 0; i < command.length; i++) { @@ -154,12 +176,14 @@ export function extractFlatShellCommandSegments(command: string): string[] { return []; } if (ch === "#" && atWordStart) { - pushSegment(i); + const pushed = pushSegment(i); const newline = command.indexOf("\n", i + 1); if (newline === -1) return segments; i = newline; segmentStart = newline + 1; atWordStart = true; + // Preserve a pending pipe through a comment-only continuation. + if (pushed) currentPiped = false; continue; } const isRedirectionOperatorCharacter = @@ -169,8 +193,13 @@ export function extractFlatShellCommandSegments(command: string): string[] { ? command[i - 1] === ">" || command[i - 1] === "<" || command[i + 1] === ">" : false; if ((ch === "\n" || ch === ";" || ch === "|" || ch === "&") && !isRedirectionOperatorCharacter) { - pushSegment(i); - if ((ch === "|" || ch === "&") && command[i + 1] === ch) i++; + const pushed = pushSegment(i); + const doubled = (ch === "|" || ch === "&") && command[i + 1] === ch; + const pipeStderr = ch === "|" && command[i + 1] === "&"; + if (doubled || pipeStderr) i++; + // `|` and `|&` pipe into the next segment. Blank continuation + // lines preserve that pending state; all other operators reset it. + if (pushed || ch !== "\n") currentPiped = ch === "|" && !doubled; segmentStart = i + 1; atWordStart = true; continue; diff --git a/packages/coding-agent/src/utils/changelog.ts b/packages/coding-agent/src/utils/changelog.ts index 456fc23f2..b64431dae 100644 --- a/packages/coding-agent/src/utils/changelog.ts +++ b/packages/coding-agent/src/utils/changelog.ts @@ -198,9 +198,10 @@ function parseChangelogContent(content: string): ChangelogEntry[] { } /** - * Compare versions. Returns: -1 if v1 < v2, 0 if v1 === v2, 1 if v1 > v2 + * Compare changelog entries by their parsed version parts. + * Returns: -1 if v1 < v2, 0 if v1 === v2, 1 if v1 > v2 */ -export function compareVersions(v1: ChangelogEntry, v2: ChangelogEntry): number { +export function compareChangelogEntries(v1: ChangelogEntry, v2: ChangelogEntry): number { if (v1.major !== v2.major) return v1.major - v2.major; if (v1.minor !== v2.minor) return v1.minor - v2.minor; return v1.patch - v2.patch; @@ -232,7 +233,7 @@ export function getNewEntries(entries: ChangelogEntry[], lastVersion: string): C return []; } - return entries.filter(entry => compareVersions(entry, parsedLastVersion) > 0); + return entries.filter(entry => compareChangelogEntries(entry, parsedLastVersion) > 0); } /** @@ -328,7 +329,7 @@ export async function resolveStartupChangelogForDisplay(options: { } if (options.mode === "hidden") { const currentVersion = parseChangelogVersion(options.currentVersion); - if (currentVersion && compareVersions(currentVersion, parsedLastVersion) > 0) { + if (currentVersion && compareChangelogEntries(currentVersion, parsedLastVersion) > 0) { await writeLastChangelogVersion(options.currentVersion, options.agentDir); } return undefined; diff --git a/packages/coding-agent/src/utils/late-cleanup.ts b/packages/coding-agent/src/utils/late-cleanup.ts new file mode 100644 index 000000000..7d9ae2d8d --- /dev/null +++ b/packages/coding-agent/src/utils/late-cleanup.ts @@ -0,0 +1,17 @@ +import { logger } from "@oh-my-pi/pi-utils"; + +const pendingCleanups = new Set>(); + +/** Keep timed-out cleanup reachable until its resources really settle. */ +export function trackLateCleanup(work: Promise, context: Record): void { + let tracked: Promise; + tracked = work + .catch(error => { + logger.warn("Deferred cleanup failed", { + ...context, + error: error instanceof Error ? error.message : String(error), + }); + }) + .finally(() => pendingCleanups.delete(tracked)); + pendingCleanups.add(tracked); +} diff --git a/packages/coding-agent/src/vibe/runtime.ts b/packages/coding-agent/src/vibe/runtime.ts index eb1983042..1da18d510 100644 --- a/packages/coding-agent/src/vibe/runtime.ts +++ b/packages/coding-agent/src/vibe/runtime.ts @@ -69,8 +69,8 @@ const TRACE_LINE_MAX = 120; const DEFAULT_WAIT_TIMEOUT_MS = 30_000; /** Response text cap inside a delivered turn result; full output stays at agent://. */ const RESPONSE_PREVIEW_MAX = 6000; -/** Grace period for abort-aware turns before teardown detaches a stuck provider/tool call. */ -const CANCELLED_TURN_SETTLE_GRACE_MS = 250; +/** Grace period for Vibe cancellation/release cleanup before teardown detaches (ms). */ +const VIBE_TEARDOWN_GRACE_MS = 5_000; const VIBE_LIFECYCLE_CUSTOM_TYPE = "vibe-session-lifecycle"; const VIBE_LIFECYCLE_VERSION = 1; @@ -251,6 +251,48 @@ export interface VibeWaitOutcome { timedOut: boolean; } +type VibeTeardownStatus = "pending" | "settled" | "failed"; + +interface TrackedVibeTeardown { + promise: Promise; + status: () => VibeTeardownStatus; +} + +/** Observe cleanup without propagating a detached late rejection. */ +function trackVibeTeardown(promise: Promise, onError: (error: unknown) => void): TrackedVibeTeardown { + let status: VibeTeardownStatus = "pending"; + return { + promise: promise.then( + () => { + status = "settled"; + }, + error => { + status = "failed"; + onError(error); + }, + ), + status: () => status, + }; +} + +/** Wait for cleanup only until the caller's shared absolute deadline. */ +async function waitForVibeTeardown(tasks: readonly TrackedVibeTeardown[], deadline: number): Promise { + if (tasks.length === 0 || tasks.every(task => task.status() !== "pending")) return true; + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) return false; + const timeout = Promise.withResolvers(); + const timer = setTimeout(timeout.resolve, remainingMs); + timer.unref?.(); + try { + return await Promise.race([ + Promise.allSettled(tasks.map(task => task.promise)).then(() => true), + timeout.promise.then(() => false), + ]); + } finally { + clearTimeout(timer); + } +} + /** Normalize a text fragment to one bounded roster/trace line. */ function firstLine(text: string, max = 100): string { return oneLineLabel(text, max); @@ -354,24 +396,6 @@ function mergeTrace(turn: VibeTurn, progress: AgentProgress): void { /** Thrown from a turn job body so the job manager marks the job failed while carrying the formatted result. */ export class VibeTurnError extends Error {} -async function awaitCancelledTurnJobs(jobs: ReadonlySet): Promise { - if (jobs.size === 0) return; - const settled = Promise.allSettled([...jobs].map(job => job.promise)).then(() => true); - const timeout = Promise.withResolvers(); - const timer = setTimeout(() => timeout.resolve(false), CANCELLED_TURN_SETTLE_GRACE_MS); - timer.unref(); - try { - if (!(await Promise.race([settled, timeout.promise]))) { - logger.warn("vibe: detached cancelled turn that did not settle within teardown grace period", { - jobCount: jobs.size, - graceMs: CANCELLED_TURN_SETTLE_GRACE_MS, - }); - } - } finally { - clearTimeout(timer); - } -} - /** * Process-global registry of vibe worker sessions, scoped by both owner agent * id and stable parent session id. Persisted lifecycle events rebuild idle @@ -419,6 +443,12 @@ export class VibeSessionRegistry { readonly #records = new Map(); readonly #terminationTails = new Map>(); readonly #terminatedScopes = new Set(); + #teardownGraceMs = VIBE_TEARDOWN_GRACE_MS; + + /** Override the teardown grace period for deterministic lifecycle tests. */ + setTeardownGraceForTesting(timeoutMs: number): void { + this.#teardownGraceMs = Math.max(1, timeoutMs); + } ownerScope(session: VibeParentSession): VibeOwnerScope { const parentSessionId = session.getSessionId?.(); @@ -709,11 +739,50 @@ export class VibeSessionRegistry { } } + #trackAgentRelease(id: string, ref: AgentRef, action: "detach" | "release"): TrackedVibeTeardown { + return trackVibeTeardown(AgentLifecycleManager.global().release(id, ref), error => { + logger.warn(`vibe: failed to ${action} worker session`, { + id, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + + #finishAgentRelease(id: string, ref: AgentRef, task: TrackedVibeTeardown, action: "detach" | "release"): void { + if (task.status() === "settled") return; + if (task.status() === "pending") { + logger.warn(`vibe: timed out waiting to ${action} worker session; detaching registry ref`, { id }); + } + AgentRegistry.global().unregister(id, ref); + } + + async #releaseRefWithinDeadline( + id: string, + ref: AgentRef, + deadline: number, + action: "detach" | "release", + ): Promise { + const task = this.#trackAgentRelease(id, ref, action); + await waitForVibeTeardown([task], deadline); + this.#finishAgentRelease(id, ref, task, action); + } + + #trackJobSettlement(record: VibeRecord, job: AsyncJob): TrackedVibeTeardown { + return trackVibeTeardown(job.promise, error => { + logger.warn("vibe: cancelled worker turn cleanup failed", { + id: record.id, + jobId: job.id, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + async #markTerminalRef( id: string, ownerId: string, childSessionFile: string, expected?: AgentRef | null, + teardownDeadline?: number, ): Promise { const registry = AgentRegistry.global(); const existing = registry.get(id); @@ -726,7 +795,9 @@ export class VibeSessionRegistry { } if (existing?.status === "aborted" && !existing.session) return; if (existing && !registry.setStatus(id, "aborted", existing)) return; - if (existing && AgentLifecycleManager.global().has(id, existing)) { + if (existing && teardownDeadline !== undefined) { + await this.#releaseRefWithinDeadline(id, existing, teardownDeadline, "release"); + } else if (existing && AgentLifecycleManager.global().has(id, existing)) { await AgentLifecycleManager.global().release(id, existing); } else if (existing?.session) { await existing.session.dispose(); @@ -1086,19 +1157,48 @@ export class VibeSessionRegistry { this.#records.delete(scopeKey(scope, record.id)); if (record.turn && manager) manager.cancel(record.turn.jobId, { ownerId: record.ownerId }); } - for (const { record, ref } of teardown) { - if (!ref) continue; - try { - await AgentLifecycleManager.global().release(record.id, ref); - } catch (error) { - logger.warn("vibe: failed to detach worker session", { + const deadline = Date.now() + this.#teardownGraceMs; + const cleanup = teardown.map(entry => ({ + ...entry, + releaseTask: entry.ref ? this.#trackAgentRelease(entry.record.id, entry.ref, "detach") : undefined, + jobTask: entry.job ? this.#trackJobSettlement(entry.record, entry.job) : undefined, + })); + await waitForVibeTeardown( + cleanup.flatMap(entry => [entry.releaseTask, entry.jobTask].filter(task => task !== undefined)), + deadline, + ); + for (const { record, ref, releaseTask, job, jobTask } of cleanup) { + if (ref && releaseTask) this.#finishAgentRelease(record.id, ref, releaseTask, "detach"); + if (job && jobTask?.status() === "pending") { + logger.warn("vibe: timed out waiting for cancelled worker turn; cleanup continues in the background", { + id: record.id, + jobId: job.id, + }); + this.#continueSuspendedCleanup(scope, record, jobTask); + } + if (this.#records.has(scopeKey(scope, record.id))) continue; + const lateRef = this.#registeredAgent(record); + if (lateRef && lateRef !== ref) { + await this.#releaseRefWithinDeadline(record.id, lateRef, deadline, "detach"); + } + } + return records.length; + } + + #continueSuspendedCleanup(scope: VibeOwnerScope, record: VibeRecord, jobTask: TrackedVibeTeardown): void { + void jobTask.promise + .then(async () => { + if (this.#records.has(scopeKey(scope, record.id))) return; + const lateRef = this.#registeredAgent(record); + if (!lateRef) return; + await this.#releaseRefWithinDeadline(record.id, lateRef, Date.now() + this.#teardownGraceMs, "detach"); + }) + .catch(error => { + logger.warn("vibe: failed to finish suspended worker cleanup", { id: record.id, error: error instanceof Error ? error.message : String(error), }); - } - } - await awaitCancelledTurnJobs(new Set(teardown.flatMap(entry => (entry.job ? [entry.job] : [])))); - return records.length; + }); } /** Terminate one worker; a tombstone failure still tears it down before reconciliation and error delivery. */ @@ -1144,15 +1244,21 @@ export class VibeSessionRegistry { await this.#persistModeExit(session, scope, records); } catch (error) { if (error instanceof SessionPersistenceIndeterminateError) { - for (const record of records) { - await this.#killRecord(record, session.asyncJobManager, session, "mode-exit", false); - } + const teardownDeadline = Date.now() + this.#teardownGraceMs; + await Promise.all( + records.map(record => + this.#killRecord(record, session.asyncJobManager, session, "mode-exit", false, teardownDeadline), + ), + ); } throw error; } - for (const record of records) { - await this.#killRecord(record, session.asyncJobManager, session, "mode-exit"); - } + const teardownDeadline = Date.now() + this.#teardownGraceMs; + await Promise.all( + records.map(record => + this.#killRecord(record, session.asyncJobManager, session, "mode-exit", true, teardownDeadline), + ), + ); return records.length; } @@ -1162,6 +1268,7 @@ export class VibeSessionRegistry { session: VibeParentSession, reason: VibeTombstoneReason, persistTerminal = true, + teardownDeadline?: number, ): Promise { const registered = this.#registeredAgent(record); const settlingJobs = new Set(); @@ -1198,26 +1305,29 @@ export class VibeSessionRegistry { record.state = "dead"; record.lastActivityAt = Date.now(); record.lastActivity = "killed"; - if (registered) { - try { - await AgentLifecycleManager.global().release(record.id, registered); - } catch (error) { - logger.warn("vibe: failed to release worker session", { - id: record.id, - error: error instanceof Error ? error.message : String(error), - }); - } + const deadline = teardownDeadline ?? Date.now() + this.#teardownGraceMs; + const releaseTask = registered ? this.#trackAgentRelease(record.id, registered, "release") : undefined; + const jobCleanup = [...settlingJobs].map(job => ({ job, task: this.#trackJobSettlement(record, job) })); + await waitForVibeTeardown( + [releaseTask, ...jobCleanup.map(entry => entry.task)].filter(task => task !== undefined), + deadline, + ); + if (registered && releaseTask) this.#finishAgentRelease(record.id, registered, releaseTask, "release"); + const pendingJobs = jobCleanup.filter(entry => entry.task.status() === "pending"); + for (const { job } of pendingJobs) { + logger.warn("vibe: timed out waiting for cancelled worker turn; cleanup continues in the background", { + id: record.id, + jobId: job.id, + }); } - await awaitCancelledTurnJobs(settlingJobs); const terminalRef = registered ?? this.#registeredAgent(record) ?? null; - if (record.childSessionFile) { - try { - const persisted = await SessionManager.peekSessionInit(record.childSessionFile); - if (persisted?.init) - await this.#markTerminalRef(record.id, record.ownerId, record.childSessionFile, terminalRef); - } catch { - // A turn killed before session initialization has no readable history to retain. - } + await this.#markTerminalRecord(record, terminalRef, deadline); + if (pendingJobs.length > 0) { + this.#continueKilledCleanup( + record, + pendingJobs.map(entry => entry.task), + registered, + ); } if (persistenceError) { let finalPersistenceError = persistenceError; @@ -1246,6 +1356,40 @@ export class VibeSessionRegistry { return { id: record.id, cancelledTurn }; } + async #markTerminalRecord( + record: VibeRecord, + expected: AgentRef | null | undefined, + teardownDeadline: number, + ): Promise { + if (!record.childSessionFile) return; + try { + const persisted = await SessionManager.peekSessionInit(record.childSessionFile); + if (persisted?.init) { + await this.#markTerminalRef(record.id, record.ownerId, record.childSessionFile, expected, teardownDeadline); + } + } catch (error) { + logger.warn("vibe: failed to retain terminal worker transcript", { + id: record.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + #continueKilledCleanup( + record: VibeRecord, + jobTasks: readonly TrackedVibeTeardown[], + expected: AgentRef | undefined, + ): void { + void Promise.allSettled(jobTasks.map(task => task.promise)) + .then(() => this.#markTerminalRecord(record, expected, Date.now() + this.#teardownGraceMs)) + .catch(error => { + logger.warn("vibe: failed to finish killed worker cleanup", { + id: record.id, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + /** Build the ExecutorOptions for a first spawn, mirroring the `task`/eval-bridge plumbing. */ async #buildSpawnOptions( session: ToolSession, diff --git a/packages/coding-agent/src/web/scrapers/hackage.ts b/packages/coding-agent/src/web/scrapers/hackage.ts index 997e90a69..339c2178a 100644 --- a/packages/coding-agent/src/web/scrapers/hackage.ts +++ b/packages/coding-agent/src/web/scrapers/hackage.ts @@ -1,4 +1,4 @@ -import { tryParseJson } from "@oh-my-pi/pi-utils"; +import { compareVersions, tryParseJson } from "@oh-my-pi/pi-utils"; import type { RenderResult, SpecialHandler } from "./types"; import { buildResult, loadPage } from "./types"; @@ -20,17 +20,6 @@ interface ParsedCabal { stability?: string; } -function compareVersions(a: string, b: string): number { - const aParts = a.split(".").map(part => Number.parseInt(part, 10) || 0); - const bParts = b.split(".").map(part => Number.parseInt(part, 10) || 0); - const max = Math.max(aParts.length, bParts.length); - for (let i = 0; i < max; i++) { - const delta = (aParts[i] || 0) - (bParts[i] || 0); - if (delta !== 0) return delta; - } - return 0; -} - function extractCabalField(content: string, fieldName: string): string | undefined { const pattern = new RegExp(`^${fieldName}:\\s*(.*)$`, "im"); const match = content.match(pattern); diff --git a/packages/coding-agent/src/web/search/providers/duckduckgo.ts b/packages/coding-agent/src/web/search/providers/duckduckgo.ts index 8116dbc75..54cb8d2ad 100644 --- a/packages/coding-agent/src/web/search/providers/duckduckgo.ts +++ b/packages/coding-agent/src/web/search/providers/duckduckgo.ts @@ -372,7 +372,7 @@ export class DuckDuckGoProvider extends SearchProvider { return true; } - isExplicitlyAvailable(_authStorage: AuthStorage): boolean { + override isExplicitlyAvailable(_authStorage: AuthStorage): boolean { return true; } diff --git a/packages/coding-agent/src/web/search/providers/ecosia.ts b/packages/coding-agent/src/web/search/providers/ecosia.ts index 562effa71..374c575ff 100644 --- a/packages/coding-agent/src/web/search/providers/ecosia.ts +++ b/packages/coding-agent/src/web/search/providers/ecosia.ts @@ -173,7 +173,7 @@ export class EcosiaProvider extends SearchProvider { return true; } - isExplicitlyAvailable(_authStorage: AuthStorage): boolean { + override isExplicitlyAvailable(_authStorage: AuthStorage): boolean { return true; } diff --git a/packages/coding-agent/src/web/search/providers/exa.ts b/packages/coding-agent/src/web/search/providers/exa.ts index e745f8ab9..9c81d5317 100644 --- a/packages/coding-agent/src/web/search/providers/exa.ts +++ b/packages/coding-agent/src/web/search/providers/exa.ts @@ -458,7 +458,7 @@ export class ExaProvider extends SearchProvider { * still uses {@link isAvailable} so an unrelated configured provider * keeps priority over the public fallback. */ - isExplicitlyAvailable(_authStorage: AuthStorage): boolean { + override isExplicitlyAvailable(_authStorage: AuthStorage): boolean { return this.#settingsAllowSearch(); } diff --git a/packages/coding-agent/src/web/search/providers/firecrawl.ts b/packages/coding-agent/src/web/search/providers/firecrawl.ts index 3f701b02c..e3bb15071 100644 --- a/packages/coding-agent/src/web/search/providers/firecrawl.ts +++ b/packages/coding-agent/src/web/search/providers/firecrawl.ts @@ -203,7 +203,7 @@ export class FirecrawlProvider extends SearchProvider { * Firecrawl supports keyless mode, so an explicit user selection * (`webSearch: firecrawl`) works without any credential configured. */ - isExplicitlyAvailable(_authStorage: AuthStorage): boolean { + override isExplicitlyAvailable(_authStorage: AuthStorage): boolean { return true; } diff --git a/packages/coding-agent/src/web/search/providers/mojeek.ts b/packages/coding-agent/src/web/search/providers/mojeek.ts index 3fe6ee71d..1272c3f75 100644 --- a/packages/coding-agent/src/web/search/providers/mojeek.ts +++ b/packages/coding-agent/src/web/search/providers/mojeek.ts @@ -210,7 +210,7 @@ export class MojeekProvider extends SearchProvider { return true; } - isExplicitlyAvailable(_authStorage: AuthStorage): boolean { + override isExplicitlyAvailable(_authStorage: AuthStorage): boolean { return true; } diff --git a/packages/coding-agent/src/web/search/providers/perplexity.ts b/packages/coding-agent/src/web/search/providers/perplexity.ts index b0742b97d..1839a52d4 100644 --- a/packages/coding-agent/src/web/search/providers/perplexity.ts +++ b/packages/coding-agent/src/web/search/providers/perplexity.ts @@ -973,7 +973,7 @@ export class PerplexityProvider extends SearchProvider { * configured provider keeps priority over the anonymous/OpenRouter * fallbacks. */ - isExplicitlyAvailable(_authStorage: AuthStorage): boolean { + override isExplicitlyAvailable(_authStorage: AuthStorage): boolean { return true; } diff --git a/packages/coding-agent/src/web/search/providers/public.ts b/packages/coding-agent/src/web/search/providers/public.ts index c68c1cf28..5e21ae36b 100644 --- a/packages/coding-agent/src/web/search/providers/public.ts +++ b/packages/coding-agent/src/web/search/providers/public.ts @@ -189,7 +189,7 @@ export class PublicWebProvider extends SearchProvider { return false; } - isExplicitlyAvailable(_authStorage: AuthStorage): boolean { + override isExplicitlyAvailable(_authStorage: AuthStorage): boolean { return true; } diff --git a/packages/coding-agent/src/web/search/providers/startpage.ts b/packages/coding-agent/src/web/search/providers/startpage.ts index 58f192b58..0fd8c60b4 100644 --- a/packages/coding-agent/src/web/search/providers/startpage.ts +++ b/packages/coding-agent/src/web/search/providers/startpage.ts @@ -215,7 +215,7 @@ export class StartpageProvider extends SearchProvider { return true; } - isExplicitlyAvailable(_authStorage: AuthStorage): boolean { + override isExplicitlyAvailable(_authStorage: AuthStorage): boolean { return true; } diff --git a/packages/coding-agent/test/agent-hub-ordering.test.ts b/packages/coding-agent/test/agent-hub-ordering.test.ts index a04c4f96c..5f72f089a 100644 --- a/packages/coding-agent/test/agent-hub-ordering.test.ts +++ b/packages/coding-agent/test/agent-hub-ordering.test.ts @@ -199,6 +199,109 @@ describe("Agent hub row ordering", () => { } }); + it("bounds observer lookups and entry rendering to the viewport on large rosters", () => { + geometry = stubStdoutGeometry(120); + geometry.setRows(12); + const agents = new AgentRegistry(); + for (let i = 0; i < 10_000; i++) { + const id = `Agent-${i.toString().padStart(5, "0")}`; + agents.register({ id, displayName: id, kind: "sub", session: null, status: "parked" }); + } + + const observers = new SessionObserverRegistry(); + const getSessions = vi.spyOn(observers, "getSessions"); + const getSession = vi.spyOn(observers, "getSession"); + const hub = new AgentHubOverlayComponent({ + observers, + hubKeys: [], + onDone: () => {}, + requestRender: () => {}, + registry: agents, + irc: new IrcBus(agents), + focusAgent: async () => {}, + }); + + try { + getSessions.mockClear(); + getSession.mockClear(); + const visibleIds = renderedAgentIds(hub); + // rows=12 → line budget 5; unknown usage is an explicit second line, + // so two complete entries fit while rendering remains viewport-bounded. + expect(visibleIds).toHaveLength(2); + expect(getSessions).not.toHaveBeenCalled(); + expect(getSession.mock.calls.length).toBeLessThanOrEqual(8); + expect(getSession.mock.calls.length).toBeGreaterThan(0); + + const text = Bun.stripANSI(hub.render(120).join("\n")); + expect(text).toContain("10000 parked"); + expect(text).toMatch(/… \d+ more/); + + // Moving selection re-renders only the new viewport, not the whole roster. + getSessions.mockClear(); + getSession.mockClear(); + hub.handleInput("j"); + const afterMove = renderedAgentIds(hub); + expect(afterMove.length).toBeGreaterThan(0); + expect(afterMove.length).toBeLessThanOrEqual(2); + expect(afterMove).toContain(visibleIds[1]!); + expect(getSessions).not.toHaveBeenCalled(); + expect(getSession.mock.calls.length).toBeLessThanOrEqual(8); + } finally { + hub.dispose(); + } + }); + + it("sizes the lazy viewport by real entry height when rows have a task line", () => { + geometry = stubStdoutGeometry(120); + geometry.setRows(12); + const agents = new AgentRegistry(); + for (let i = 0; i < 100; i++) { + const id = `TaskAgent-${i.toString().padStart(3, "0")}`; + agents.register({ + id, + displayName: id, + kind: "sub", + session: null, + status: "parked", + }); + } + + const observers = new SessionObserverRegistry(); + const getSession = vi.spyOn(observers, "getSession"); + const hub = new AgentHubOverlayComponent({ + observers, + hubKeys: [], + onDone: () => {}, + requestRender: () => {}, + registry: agents, + irc: new IrcBus(agents), + focusAgent: async () => {}, + }); + + try { + // Force a second task line via observer metadata so entry height is 2. + getSession.mockImplementation((id: string) => ({ + id, + kind: "subagent", + label: "Subagent", + status: "active", + description: `task for ${id}`, + lastUpdate: Date.now(), + })); + getSession.mockClear(); + const visibleIds = renderedAgentIds(hub); + // Each entry is 2 lines; budget 5 → at most 2 full entries + probes. + expect(visibleIds.length).toBeGreaterThan(0); + expect(visibleIds.length).toBeLessThanOrEqual(3); + expect(getSession.mock.calls.length).toBeLessThanOrEqual(6); + const text = Bun.stripANSI(hub.render(120).join("\n")); + expect(text).toContain("task for"); + expect(text).toContain(visibleIds[0]!); + } finally { + hub.dispose(); + } + }); + it("truncates lines and sanitizes newlines to prevent terminal wrapping", () => { geometry = stubStdoutGeometry(80); const agents = new AgentRegistry(); @@ -318,19 +421,17 @@ describe("Agent hub row ordering", () => { agents.register({ id: "GuestAgent", displayName: "Guest Agent", kind: "sub", session: null }); const observers = new SessionObserverRegistry(); - vi.spyOn(observers, "getSessions").mockReturnValue([ - { - id: "GuestAgent", - kind: "subagent", - label: "Subagent", - status: "active", - lastUpdate: Date.now(), - progress: { - resolvedModel: "openai/gpt-4o", - resolvedModelIsFallback: true, - } as never, - }, - ]); + vi.spyOn(observers, "getSession").mockReturnValue({ + id: "GuestAgent", + kind: "subagent", + label: "Subagent", + status: "active", + lastUpdate: Date.now(), + progress: { + resolvedModel: "openai/gpt-4o", + resolvedModelIsFallback: true, + } as never, + }); const hub = makeHub(agents, { observers }); @@ -352,19 +453,17 @@ describe("Agent hub row ordering", () => { agents.register({ id: "FastAgent", displayName: "Fast Agent", kind: "sub", session }); const observers = new SessionObserverRegistry(); - vi.spyOn(observers, "getSessions").mockReturnValue([ - { - id: "FastAgent", - kind: "subagent", - label: "Subagent", - status: "active", - lastUpdate: Date.now(), - progress: { - resolvedModel: "fireworks/kimi-k2", - resolvedModelIsFallback: true, - } as never, - }, - ]); + vi.spyOn(observers, "getSession").mockReturnValue({ + id: "FastAgent", + kind: "subagent", + label: "Subagent", + status: "active", + lastUpdate: Date.now(), + progress: { + resolvedModel: "fireworks/kimi-k2", + resolvedModelIsFallback: true, + } as never, + }); const hub = makeHub(agents, { observers }); diff --git a/packages/coding-agent/test/agent-session-handoff.test.ts b/packages/coding-agent/test/agent-session-handoff.test.ts index 5bb139086..c630dc93d 100644 --- a/packages/coding-agent/test/agent-session-handoff.test.ts +++ b/packages/coding-agent/test/agent-session-handoff.test.ts @@ -850,30 +850,6 @@ describe("AgentSession handoff", () => { expect(promptSpy).toHaveBeenCalledTimes(1); }); - it("does not switch providers after provider-native auto-compaction fails", async () => { - session.settings.set("compaction.strategy", "context-full"); - session.settings.set("compaction.thresholdTokens", 50); - session.settings.set("compaction.keepRecentTokens", 1); - session.settings.set("contextPromotion.enabled", false); - - const attemptedCandidates: string[] = []; - vi.spyOn(compactionModule, "compact").mockImplementation(async (_preparation, candidate) => { - attemptedCandidates.push(`${candidate.provider}/${candidate.id}`); - throw new compactionModule.NativeCompactionError(new Error("native compaction transport failed")); - }); - - await session.prompt("pending prompt ".repeat(120)); - await waitFor(() => - events.some( - event => - event.type === "auto_compaction_end" && - event.errorMessage?.includes("native compaction transport failed") === true, - ), - ); - - expect(attemptedCandidates.length).toBeGreaterThan(0); - expect(new Set(attemptedCandidates.map(candidate => candidate.split("/", 1)[0]))).toHaveLength(1); - }); it("keeps pre-prompt context-full checks aligned with provider-anchored usage", async () => { await session.dispose(); authStorage.setRuntimeApiKey("openai", "test-key"); diff --git a/packages/coding-agent/test/agent-session-retry-cap.test.ts b/packages/coding-agent/test/agent-session-retry-cap.test.ts index fa70f6f87..6b2f9ea65 100644 --- a/packages/coding-agent/test/agent-session-retry-cap.test.ts +++ b/packages/coding-agent/test/agent-session-retry-cap.test.ts @@ -1164,29 +1164,14 @@ describe("AgentSession retry delay cap", () => { if (streamCalls === 1) { const thinking = { type: "thinking" as const, thinking: "partial thought" }; - // No visible text: a committed text block makes the failed turn - // replay-unsafe (turn-recovery #hasReplayUnsafeOutput), which would - // correctly suppress this retry. The delay-cap contract under test - // needs a replay-safe partial turn, so only thinking plus an - // incomplete (never toolcall_end'd) tool call is emitted. - const toolCall: ToolCall = { - type: "toolCall", - id: "tc-incomplete", - name: "bash", - arguments: { command: "bun probe-archive3.ts" }, - }; - partial.content.push(thinking, toolCall); + const text = { type: "text" as const, text: "partial buffered answer" }; + partial.content.push(thinking, text); stream.push({ type: "start", partial }); stream.push({ type: "thinking_start", contentIndex: 0, partial }); stream.push({ type: "thinking_delta", contentIndex: 0, delta: thinking.thinking, partial }); stream.push({ type: "thinking_end", contentIndex: 0, content: thinking.thinking, partial }); - stream.push({ type: "toolcall_start", contentIndex: 1, partial }); - stream.push({ - type: "toolcall_delta", - contentIndex: 1, - delta: JSON.stringify(toolCall.arguments), - partial, - }); + stream.push({ type: "text_start", contentIndex: 1, partial }); + stream.push({ type: "text_delta", contentIndex: 1, delta: text.text, partial }); stream.push({ type: "error", reason: "error", @@ -1243,6 +1228,7 @@ describe("AgentSession retry delay cap", () => { if (event.type === "auto_retry_end") retryEndEvents.push(event); }); + session.setTextOutputCommitted(false); await session.prompt("Trigger partial socket close"); await session.waitForIdle(); diff --git a/packages/coding-agent/test/agent-session-retry-fallback.test.ts b/packages/coding-agent/test/agent-session-retry-fallback.test.ts index f0d428ce4..b15931c60 100644 --- a/packages/coding-agent/test/agent-session-retry-fallback.test.ts +++ b/packages/coding-agent/test/agent-session-retry-fallback.test.ts @@ -1,13 +1,14 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { scheduler } from "node:timers/promises"; -import { Agent } from "@oh-my-pi/pi-agent-core"; +import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; import { type AssistantMessage, Effort, type Model, type ModelUsageHealth, type ProviderSessionState, + z, } from "@oh-my-pi/pi-ai"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; @@ -96,6 +97,7 @@ describe("AgentSession retry fallback", () => { authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); authStorage.setRuntimeApiKey("anthropic", "anthropic-test-key"); authStorage.setRuntimeApiKey("openai", "openai-test-key"); + authStorage.setRuntimeApiKey("fireworks", "fireworks-test-key"); authStorage.setRuntimeApiKey("google", "google-test-key"); authStorage.setRuntimeApiKey("google-vertex", "google-vertex-test-key"); authStorage.setRuntimeApiKey("openrouter", "openrouter-test-key"); @@ -290,9 +292,15 @@ describe("AgentSession retry fallback", () => { { credentialId: 1, credentialType: "oauth", + state: "reserve", + remainingFraction: 0.08, + }, + { + credentialId: 2, + credentialType: "oauth", selected: true, state: "reserve", - remainingFraction: 0.05, + remainingFraction: 0.02, }, ], } @@ -308,15 +316,72 @@ describe("AgentSession retry fallback", () => { session.setUsageFallbackConfirmer(confirmFallback); await session.prompt("Keep working on the same task"); await session.waitForIdle(); - expect(confirmFallback).toHaveBeenCalledWith({ - from: `${primaryModel.provider}/${primaryModel.id}`, - to: `${fallbackModel.provider}/${fallbackModel.id}`, - remainingPercent: 5, - }); + expect(confirmFallback).toHaveBeenCalledWith( + { + from: `${primaryModel.provider}/${primaryModel.id}`, + to: `${fallbackModel.provider}/${fallbackModel.id}`, + remainingPercent: 2, + }, + expect.any(AbortSignal), + ); expect(requestedModels).toEqual([`${fallbackModel.provider}/${fallbackModel.id}`]); expect(session.messages.some(message => message.role === "user")).toBe(true); }); + it("honors a live fail-closed policy after reserve spending was approved", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + const fallbackModel = getBundledModel("openai", "gpt-4o-mini"); + if (!primaryModel || !fallbackModel) throw new Error("Expected bundled reserve policy models"); + const mock = createMockModel({ responses: [{ content: ["stayed on primary"] }] }); + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: mock.stream, + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.usageAwareFallback": true, + "retry.usageReservePolicy": "confirm", + "retry.fallbackChains": { + default: [`${fallbackModel.provider}/${fallbackModel.id}`], + }, + }); + settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`); + const usageHealth = vi + .spyOn(modelRegistry.authStorage, "getModelUsageHealth") + .mockImplementation(async provider => + provider === primaryModel.provider + ? { + state: "reserve", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "reserve", + remainingFraction: 0.05, + }, + ], + } + : { state: "healthy", accounts: [] }, + ); + const confirmFallback = vi.fn(async () => false); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + session.setUsageFallbackConfirmer(confirmFallback); + + await session.prompt("Stay on the primary"); + await session.waitForIdle(); + settings.override("retry.usageReservePolicy", "fail-closed"); + expect(settings.get("retry.usageReservePolicy")).toBe("fail-closed"); + + await expect(session.prompt("Do not spend reserve")).rejects.toThrow("reserve policy is fail-closed"); + expect(confirmFallback).toHaveBeenCalledTimes(1); + expect(usageHealth).toHaveBeenCalledTimes(3); + }); it("reselects a healthy same-provider account before considering a model fallback", async () => { const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); const fallbackModel = getBundledModel("openai", "gpt-4o-mini"); @@ -485,6 +550,647 @@ describe("AgentSession retry fallback", () => { expect(requestedModels).toEqual([]); }); + it("cancels a pending reserve confirmation without dispatching the prompt", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + const fallbackModel = getBundledModel("openai", "gpt-4o-mini"); + if (!primaryModel || !fallbackModel) throw new Error("Expected bundled confirmation cancellation models"); + const requestedModels: string[] = []; + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: (model, context, options) => { + requestedModels.push(`${model.provider}/${model.id}`); + return createMockModel().stream(model, context, options); + }, + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.usageAwareFallback": true, + "retry.fallbackChains": { + default: [`${fallbackModel.provider}/${fallbackModel.id}`], + }, + }); + settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`); + vi.spyOn(modelRegistry.authStorage, "getModelUsageHealth").mockImplementation(async provider => + provider === primaryModel.provider + ? { + state: "reserve", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "reserve", + remainingFraction: 0.05, + }, + ], + } + : { state: "healthy", accounts: [] }, + ); + const confirmationStarted = Promise.withResolvers(); + const pendingConfirmation = Promise.withResolvers(); + const confirmationAborted = Promise.withResolvers(); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + session.setUsageFallbackConfirmer(async (_confirmation, signal) => { + confirmationStarted.resolve(); + signal.addEventListener("abort", () => confirmationAborted.resolve(), { once: true }); + return pendingConfirmation.promise; + }); + + const prompt = session.prompt("Do not send after confirmation cancellation"); + await confirmationStarted.promise; + await session.abort(); + await confirmationAborted.promise; + await prompt; + + expect(requestedModels).toEqual([]); + }); + + it("defers usage fallback for a queued steer until the active stream finishes", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + const fallbackModel = getBundledModel("openai", "gpt-4o-mini"); + if (!primaryModel || !fallbackModel) throw new Error("Expected bundled queued fallback models"); + const requestedModels: string[] = []; + const streamStarted = Promise.withResolvers(); + const firstResponse = Promise.withResolvers<{ content: string[] }>(); + const mock = createMockModel({ + responses: [ + async () => { + streamStarted.resolve(); + return firstResponse.promise; + }, + { content: ["queued steer completed"] }, + ], + }); + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: (model, context, options) => { + requestedModels.push(`${model.provider}/${model.id}`); + return mock.stream(model, context, options); + }, + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.usageAwareFallback": true, + "retry.usageReservePolicy": "auto", + "retry.fallbackChains": { + default: [`${fallbackModel.provider}/${fallbackModel.id}`], + }, + }); + settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`); + let useReserve = false; + const usageHealth = vi + .spyOn(modelRegistry.authStorage, "getModelUsageHealth") + .mockImplementation(async provider => + provider === primaryModel.provider + ? useReserve + ? { + state: "reserve", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "reserve", + remainingFraction: 0.05, + }, + ], + } + : { + state: "healthy", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "healthy", + remainingFraction: 0.8, + }, + ], + } + : { state: "healthy", accounts: [] }, + ); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + const firstPrompt = session.prompt("Keep the primary stream active"); + await streamStarted.promise; + useReserve = true; + await session.sendUserMessage("Queue this steer", { deliverAs: "steer" }); + + expect(usageHealth).toHaveBeenCalledTimes(1); + expect(session.model?.id).toBe(primaryModel.id); + + firstResponse.resolve({ content: ["primary stream completed"] }); + await firstPrompt; + await session.waitForIdle(); + + expect(requestedModels).toEqual([ + `${primaryModel.provider}/${primaryModel.id}`, + `${fallbackModel.provider}/${fallbackModel.id}`, + ]); + }); + + it("cancels queued-turn usage confirmation when post-prompt work is disposed", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + const fallbackModel = getBundledModel("openai", "gpt-4o-mini"); + if (!primaryModel || !fallbackModel) throw new Error("Expected bundled queued cancellation models"); + const requestedModels: string[] = []; + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: (model, context, options) => { + requestedModels.push(`${model.provider}/${model.id}`); + return createMockModel().stream(model, context, options); + }, + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.usageAwareFallback": true, + "retry.fallbackChains": { + default: [`${fallbackModel.provider}/${fallbackModel.id}`], + }, + }); + settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`); + vi.spyOn(modelRegistry.authStorage, "getModelUsageHealth").mockImplementation(async provider => + provider === primaryModel.provider + ? { + state: "reserve", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "reserve", + remainingFraction: 0.05, + }, + ], + } + : { state: "healthy", accounts: [] }, + ); + const confirmationStarted = Promise.withResolvers(); + const pendingConfirmation = Promise.withResolvers(); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + session.setUsageFallbackConfirmer(async () => { + confirmationStarted.resolve(); + return pendingConfirmation.promise; + }); + + await session.sendUserMessage("Queue this turn", { deliverAs: "steer" }); + await confirmationStarted.promise; + await session.dispose(); + session = undefined; + + expect(requestedModels).toEqual([]); + }); + + it("does not reschedule a queued drain after a dequeue hook rejects", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!primaryModel) throw new Error("Expected bundled queued-drain model"); + const requestedModels: string[] = []; + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: (model, context, options) => { + requestedModels.push(`${model.provider}/${model.id}`); + return createMockModel().stream(model, context, options); + }, + }); + const settings = Settings.isolated({ "compaction.enabled": false }); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + const hookRan = Promise.withResolvers(); + let attempts = 0; + const failingHook = vi.fn(() => { + hookRan.resolve(); + if (++attempts === 1) throw new Error("blocked before dequeue"); + }); + agent.addBeforeQueuedMessageDequeueHook(failingHook); + + await session.sendUserMessage("Keep this queued", { deliverAs: "steer" }); + await hookRan.promise; + await session.waitForIdle(); + + expect(failingHook).toHaveBeenCalledTimes(1); + expect(agent.hasQueuedMessages()).toBe(true); + expect(requestedModels).toEqual([]); + }); + + it("enforces fail-closed usage health when model fallback is disabled", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!primaryModel) throw new Error("Expected bundled fail-closed model"); + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: createMockModel().stream, + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.modelFallback": false, + "retry.usageAwareFallback": true, + "retry.usageReservePolicy": "fail-closed", + }); + vi.spyOn(modelRegistry.authStorage, "getModelUsageHealth").mockResolvedValue({ + state: "reserve", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "reserve", + remainingFraction: 0.05, + }, + ], + }); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + await expect(session.prompt("Do not spend reserve")).rejects.toThrow("reserve policy is fail-closed"); + }); + + it("does not degrade Fireworks Fast or retry a chain after queued fail-closed preflight", async () => { + const primaryModel = getBundledModel("fireworks", "kimi-k2.6-fast"); + const fallbackModel = getBundledModel("openai", "gpt-4o-mini"); + if (!primaryModel || !fallbackModel) throw new Error("Expected bundled queued fail-closed models"); + const requestedModels: string[] = []; + const streamStarted = Promise.withResolvers(); + const firstResponse = Promise.withResolvers<{ content: string[] }>(); + const mock = createMockModel({ + responses: [ + async () => { + streamStarted.resolve(); + return firstResponse.promise; + }, + { content: ["must not run"] }, + ], + }); + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: (model, context, options) => { + requestedModels.push(`${model.provider}/${model.id}`); + return mock.stream(model, context, options); + }, + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.usageAwareFallback": true, + "retry.usageReservePolicy": "fail-closed", + "retry.fallbackChains": { + default: [`${fallbackModel.provider}/${fallbackModel.id}`], + }, + }); + settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`); + let useReserve = false; + const usageHealth = vi.spyOn(modelRegistry.authStorage, "getModelUsageHealth").mockImplementation(async () => + useReserve + ? { + state: "reserve", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "reserve", + remainingFraction: 0.05, + }, + ], + } + : { + state: "healthy", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "healthy", + remainingFraction: 0.8, + }, + ], + }, + ); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + const firstPrompt = session.prompt("Keep the primary stream active"); + await streamStarted.promise; + useReserve = true; + await session.sendUserMessage("Queue blocked work", { deliverAs: "steer" }); + firstResponse.resolve({ content: ["primary stream completed"] }); + await firstPrompt; + await session.waitForIdle(); + + expect(usageHealth).toHaveBeenCalledTimes(2); + expect(requestedModels).toEqual([`${primaryModel.provider}/${primaryModel.id}`]); + expect(session.model?.id).toBe(primaryModel.id); + expect(agent.hasQueuedMessages()).toBe(true); + }); + + it("rechecks fail-closed usage health before an internally scheduled continuation", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!primaryModel) throw new Error("Expected bundled scheduled continuation model"); + const requestedModels: string[] = []; + let useReserve = false; + const mock = createMockModel({ + responses: [ + async () => { + useReserve = true; + return { content: [], stopReason: "stop" }; + }, + { content: ["must not run"] }, + ], + }); + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: (model, context, options) => { + requestedModels.push(`${model.provider}/${model.id}`); + return mock.stream(model, context, options); + }, + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.usageAwareFallback": true, + "retry.usageReservePolicy": "fail-closed", + }); + settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`); + const usageHealth = vi.spyOn(modelRegistry.authStorage, "getModelUsageHealth").mockImplementation(async () => + useReserve + ? { + state: "reserve", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "reserve", + remainingFraction: 0.05, + }, + ], + } + : { + state: "healthy", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "healthy", + remainingFraction: 0.8, + }, + ], + }, + ); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + await session.prompt("Retry this empty response"); + await session.waitForIdle(); + + expect(usageHealth).toHaveBeenCalledTimes(2); + expect(requestedModels).toEqual([`${primaryModel.provider}/${primaryModel.id}`]); + }); + + it("rechecks fail-closed usage health before a same-turn tool continuation", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!primaryModel) throw new Error("Expected bundled tool-continuation model"); + const requestedModels: string[] = []; + let useReserve = false; + const toolSchema = z.object({ value: z.string() }); + const tool: AgentTool = { + name: "consume", + label: "Consume", + description: "Consume plan quota", + parameters: toolSchema, + async execute(_toolCallId, params) { + useReserve = true; + return { content: [{ type: "text", text: params.value }], details: params }; + }, + }; + const mock = createMockModel({ + responses: [ + { content: [{ type: "toolCall", id: "tool-1", name: "consume", arguments: { value: "done" } }] }, + { content: ["must not run"] }, + ], + }); + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [tool], messages: [] }, + streamFn: (model, context, options) => { + requestedModels.push(`${model.provider}/${model.id}`); + return mock.stream(model, context, options); + }, + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.usageAwareFallback": true, + "retry.usageReservePolicy": "fail-closed", + }); + settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`); + const usageHealth = vi.spyOn(modelRegistry.authStorage, "getModelUsageHealth").mockImplementation(async () => + useReserve + ? { + state: "reserve", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "reserve", + remainingFraction: 0.05, + }, + ], + } + : { + state: "healthy", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "healthy", + remainingFraction: 0.8, + }, + ], + }, + ); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + await session.prompt("Use the tool"); + await session.waitForIdle(); + + expect(usageHealth).toHaveBeenCalledTimes(2); + expect(requestedModels).toEqual([`${primaryModel.provider}/${primaryModel.id}`]); + }); + it("rechecks fail-closed usage health when prompt setup changes the model", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + const setupTarget = getBundledModel("anthropic", "claude-sonnet-4-6"); + if (!primaryModel || !setupTarget) throw new Error("Expected bundled setup-handoff models"); + const requestedModels: string[] = []; + const usageChecks: string[] = []; + const mock = createMockModel({ responses: [{ content: ["must not run"] }] }); + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: (model, context, options) => { + requestedModels.push(`${model.provider}/${model.id}`); + return mock.stream(model, context, options); + }, + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.usageAwareFallback": true, + "retry.usageReservePolicy": "fail-closed", + }); + settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`); + const usageHealth = vi + .spyOn(modelRegistry.authStorage, "getModelUsageHealth") + .mockImplementation(async (_provider, options) => { + usageChecks.push(options.modelId ?? ""); + const reserve = options.modelId === setupTarget.id; + return { + state: reserve ? "reserve" : "healthy", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: reserve ? "reserve" : "healthy", + remainingFraction: reserve ? 0.05 : 0.8, + }, + ], + }; + }); + const extensionRunner = { + emit: vi.fn().mockResolvedValue(undefined), + hasHandlers: vi.fn().mockReturnValue(false), + emitBeforeAgentStart: vi.fn(async () => { + if (!session) throw new Error("Expected active session"); + await session.setModelTemporary(setupTarget, undefined, { ephemeral: true }); + return undefined; + }), + } as unknown as ExtensionRunner; + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + extensionRunner, + }); + + await session.prompt("Change models during setup"); + + expect(usageHealth).toHaveBeenCalledTimes(2); + expect(usageChecks).toEqual([primaryModel.id, setupTarget.id]); + expect(requestedModels).toEqual([]); + }); + + it("restarts usage preflight when the model changes during a health request", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + const selectedModel = getBundledModel("anthropic", "claude-sonnet-4-6"); + if (!primaryModel || !selectedModel) throw new Error("Expected bundled preflight race models"); + const requestedModels: string[] = []; + const usageChecks: string[] = []; + const healthStarted = Promise.withResolvers(); + const releaseHealth = Promise.withResolvers(); + const mock = createMockModel({ responses: [{ content: ["must not run"] }] }); + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: (model, context, options) => { + requestedModels.push(`${model.provider}/${model.id}`); + return mock.stream(model, context, options); + }, + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.usageAwareFallback": true, + "retry.usageReservePolicy": "fail-closed", + }); + settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`); + const usageHealth = vi + .spyOn(modelRegistry.authStorage, "getModelUsageHealth") + .mockImplementation(async (_provider, options) => { + usageChecks.push(options.modelId ?? ""); + if (options.modelId === primaryModel.id) { + healthStarted.resolve(); + await releaseHealth.promise; + } + const reserve = options.modelId === selectedModel.id; + return { + state: reserve ? "reserve" : "healthy", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: reserve ? "reserve" : "healthy", + remainingFraction: reserve ? 0.05 : 0.8, + }, + ], + }; + }); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + const prompting = session.prompt("Change models during preflight"); + await healthStarted.promise; + await session.setModelTemporary(selectedModel, undefined, { ephemeral: true }); + releaseHealth.resolve(); + await expect(prompting).rejects.toThrow(`reserve reached for ${selectedModel.provider}/${selectedModel.id}`); + + expect(usageHealth).toHaveBeenCalledTimes(2); + expect(usageChecks).toEqual([primaryModel.id, selectedModel.id]); + expect(session.model?.id).toBe(selectedModel.id); + expect(requestedModels).toEqual([]); + }); + + it("finishes usage preflight when no model is selected", async () => { + const agent = new Agent({ + initialState: { model: undefined, systemPrompt: ["Test"], tools: [], messages: [] }, + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.usageAwareFallback": true, + }); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + await expect(session.prompt("No model configured")).rejects.toThrow("No model selected"); + expect(agent.state.isStreaming).toBe(false); + }); + it("continues a startup-owned role fallback chain from the active fallback", async () => { const firstFallback = getBundledModel("openai", "gpt-4o-mini"); const secondFallback = getBundledModel("openai", "gpt-4o"); @@ -2870,6 +3576,54 @@ describe("AgentSession retry fallback", () => { expect(session.thinkingLevel).toBe(Effort.Low); }); + it("skips usage fallbacks whose effort floor exceeds the session ceiling", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + const incompatibleFallback = getBundledModel("fireworks", "deepseek-v4-pro"); + const compatibleFallback = getBundledModel("openai", "gpt-4o-mini"); + if (!primaryModel || !incompatibleFallback || !compatibleFallback) { + throw new Error("Expected bundled usage fallback effort models"); + } + const requestedModels: string[] = []; + const usageChecks: string[] = []; + const agent = createFallbackAgent(primaryModel, requestedModels); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.usageAwareFallback": true, + "retry.usageReservePolicy": "auto", + "retry.fallbackChains": { + default: [ + `${incompatibleFallback.provider}/${incompatibleFallback.id}`, + `${compatibleFallback.provider}/${compatibleFallback.id}`, + ], + }, + }); + settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`); + vi.spyOn(modelRegistry.authStorage, "getModelUsageHealth").mockImplementation(async (_provider, options) => { + usageChecks.push(options.modelId ?? ""); + return options.modelId === primaryModel.id + ? { + state: "depleted", + accounts: [{ credentialId: 1, credentialType: "oauth", state: "depleted" }], + } + : { state: "healthy", accounts: [] }; + }); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + thinkingLevel: Effort.Low, + thinkingLevelCeiling: Effort.Low, + }); + + await session.prompt("Use an effort-compatible fallback"); + await session.waitForIdle(); + + expect(usageChecks).toEqual([primaryModel.id, compatibleFallback.id]); + expect(requestedModels).toEqual([`${compatibleFallback.provider}/${compatibleFallback.id}`]); + expect(session.model?.id).toBe(compatibleFallback.id); + }); + it("accepts cached Ollama Cloud fallback selectors during startup validation", () => { const primaryModel = getBundledModel("openai", "gpt-4o-mini"); if (!primaryModel) { diff --git a/packages/coding-agent/test/agent-session-unexpected-stop-guard.test.ts b/packages/coding-agent/test/agent-session-unexpected-stop-guard.test.ts index b059004c1..67c2d2781 100644 --- a/packages/coding-agent/test/agent-session-unexpected-stop-guard.test.ts +++ b/packages/coding-agent/test/agent-session-unexpected-stop-guard.test.ts @@ -50,6 +50,13 @@ function unexpectedStop(text: string): MockResponse { }; } +function thinkingOnlyStop(thinking: string): MockResponse { + return { + content: [{ type: "thinking", thinking, thinkingSignature: "reasoning_content" }], + stopReason: "stop", + }; +} + async function createHarness( responses: MockResponse[], settingsOverrides: SettingsOverrides = {}, @@ -170,6 +177,30 @@ describe("AgentSession unexpected stop guard", () => { expect(reminderMessages(session.agent.state.messages)).toHaveLength(1); }); + it("classifies a thinking-only stop on its thinking text and continues", async () => { + let calls = 0; + const spy = vi.spyOn(unexpectedStopClassifier, "classifyUnexpectedStop").mockImplementation(async () => { + calls++; + return calls === 1; + }); + const { session, mock } = await createHarness( + [thinkingOnlyStop(" 响应"), { content: ["done now"], stopReason: "stop" }], + { + "features.unexpectedStopDetection": true, + "providers.unexpectedStopModel": "online", + }, + ); + + await session.prompt("do the thing"); + await session.waitForIdle(); + + expect(spy).toHaveBeenCalledTimes(2); + expect(spy.mock.calls[0]?.[0]).toContain("响应"); + expect(mock.calls).toHaveLength(2); + expect(assistantText(session.agent.state.messages)).toContain("done now"); + expect(reminderMessages(session.agent.state.messages)).toHaveLength(1); + }); + it("does not continue when the classifier returns false", async () => { const spy = vi.spyOn(unexpectedStopClassifier, "classifyUnexpectedStop").mockResolvedValue(false); const { session, mock } = await createHarness( diff --git a/packages/coding-agent/test/async-job-manager.test.ts b/packages/coding-agent/test/async-job-manager.test.ts index 1a336456a..21fe7529a 100644 --- a/packages/coding-agent/test/async-job-manager.test.ts +++ b/packages/coding-agent/test/async-job-manager.test.ts @@ -113,6 +113,30 @@ describe("AsyncJobManager", () => { expect(completions).toHaveLength(0); }); + test("bounds owner-job reap while preserving late settlement", async () => { + const manager = new AsyncJobManager({ onJobComplete: async () => {} }); + const release = Promise.withResolvers(); + const jobId = manager.register( + "task", + "ignores abort", + async () => { + await release.promise; + return "late result"; + }, + { ownerId: "owner" }, + ); + + const reap = await manager.cancelAndReapOwnerJobs("owner", Date.now()); + + expect(reap.settled).toBe(false); + expect(reap.pendingJobIds).toEqual([jobId]); + expect(manager.getJob(jobId)?.status).toBe("cancelled"); + + release.resolve(); + await reap.completion; + expect(manager.getJob(jobId)?.resultText).toBe("late result"); + }); + test("enforces maxRunningJobs cap", () => { const manager = new AsyncJobManager({ maxRunningJobs: 1, diff --git a/packages/coding-agent/test/discovery/mcp-enabled-import.test.ts b/packages/coding-agent/test/discovery/mcp-enabled-import.test.ts new file mode 100644 index 000000000..6595c45c3 --- /dev/null +++ b/packages/coding-agent/test/discovery/mcp-enabled-import.test.ts @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { type MCPServer, mcpCapability } from "@oh-my-pi/pi-coding-agent/capability/mcp"; +import { loadCapability } from "@oh-my-pi/pi-coding-agent/discovery"; +import { removeWithRetries } from "@oh-my-pi/pi-utils"; + +async function loadMcp(cwd: string, provider: string): Promise { + const result = await loadCapability(mcpCapability.id, { + cwd, + providers: [provider], + }); + return result.items; +} + +interface Fixture { + /** Discovery provider id passed to `loadCapability`. */ + provider: string; + /** Project-relative config file the importer reads. */ + file: string; + /** File body carrying a single server with `enabled: false`. */ + content: string; +} + +// Project-scoped config for each translated importer that previously dropped the +// per-server `enabled` flag (issue #7652). Codex/OpenCode/native already +// propagate it and are covered elsewhere. +const FIXTURES: Fixture[] = [ + { + provider: "claude", + file: ".claude/.mcp.json", + content: JSON.stringify({ + mcpServers: { markitdown: { command: "uvx", args: ["markitdown-mcp"], type: "stdio", enabled: false } }, + }), + }, + { + provider: "cursor", + file: ".cursor/mcp.json", + content: JSON.stringify({ + mcpServers: { markitdown: { command: "uvx", args: ["markitdown-mcp"], type: "stdio", enabled: false } }, + }), + }, + { + provider: "gemini", + file: ".gemini/settings.json", + content: JSON.stringify({ + mcpServers: { markitdown: { command: "uvx", args: ["markitdown-mcp"], type: "stdio", enabled: false } }, + }), + }, + { + provider: "windsurf", + file: ".windsurf/mcp_config.json", + content: JSON.stringify({ + mcpServers: { markitdown: { command: "uvx", args: ["markitdown-mcp"], type: "stdio", enabled: false } }, + }), + }, + { + provider: "vscode", + file: ".vscode/mcp.json", + content: JSON.stringify({ + mcp: { servers: { markitdown: { command: "uvx", args: ["markitdown-mcp"], type: "stdio", enabled: false } } }, + }), + }, +]; + +interface CompoundFixture { + /** Discovery provider id passed to `loadCapability`. */ + provider: string; + /** User-scope config file, relative to the temp HOME. */ + userFile: string; + /** Project-scope config file, relative to the temp cwd. */ + projectFile: string; +} + +// Providers exposing both a user and a project MCP scope. A project +// `enabled: false` must claim the dedupe key ahead of the same-named user +// server so the disable actually suppresses it (#7654). VS Code MCP is +// project-only, so it has no user/project compound case. +const COMPOUND_FIXTURES: CompoundFixture[] = [ + { provider: "claude", userFile: ".claude.json", projectFile: ".claude/.mcp.json" }, + { provider: "cursor", userFile: ".cursor/mcp.json", projectFile: ".cursor/mcp.json" }, + { provider: "gemini", userFile: ".gemini/settings.json", projectFile: ".gemini/settings.json" }, + { provider: "windsurf", userFile: ".codeium/windsurf/mcp_config.json", projectFile: ".windsurf/mcp_config.json" }, +]; + +function mcpServersJson(enabled: boolean, command: string): string { + return JSON.stringify({ + mcpServers: { markitdown: { command, args: ["markitdown-mcp"], type: "stdio", enabled } }, + }); +} + +describe("translated MCP importers propagate enabled: false", () => { + let tempCwd = ""; + let tempHome = ""; + let originalHome: string | undefined; + + beforeEach(async () => { + originalHome = process.env.HOME; + tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-mcp-enabled-cwd-")); + tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "omp-mcp-enabled-home-")); + process.env.HOME = tempHome; + vi.spyOn(os, "homedir").mockReturnValue(tempHome); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + await removeWithRetries(tempCwd); + await removeWithRetries(tempHome); + }); + + for (const { provider, file, content } of FIXTURES) { + test(`${provider} carries enabled: false`, async () => { + const filePath = path.join(tempCwd, file); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content); + + const servers = await loadMcp(tempCwd, provider); + const server = servers.find(item => item.name === "markitdown"); + + expect(server).toBeDefined(); + expect(server?.enabled).toBe(false); + }); + } + + for (const { provider, userFile, projectFile } of COMPOUND_FIXTURES) { + test(`${provider} project enabled: false suppresses a same-named user server`, async () => { + const userPath = path.join(tempHome, userFile); + const projectPath = path.join(tempCwd, projectFile); + await fs.mkdir(path.dirname(userPath), { recursive: true }); + await fs.mkdir(path.dirname(projectPath), { recursive: true }); + await fs.writeFile(userPath, mcpServersJson(true, "user-markitdown")); + await fs.writeFile(projectPath, mcpServersJson(false, "project-markitdown")); + + const result = await loadCapability(mcpCapability.id, { + cwd: tempCwd, + providers: [provider], + suppress: server => server.enabled === false, + }); + + expect(result.items.find(server => server.name === "markitdown")).toBeUndefined(); + }); + } +}); diff --git a/packages/coding-agent/test/eval/agent-bridge-policy.test.ts b/packages/coding-agent/test/eval/agent-bridge-policy.test.ts index fb290ced8..95036176c 100644 --- a/packages/coding-agent/test/eval/agent-bridge-policy.test.ts +++ b/packages/coding-agent/test/eval/agent-bridge-policy.test.ts @@ -290,10 +290,7 @@ describe("runEvalAgent", () => { }), }); - await runEvalAgent( - { prompt: " hello ", label: "My Agent", model: "p/override", schema }, - { session, signal: abortController.signal }, - ); + await runEvalAgent({ prompt: " hello ", label: "My Agent", schema }, { session, signal: abortController.signal }); await runEvalAgent({ prompt: "plain" }, { session }); const firstOptions = runSpy.mock.calls[0]?.[0]; @@ -306,10 +303,26 @@ describe("runEvalAgent", () => { expect(firstOptions.outputSchemaOverridesAgent).toBe(true); expect(firstOptions.assignment).toBe("hello"); expect(firstOptions.description).toBe("My Agent"); - expect(firstOptions.modelOverride).toEqual(["p/override"]); + // No per-call override: the agent's own frontmatter model applies. + expect(firstOptions.modelOverride).toEqual(["p/current"]); expect(secondOptions.outputSchema).toBeUndefined(); expect(secondOptions.outputSchemaOverridesAgent).toBeUndefined(); }); + + it("drops a per-call model argument on agent() (removed, issue #6438)", async () => { + mockAgents(); + const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => singleResult(options)); + + // The schema strips unknown keys; a legacy `model` argument is silently + // discarded so resolution is identical to omitting it — the agent's own + // frontmatter model applies (issue #6438). + await runEvalAgent({ prompt: "work", model: "default" }, { session: makeSession() }); + await runEvalAgent({ prompt: "work" }, { session: makeSession() }); + + const withModel = runSpy.mock.calls[0]?.[0]; + const withoutModel = runSpy.mock.calls[1]?.[0]; + expect(withModel?.modelOverride).toEqual(withoutModel?.modelOverride); + }); it("returns host-parsed data for caller, agent, and inherited schemas", async () => { const agentSchema = { type: "object" }; const sessionSchema = { type: "object" }; diff --git a/packages/coding-agent/test/eval/prelude-agent.test.ts b/packages/coding-agent/test/eval/prelude-agent.test.ts index 4aa4019aa..e74aa34f5 100644 --- a/packages/coding-agent/test/eval/prelude-agent.test.ts +++ b/packages/coding-agent/test/eval/prelude-agent.test.ts @@ -66,12 +66,11 @@ describe("eval js agent() handle", () => { ) => Promise; const schema = { type: "object", properties: { ok: { type: "boolean" } } }; - await positionalAgent("scout", "reviewer", "p/model", "Legacy", schema, true, false, true, "strict"); + await positionalAgent("scout", "reviewer", "Legacy", schema, true, false, true, "strict"); expect(seenArgs).toEqual({ prompt: "scout", agent: "reviewer", - model: "p/model", label: "Legacy", schema, isolated: true, diff --git a/packages/coding-agent/test/event-controller-message-update-coalesce.test.ts b/packages/coding-agent/test/event-controller-message-update-coalesce.test.ts new file mode 100644 index 000000000..653f0e849 --- /dev/null +++ b/packages/coding-agent/test/event-controller-message-update-coalesce.test.ts @@ -0,0 +1,238 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import type { AssistantMessage, Usage } from "@oh-my-pi/pi-ai"; +import { resetSettingsForTest, Settings, settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { EventController } from "@oh-my-pi/pi-coding-agent/modes/controllers/event-controller"; +import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; +import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { vocalizer } from "@oh-my-pi/pi-coding-agent/tts/vocalizer"; +import type { TUI } from "@oh-my-pi/pi-tui"; + +function zeroUsage(): Usage { + return { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; +} + +function assistantMessage(text: string): AssistantMessage { + return { + role: "assistant", + content: [{ type: "text", text }], + usage: zeroUsage(), + stopReason: undefined, + createdAt: new Date(0), + } as unknown as AssistantMessage; +} + +function messageUpdate(text: string): Extract { + return { + type: "message_update", + message: assistantMessage(text), + assistantMessageEvent: { type: "text_delta", contentIndex: 0, delta: text, partial: assistantMessage(text) }, + } as unknown as Extract; +} + +function createStreamingFixture() { + const listeners: Array<(event: AgentSessionEvent) => void> = []; + const ui = { + requestRender: vi.fn(), + requestComponentRender: vi.fn(), + } as unknown as TUI; + const viewSession = { isStreaming: true, getToolByName: () => undefined }; + const ctx = { + isInitialized: true, + init: vi.fn(async () => {}), + ui, + settings, + chatContainer: { addChild: vi.fn(), children: [] }, + pendingTools: new Map(), + transcriptMessageComponents: new WeakMap(), + streamingComponent: { + setHideThinkingBlock: vi.fn(), + markTranscriptBlockFinalized: vi.fn(), + updateContent: vi.fn(), + }, + noteDisplayableThinkingContent: vi.fn(() => false), + ensureLoadingAnimation: vi.fn(), + statusLine: { invalidate: vi.fn() }, + updateEditorTopBorder: vi.fn(), + setWorkingMessage: vi.fn(), + viewSession, + session: { + subscribe: (listener: (event: AgentSessionEvent) => void) => { + listeners.push(listener); + return () => {}; + }, + } as unknown as InteractiveModeContext["session"], + } as unknown as InteractiveModeContext; + const controller = new EventController(ctx); + controller.subscribeToAgent(); + const emit = (event: AgentSessionEvent) => { + for (const listener of listeners) void listener(event); + }; + return { controller, ctx, ui, emit }; +} +async function flushMicrotasks(): Promise { + for (let i = 0; i < 12; i++) await Promise.resolve(); +} + +describe("EventController message_update coalescing", () => { + beforeEach(async () => { + resetSettingsForTest(); + await Settings.init({ inMemory: true, overrides: { "display.smoothStreaming": false } }); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + resetSettingsForTest(); + }); + + it("runs the streaming rebuild once per window instead of once per delta, applying the latest snapshot", async () => { + const { ctx, ui, emit } = createStreamingFixture(); + + emit(messageUpdate("tok1")); + emit(messageUpdate("tok1 tok2")); + emit(messageUpdate("tok1 tok2 tok3")); + emit(messageUpdate("tok1 tok2 tok3 tok4")); + emit(messageUpdate("tok1 tok2 tok3 tok4 tok5")); + + vi.advanceTimersByTime(32); + expect(ui.requestRender).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + await flushMicrotasks(); + + expect(ui.requestRender).toHaveBeenCalledTimes(1); + expect((ctx.streamingMessage as AssistantMessage | undefined)?.content).toEqual([ + { type: "text", text: "tok1 tok2 tok3 tok4 tok5" }, + ]); + + emit(messageUpdate("tok1 tok2 tok3 tok4 tok5 tok6")); + emit(messageUpdate("tok1 tok2 tok3 tok4 tok5 tok6 tok7")); + vi.advanceTimersByTime(33); + await flushMicrotasks(); + + expect(ui.requestRender).toHaveBeenCalledTimes(2); + expect((ctx.streamingMessage as AssistantMessage | undefined)?.content).toEqual([ + { type: "text", text: "tok1 tok2 tok3 tok4 tok5 tok6 tok7" }, + ]); + }); + + it("flushes the pending snapshot before a subsequent non-update event", async () => { + const { ctx, emit } = createStreamingFixture(); + + emit(messageUpdate("tok1")); + emit(messageUpdate("tok1 tok2")); + emit({ type: "message_end", message: assistantMessage("tok1 tok2") } as Extract< + AgentSessionEvent, + { type: "message_end" } + >); + + expect((ctx.streamingMessage as AssistantMessage | undefined)?.content).toEqual([ + { type: "text", text: "tok1 tok2" }, + ]); + }); + + it("speaks every delta exactly once even when intermediate snapshots are coalesced away", async () => { + const { emit } = createStreamingFixture(); + const pushDelta = vi.spyOn(vocalizer, "pushDelta"); + settings.set("speech.enabled", true); + settings.set("speech.mode", "assistant"); + + emit(messageUpdate("one ")); + emit(messageUpdate("one two ")); + emit(messageUpdate("one two three ")); + + vi.advanceTimersByTime(33); + await flushMicrotasks(); + + expect(pushDelta).toHaveBeenCalledTimes(3); + expect(pushDelta).toHaveBeenNthCalledWith(1, "one "); + expect(pushDelta).toHaveBeenNthCalledWith(2, "one two "); + expect(pushDelta).toHaveBeenNthCalledWith(3, "one two three "); + }); + + it("serializes a tail event behind an in-flight window flush", async () => { + // The coalesced flush fires from a 33ms timer, NOT from the listener + // path, so AgentSession's fire-and-forget dispatch cannot serialize it: + // a message_end landing mid-flush used to run its handler concurrently, + // both calling init while the flush was suspended. The dispatch chain + // must hold the tail event until the window flush completed. + const { ctx, emit } = createStreamingFixture(); + ctx.isInitialized = false; + const initGate = Promise.withResolvers(); + let initCalls = 0; + ctx.init = vi.fn(async () => { + initCalls += 1; + if (initCalls === 1) await initGate.promise; + }); + + emit(messageUpdate("tok1 tok2")); + vi.advanceTimersByTime(33); // window fires; flush suspends on init (call 1) + + emit({ type: "message_end", message: assistantMessage("tok1 tok2") } as Extract< + AgentSessionEvent, + { type: "message_end" } + >); + await flushMicrotasks(); + + // The end handler must be queued behind the suspended flush, not + // running alongside it (which would double-init). + expect(initCalls).toBe(1); + initGate.resolve(); + await flushMicrotasks(); + + // Flush completed, then the end handler ran to completion. + expect(initCalls).toBe(2); + }); + + it("does not run two events queued in the same window concurrently", async () => { + // A burst that lands while a run is in flight must dispatch strictly + // one after the other: each waiter is chained onto the current tail, + // so two events sharing one suspended handler cannot both resume into + // parallel dispatch after the gate opens (regression: the shared + // `await this.#dispatchTail` let every queued callback start its own + // run once the tail settled). + const { ctx, emit } = createStreamingFixture(); + ctx.isInitialized = false; + const gates = [Promise.withResolvers(), Promise.withResolvers()]; + let initCalls = 0; + ctx.init = vi.fn(async () => { + initCalls += 1; + if (initCalls <= 2) await gates[initCalls - 1]!.promise; // first two runs each suspend on their own gate + }); + + emit(messageUpdate("tok1")); + vi.advanceTimersByTime(33); // window fires; flush run 1 suspends on gate 1 + + // Two non-update events land while the flush is still suspended. + emit({ type: "message_end", message: assistantMessage("tok1") } as Extract< + AgentSessionEvent, + { type: "message_end" } + >); + emit({ type: "message_end", message: assistantMessage("tok1") } as Extract< + AgentSessionEvent, + { type: "message_end" } + >); + await flushMicrotasks(); + + // Neither queued handler has started yet — both are chained behind + // the suspended flush. + expect(initCalls).toBe(1); + + // Release run 1: run 2 starts and suspends on gate 2; run 3 is queued. + gates[0]!.resolve(); + await flushMicrotasks(); + expect(initCalls).toBe(2); + + // Release run 2: run 3 finally runs to completion. + gates[1]!.resolve(); + await flushMicrotasks(); + expect(initCalls).toBe(3); + }); +}); diff --git a/packages/coding-agent/test/extensibility/legacy-pi-image-convert.test.ts b/packages/coding-agent/test/extensibility/legacy-pi-image-convert.test.ts new file mode 100644 index 000000000..010569014 --- /dev/null +++ b/packages/coding-agent/test/extensibility/legacy-pi-image-convert.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "bun:test"; +import { convertToPng } from "@oh-my-pi/pi-coding-agent/extensibility/legacy-pi-coding-agent-shim"; + +const RED_1X1_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC"; +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +describe("legacy shim image conversion", () => { + it("preserves PNG attachments without re-encoding", async () => { + await expect(convertToPng(RED_1X1_PNG_BASE64, "image/png")).resolves.toEqual({ + data: RED_1X1_PNG_BASE64, + mimeType: "image/png", + }); + }); + + it("converts decodable image attachments to PNG", async () => { + const jpeg = await new Bun.Image(Buffer.from(RED_1X1_PNG_BASE64, "base64")).jpeg({ quality: 90 }).toBase64(); + const converted = await convertToPng(jpeg, "image/jpeg"); + + expect(converted?.mimeType).toBe("image/png"); + expect(Buffer.from(converted?.data ?? "", "base64").subarray(0, PNG_SIGNATURE.byteLength)).toEqual(PNG_SIGNATURE); + }); + + it("returns null when image decoding fails", async () => { + await expect(convertToPng("not-base64-image-data", "image/jpeg")).resolves.toBeNull(); + }); +}); diff --git a/packages/coding-agent/test/fixtures/browser-executable-probe.ts b/packages/coding-agent/test/fixtures/browser-executable-probe.ts new file mode 100644 index 000000000..d0f5641e7 --- /dev/null +++ b/packages/coding-agent/test/fixtures/browser-executable-probe.ts @@ -0,0 +1,7 @@ +import { ensureChromiumExecutable } from "@oh-my-pi/pi-coding-agent/tools/browser/launch"; + +const platform = process.env.OMP_BROWSER_PROBE_PLATFORM; +if (platform) Object.defineProperty(process, "platform", { value: platform }); + +const executable = await ensureChromiumExecutable(); +process.stdout.write(executable ?? ""); diff --git a/packages/coding-agent/test/hindsight-backend.test.ts b/packages/coding-agent/test/hindsight-backend.test.ts index 969f06fa6..a7415e3ed 100644 --- a/packages/coding-agent/test/hindsight-backend.test.ts +++ b/packages/coding-agent/test/hindsight-backend.test.ts @@ -338,6 +338,42 @@ describe("hindsightBackend first-turn injection", () => { expect(session.getHindsightSessionState()?.lastRecallSnippet).toBe(block); }); + it("does not let agent_start preempt first-turn recall injection", async () => { + const settings = Settings.isolated({ + "memory.backend": "hindsight", + "hindsight.apiUrl": "http://localhost:8888", + }); + const session = makeFakeSession({ + sessionId: "s-race", + entries: [{ role: "user", text: "What is the canary phrase?" }], + }); + await hindsightBackend.start({ + session: session as never, + settings, + modelRegistry: {} as never, + agentDir: "/tmp", + taskDepth: 0, + }); + + vi.spyOn(HindsightApi.prototype, "recall").mockResolvedValue({ + results: [{ id: "1", text: "The canary phrase is PURPLE-OTTER-9931." }], + } as never); + + // The agent loop fires agent_start once the turn begins. This must NOT run + // its own recall: doing so consumed the shared first-turn flag and left + // injection to a racing background prompt rebuild that a fast turn outran, + // dropping recalled memory from the model's prompt (#7568). + session.emit({ type: "agent_start" }); + for (let i = 0; i < 50; i++) await Promise.resolve(); + + expect(session.getHindsightSessionState()?.hasRecalledForFirstTurn).toBe(false); + + // beforeAgentStartPrompt is the sole, awaited injection path. + const block = await hindsightBackend.beforeAgentStartPrompt?.(session as never, "What is the canary phrase?"); + expect(block).toContain("PURPLE-OTTER-9931"); + expect(session.getHindsightSessionState()?.hasRecalledForFirstTurn).toBe(true); + }); + it("keeps the wrapper in buildDeveloperInstructions", async () => { const settings = Settings.isolated({ "memory.backend": "hindsight", diff --git a/packages/coding-agent/test/internal-urls/memory-protocol.test.ts b/packages/coding-agent/test/internal-urls/memory-protocol.test.ts index 6bd8f71c8..8fec71f8f 100644 --- a/packages/coding-agent/test/internal-urls/memory-protocol.test.ts +++ b/packages/coding-agent/test/internal-urls/memory-protocol.test.ts @@ -524,3 +524,62 @@ describe("MemoryProtocolHandler — mnemopi bridge (issue #4443)", () => { }); }); }); + +/** + * Register a live session simulating memory.backend=hindsight: it exposes a + * Hindsight state but no mnemopi state, so the handler must treat memory:// + * as unaddressable and return a corrective pointer (issue #7587). + */ +function withHindsightSession(fn: () => Promise): Promise { + const session = { + getHindsightSessionState: () => ({ bankId: "test-bank" }), + } as unknown as AgentSession; + AgentRegistry.global().register({ + id: "test-hindsight", + displayName: "test-hindsight", + kind: "main", + session, + sessionFile: null, + }); + return fn(); +} + +describe("MemoryProtocolHandler — hindsight (issue #7587)", () => { + beforeEach(() => { + AgentRegistry.resetGlobalForTests(); + InternalUrlRouter.resetForTests(); + }); + + afterEach(() => { + AgentRegistry.resetGlobalForTests(); + InternalUrlRouter.resetForTests(); + }); + + it("returns a corrective error for memory:// when hindsight is active", async () => { + await withHindsightSession(async () => { + const router = InternalUrlRouter.instance(); + await expect(router.resolve("memory://a1b2c3d4e5f6")).rejects.toThrow( + /Hindsight memories are not addressable via memory:\/\/.*use `recall`.*`reflect`/s, + ); + }); + }); + + it("uses the calling session backend when hindsight and mnemopi sessions coexist", async () => { + await withMnemopiSession(async () => { + await withHindsightSession(async () => { + const router = InternalUrlRouter.instance(); + const settings = Settings.isolated({ "memory.backend": "hindsight" }); + await expect(router.resolve("memory://a1b2c3d4e5f6", { settings })).rejects.toThrow( + /Hindsight memories are not addressable via memory:\/\//, + ); + }); + }); + }); + + it("keeps the generic namespace error when no memory backend is active", async () => { + const router = InternalUrlRouter.instance(); + await expect(router.resolve("memory://a1b2c3d4e5f6")).rejects.toThrow( + /Unknown memory namespace: a1b2c3d4e5f6\. Supported: root/, + ); + }); +}); diff --git a/packages/coding-agent/test/model-discovery.test.ts b/packages/coding-agent/test/model-discovery.test.ts index 2b740a3c7..a252926df 100644 --- a/packages/coding-agent/test/model-discovery.test.ts +++ b/packages/coding-agent/test/model-discovery.test.ts @@ -2172,6 +2172,80 @@ providers: expect(unknown?.reasoning).toBe(false); }); + test("openai-models-list discovery reads server-advertised input modalities for ids absent from the catalog", async () => { + writeRawModelsJson({ + "openai-test": { + baseUrl: "http://127.0.0.1:9996", + api: "openai-completions", + auth: "none", + discovery: { type: "openai-models-list" }, + }, + }); + const fetchMock: FetchImpl = async input => { + const url = String(input); + if (url === "http://127.0.0.1:9996/v1/models") { + // Custom virtual tier ids that are absent from the bundled + // catalog: their vision support can only come from the server row. + return new Response( + JSON.stringify({ + data: [ + { id: "high", object: "model", input: ["text", "image"] }, + { id: "leftover", object: "model", architecture: { input_modalities: ["text", "image"] } }, + { id: "synthetic-tier", object: "model", input_modalities: ["text", "image"] }, + { id: "low", object: "model", input: ["text"] }, + { id: "medium", object: "model" }, + ], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + throw new Error(`Unexpected URL: ${url}`); + }; + const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); + await registry.refresh(); + // Direct `input`, top-level `input_modalities`, and OpenRouter-style + // `architecture.input_modalities` all surface vision support. + expect(registry.find("openai-test", "high")?.input).toEqual(["text", "image"]); + expect(registry.find("openai-test", "leftover")?.input).toEqual(["text", "image"]); + expect(registry.find("openai-test", "synthetic-tier")?.input).toEqual(["text", "image"]); + // Server explicitly reports text-only; no image support invented. + expect(registry.find("openai-test", "low")?.input).toEqual(["text"]); + // Silent server → default text-only fallback. + expect(registry.find("openai-test", "medium")?.input).toEqual(["text"]); + }); + + test("lm-studio discovery keeps native VLM modalities over a thin OpenAI row", async () => { + writeRawModelsJson({ + "lm-studio-test": { + baseUrl: "http://127.0.0.1:9995", + api: "openai-completions", + auth: "none", + discovery: { type: "lm-studio" }, + }, + }); + const fetchMock: FetchImpl = async input => { + const url = String(input); + if (url === "http://127.0.0.1:9995/v1/models") { + return new Response(JSON.stringify({ data: [{ id: "local-vlm", object: "model", input: ["text"] }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === "http://127.0.0.1:9995/api/v0/models") { + return new Response( + JSON.stringify({ + data: [{ id: "local-vlm", type: "vlm", capabilities: ["vision"], state: "loaded" }], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + throw new Error(`Unexpected URL: ${url}`); + }; + const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); + await registry.refresh(); + expect(registry.find("lm-studio-test", "local-vlm")?.input).toEqual(["text", "image"]); + }); + test("proxy discovery honors API-reported context_length and endpoint routing", async () => { writeRawModelsJson({ "proxy-test": { diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index 87f1f7247..591c3cb53 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -1876,6 +1876,7 @@ describe("ModelRegistry", () => { let vertexStale: ModelRegistry; let litellmStaleNamespaceCache: ModelRegistry; let litellmCurrentNamespaceCache: ModelRegistry; + let openaiModelsListStaleNamespaceCache: ModelRegistry; const vertexProjectModel = () => buildModel({ id: "zai-org/glm-4.7-maas", @@ -2101,7 +2102,7 @@ describe("ModelRegistry", () => { { seedCache: dbPath => writeModelCache( - "cached-compact-proxy:openai-models-list-context-v2", + "cached-compact-proxy:openai-models-list-context-v3", Date.now(), [ buildModel({ @@ -2171,6 +2172,45 @@ describe("ModelRegistry", () => { dbPath, ), }); + openaiModelsListStaleNamespaceCache = readonlyRegistry( + { + providers: { + "stale-openai-proxy": { + baseUrl: "https://stale-proxy.example.com/v1", + apiKey: "TEST_KEY", + api: "openai-completions", + discovery: { type: "openai-models-list" }, + models: [], + }, + }, + }, + { + // Row under the retired pre-modality namespace; the context-v3 + // bump must orphan it instead of serving the stale text-only row. + seedCache: dbPath => + writeModelCache( + "stale-openai-proxy:openai-models-list-context-v2", + Date.now(), + [ + buildModel({ + id: "stale-vlm", + name: "Stale VLM", + api: "openai-completions", + provider: "stale-openai-proxy", + baseUrl: "https://stale-proxy.example.com/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + }), + ], + true, + "", + dbPath, + ), + }, + ); }); test("legacy cached discovery sentinels are ignored after nullable limit cutover", () => { @@ -2223,6 +2263,13 @@ describe("ModelRegistry", () => { expect(model?.provider).toBe("litellm-proxy"); }); + test("ignores openai-models-list rows cached under the retired context-v2 namespace", () => { + // PR #7584 added server-advertised input-modality parsing; warm v2 rows + // pinned vision-capable ids at text-only and must not load. + expect(openaiModelsListStaleNamespaceCache.find("stale-openai-proxy", "stale-vlm")).toBeUndefined(); + expect(getModelsForProvider(openaiModelsListStaleNamespaceCache, "stale-openai-proxy")).toHaveLength(0); + }); + test("replaces bundled google-vertex models with authoritative Vertex project discovery", () => { const vertexModels = getModelsForProvider(vertexAuthoritative, "google-vertex"); expect(vertexModels.map(model => model.id)).toEqual(["zai-org/glm-4.7-maas"]); diff --git a/packages/coding-agent/test/modes/noninteractive-dispose.test.ts b/packages/coding-agent/test/modes/noninteractive-dispose.test.ts index 918ecb201..b6b5b12e1 100644 --- a/packages/coding-agent/test/modes/noninteractive-dispose.test.ts +++ b/packages/coding-agent/test/modes/noninteractive-dispose.test.ts @@ -40,6 +40,7 @@ describe("print-mode error exit disposes the session before exit", () => { state: { messages: [errorMsg] }, getLastAssistantMessage: () => errorMsg, prepareForHeadlessAdvisorDrain: () => {}, + setTextOutputCommitted: () => {}, waitForAdvisorCatchup: async () => { order.push("catchup"); return true; diff --git a/packages/coding-agent/test/print-mode-json-flush.test.ts b/packages/coding-agent/test/print-mode-json-flush.test.ts new file mode 100644 index 000000000..0e5d4d398 --- /dev/null +++ b/packages/coding-agent/test/print-mode-json-flush.test.ts @@ -0,0 +1,150 @@ +/** + * Regression (#7635): `--mode json` must not exit until the final record's + * stdout write has fully drained. + * + * The JSON path emitted each event with a fire-and-forget `process.stdout.write` + * and relied on an empty-write "flush barrier" before dispose/exit. The barrier + * awaited its own callback, not the preceding large write, so a big final + * `agent_end` (multi-MB) could be truncated when the process exited before the + * pipe drained — while still exiting 0. The fix serializes every print-mode + * stdout write on its own completion callback and blocks shutdown on the tail. + * + * Contract: `runPrintMode` stays pending until the final record's write callback + * fires (so `process.exit` can't discard it), and the full record is delivered. + */ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import { runPrintMode } from "@oh-my-pi/pi-coding-agent/modes/print-mode"; +import type { AgentSession, AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; + +interface FlushHarness { + session: AgentSession; + promptStarted: Promise; + resolvePrompt: () => void; + emit: (event: AgentSessionEvent) => void; + disposed: () => boolean; +} + +function createFlushHarness(): FlushHarness { + const { promise: promptStarted, resolve: markPromptStarted } = Promise.withResolvers(); + const { promise: promptReleased, resolve: resolvePrompt } = Promise.withResolvers(); + let subscriber: ((event: AgentSessionEvent) => void) | undefined; + let disposed = false; + let advisorDrainPrepared = false; + + const session = { + sessionManager: { + getHeader: () => undefined, + buildSessionContext: () => ({ messages: [] }), + getEntries: () => [], + }, + settings: { get: () => false }, + extensionRunner: undefined, + subscribe: (listener: (event: AgentSessionEvent) => void) => { + subscriber = listener; + return () => {}; + }, + prompt: async () => { + markPromptStarted(); + await promptReleased; + return true; + }, + prepareForHeadlessAdvisorDrain: () => { + advisorDrainPrepared = true; + }, + waitForAdvisorCatchup: async () => { + if (!advisorDrainPrepared) throw new Error("advisor catch-up started before headless delivery was armed"); + }, + dispose: async () => { + disposed = true; + }, + } as unknown as AgentSession; + + return { + session, + promptStarted, + resolvePrompt, + emit: event => subscriber?.(event), + disposed: () => disposed, + }; +} + +function makeLargeAgentEnd(payload: string): AgentSessionEvent { + return { + type: "agent_end", + messages: [ + { + role: "assistant", + content: [{ type: "text", text: payload }], + stopReason: "aborted", + errorMessage: "Deadline exceeded", + timestamp: Date.now(), + }, + ], + } as unknown as AgentSessionEvent; +} + +describe("print-mode JSON flush (#7635)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("blocks exit until the final agent_end write drains, then delivers it in full", async () => { + const writes: string[] = []; + let releaseAgentEnd: (() => void) | undefined; + const { promise: agentEndWriteIssued, resolve: markAgentEndWriteIssued } = Promise.withResolvers(); + vi.spyOn(process.stdout, "write").mockImplementation((...args: unknown[]) => { + const chunk = args[0]; + const text = typeof chunk === "string" ? chunk : Buffer.from(chunk as Uint8Array).toString(); + writes.push(text); + const cb = args[args.length - 1]; + const invoke = typeof cb === "function" ? (cb as (err?: Error | null) => void) : undefined; + // Defer the large agent_end record's completion callback to emulate a + // backpressured pipe; every other write completes synchronously. + if (text.includes('"type":"agent_end"')) { + releaseAgentEnd = () => invoke?.(null); + markAgentEndWriteIssued(); + } else { + invoke?.(null); + } + return true; + }); + + const payload = "x".repeat(1_500_000); + const harness = createFlushHarness(); + + const run = runPrintMode(harness.session, { mode: "json", initialMessage: "hello" }); + let settled = false; + void run.then(() => { + settled = true; + }); + + await harness.promptStarted; + harness.emit(makeLargeAgentEnd(payload)); + harness.resolvePrompt(); + + // Drain to quiescence: every step runPrintMode can complete without the + // deferred write is microtask-driven, so one macrotask boundary flushes + // them all. The pre-fix fire-and-forget path settles and disposes here; + // the fix must still be blocked on the undrained agent_end write. + await agentEndWriteIssued; + const { promise: nextTask, resolve: resolveNextTask } = Promise.withResolvers(); + setImmediate(resolveNextTask); + await nextTask; + expect(releaseAgentEnd).toBeDefined(); + expect(settled).toBe(false); + expect(harness.disposed()).toBe(false); + + releaseAgentEnd?.(); + await run; + + expect(settled).toBe(true); + expect(harness.disposed()).toBe(true); + + const agentEndLine = writes.find(line => line.includes('"type":"agent_end"')); + expect(agentEndLine).toBeDefined(); + expect(agentEndLine?.endsWith("\n")).toBe(true); + // The complete payload survives — not a pipe-buffer-sized prefix. + expect(agentEndLine).toContain(payload); + expect(JSON.parse(agentEndLine as string)).toMatchObject({ type: "agent_end" }); + }); +}); diff --git a/packages/coding-agent/test/print-mode-working-indicator.test.ts b/packages/coding-agent/test/print-mode-working-indicator.test.ts index fc86976f4..3db6b93ef 100644 --- a/packages/coding-agent/test/print-mode-working-indicator.test.ts +++ b/packages/coding-agent/test/print-mode-working-indicator.test.ts @@ -36,6 +36,7 @@ interface DelayedSession { promptStarted: Promise; resolvePrompt: () => void; getPlanModeAtPrompt: () => PlanModeState | undefined; + getTextOutputCommitted: () => boolean; getModeChanges: () => Array<{ mode: string; data?: Record }>; getPlanProposalHandler: () => PlanProposalHandler | undefined; getCurrentPlanMode: () => PlanModeState | undefined; @@ -57,6 +58,7 @@ function createDelayedSession( const modeChanges: Array<{ mode: string; data?: Record }> = []; let planProposalHandler: PlanProposalHandler | undefined; let subscriber: ((event: AgentSessionEvent) => void) | undefined; + let textOutputCommitted = true; let abortCalls = 0; const session = { @@ -105,6 +107,9 @@ function createDelayedSession( abort: async () => { abortCalls++; }, + setTextOutputCommitted: (committed: boolean) => { + textOutputCommitted = committed; + }, subscribe: (listener: (event: AgentSessionEvent) => void) => { subscriber = listener; return () => {}; @@ -133,6 +138,7 @@ function createDelayedSession( getPlanModeAtPrompt: () => planModeAtPrompt, getModeChanges: () => modeChanges, getPlanProposalHandler: () => planProposalHandler, + getTextOutputCommitted: () => textOutputCommitted, getCurrentPlanMode: () => planModeState, emit: event => subscriber?.(event), getAbortCalls: () => abortCalls, @@ -226,12 +232,14 @@ describe("print mode working indicator", () => { try { expect(stderrOutput.join("")).toContain("Working"); expect(stdoutOutput.join("")).toBe(""); + expect(delayed.getTextOutputCommitted()).toBe(false); } finally { delayed.resolvePrompt(); await run; } expect(stdoutOutput.join("")).toBe("final answer\n"); + expect(delayed.getTextOutputCommitted()).toBe(true); }); it("does not write the text-mode working indicator in JSON mode while the prompt is pending", async () => { @@ -241,6 +249,7 @@ describe("print mode working indicator", () => { await delayed.promptStarted; try { expect(stderrOutput.join("")).toBe(""); + expect(delayed.getTextOutputCommitted()).toBe(true); } finally { delayed.resolvePrompt(); await run; @@ -351,6 +360,7 @@ describe("print mode working indicator", () => { messages.push(message); return true; }, + setTextOutputCommitted: () => {}, prepareForHeadlessAdvisorDrain: () => {}, waitForAdvisorCatchup: async (timeoutMs: number) => { catchupTimeoutMs = timeoutMs; diff --git a/packages/coding-agent/test/registry/agent-lifecycle.test.ts b/packages/coding-agent/test/registry/agent-lifecycle.test.ts index 36e70837d..9f61c6de4 100644 --- a/packages/coding-agent/test/registry/agent-lifecycle.test.ts +++ b/packages/coding-agent/test/registry/agent-lifecycle.test.ts @@ -314,6 +314,23 @@ describe("AgentLifecycleManager", () => { expect(registry.get("6-Sub")).toBeUndefined(); }); + it("does not let one stuck adopted agent block sibling disposal", async () => { + const gate = deferred(); + const stuck = makeSessionStub(() => gate.promise); + const sibling = makeSessionStub(); + registerIdleSub("stuck-Sub", stuck.session); + registerIdleSub("sibling-Sub", sibling.session); + lifecycle.adopt("stuck-Sub", { idleTtlMs: TTL }); + lifecycle.adopt("sibling-Sub", { idleTtlMs: TTL }); + + await lifecycle.dispose(Date.now()); + + expect(stuck.disposeCalls()).toBe(1); + expect(sibling.disposeCalls()).toBe(1); + gate.resolve(); + await flushAsync(); + }); + it("a delayed release cannot remove or mutate a replacement ref with the same id", async () => { const gate = deferred(); const oldSession = makeSessionStub(() => gate.promise); diff --git a/packages/coding-agent/test/sdk-model-selection.test.ts b/packages/coding-agent/test/sdk-model-selection.test.ts index 87ea06ffd..14f8fa2ab 100644 --- a/packages/coding-agent/test/sdk-model-selection.test.ts +++ b/packages/coding-agent/test/sdk-model-selection.test.ts @@ -512,6 +512,33 @@ describe("createAgentSession deferred model pattern resolution", () => { } }); + test("rejects a depleted terminal fallback after startup skips the primary", async () => { + const settings = Settings.isolated({ + "retry.usageAwareFallback": true, + "retry.usageReservePolicy": "confirm", + }); + settings.setModelRole("task", "runtime-provider/runtime-model,runtime-provider/runtime-reasoning-model"); + const options = await buildSessionOptions("task"); + const usageHealth = vi.spyOn(options.authStorage, "getModelUsageHealth").mockResolvedValue({ + state: "depleted", + accounts: [{ credentialId: 1, credentialType: "oauth", state: "depleted" }], + }); + + const { session, modelFallbackMessage } = await createAgentSession({ + ...options, + modelPatternFallbackRole: "subagent:usage-aware-terminal", + settings, + hasUI: false, + }); + try { + expect(usageHealth).toHaveBeenCalledTimes(2); + expect(session.model).toBeUndefined(); + expect(modelFallbackMessage).toContain("not found"); + } finally { + await session.dispose(); + } + }); + test("defers ACP reserve fallback until prompt-time capabilities are configured", async () => { const settings = Settings.isolated({ "retry.usageAwareFallback": true, diff --git a/packages/coding-agent/test/session-manager-atomic-rewrite-race.test.ts b/packages/coding-agent/test/session-manager-atomic-rewrite-race.test.ts index a82c94e9e..6a4d5729b 100644 --- a/packages/coding-agent/test/session-manager-atomic-rewrite-race.test.ts +++ b/packages/coding-agent/test/session-manager-atomic-rewrite-race.test.ts @@ -27,7 +27,10 @@ class DetachingRewriteStorage extends MemorySessionStorage { guardRejections = 0; readonly #writers = new Set(); - openWriter(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }): SessionStorageWriter { + override openWriter( + path: string, + options?: { flags?: "a" | "w"; onError?: (err: Error) => void }, + ): SessionStorageWriter { const inner = super.openWriter(path, options); const writers = this.#writers; const detachedLines = this.detachedLines; diff --git a/packages/coding-agent/test/session-manager/large-session-memory.test.ts b/packages/coding-agent/test/session-manager/large-session-memory.test.ts index 591187a2a..3d5df8523 100644 --- a/packages/coding-agent/test/session-manager/large-session-memory.test.ts +++ b/packages/coding-agent/test/session-manager/large-session-memory.test.ts @@ -12,7 +12,7 @@ import * as snapcompact from "@oh-my-pi/snapcompact"; class CountingMemorySessionStorage extends MemorySessionStorage { writeTextSyncCalls = 0; - writeTextSync(filePath: string, content: string): void { + override writeTextSync(filePath: string, content: string): void { this.writeTextSyncCalls++; super.writeTextSync(filePath, content); } diff --git a/packages/coding-agent/test/session-paths.test.ts b/packages/coding-agent/test/session-paths.test.ts new file mode 100644 index 000000000..f437f5851 --- /dev/null +++ b/packages/coding-agent/test/session-paths.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { computeDefaultSessionDir } from "@oh-my-pi/pi-coding-agent/session/session-paths"; +import { FileSessionStorage } from "@oh-my-pi/pi-coding-agent/session/session-storage"; + +const cleanup: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + cleanup.push(dir); + return dir; +} + +function legacySessionDir(sessionsRoot: string, cwd: string): string { + const name = `--${path + .resolve(cwd) + .replace(/^[/\\]/, "") + .replace(/[/\\:]/g, "-")}--`; + return path.join(sessionsRoot, name); +} + +afterEach(() => { + for (const dir of cleanup.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe("legacy session directory migration", () => { + test("keeps a colliding live legacy session reachable through its path", () => { + const sessionsRoot = makeTempDir("omp-session-root-"); + const cwd = makeTempDir("omp-session-cwd-"); + const storage = new FileSessionStorage(); + const canonicalDir = computeDefaultSessionDir(cwd, storage, sessionsRoot); + const legacyDir = legacySessionDir(sessionsRoot, cwd); + const source = path.join(legacyDir, "active.jsonl"); + const destination = path.join(canonicalDir, "active.jsonl"); + fs.mkdirSync(legacyDir, { recursive: true }); + fs.writeFileSync(source, "live-before\n"); + fs.writeFileSync(destination, "stale\n"); + const fd = fs.openSync(source, "a"); + + computeDefaultSessionDir(cwd, storage, sessionsRoot); + fs.writeSync(fd, "live-after\n"); + fs.closeSync(fd); + + expect(fs.readFileSync(source, "utf8")).toBe("live-before\nlive-after\n"); + expect(fs.readFileSync(destination, "utf8")).toBe("stale\n"); + }); + + test("preserves writes when an older process recreates its cached legacy directory", () => { + const sessionsRoot = makeTempDir("omp-session-root-"); + const cwd = makeTempDir("omp-session-cwd-"); + const storage = new FileSessionStorage(); + const canonicalDir = computeDefaultSessionDir(cwd, storage, sessionsRoot); + const legacyDir = legacySessionDir(sessionsRoot, cwd); + const destination = path.join(canonicalDir, "active.jsonl"); + fs.writeFileSync(destination, "canonical\n"); + + fs.mkdirSync(legacyDir, { recursive: true }); + const recreated = path.join(legacyDir, "active.jsonl"); + fs.writeFileSync(recreated, "older-process-write\n"); + computeDefaultSessionDir(cwd, storage, sessionsRoot); + + expect(fs.readFileSync(recreated, "utf8")).toBe("older-process-write\n"); + expect(fs.readFileSync(destination, "utf8")).toBe("canonical\n"); + }); +}); diff --git a/packages/coding-agent/test/settings-stream-fn.test.ts b/packages/coding-agent/test/settings-stream-fn.test.ts index 424889447..44be30735 100644 --- a/packages/coding-agent/test/settings-stream-fn.test.ts +++ b/packages/coding-agent/test/settings-stream-fn.test.ts @@ -81,7 +81,17 @@ describe("createSettingsAwareStreamFn", () => { expect(calls[0]?.options?.hideThinkingSummary).toBe(true); }); - it("applies Responses-family text verbosity from settings while preserving caller overrides", () => { + it("applies Codex text verbosity only when settings or caller options configure it", () => { + const unconfiguredSettings = Settings.isolated({}); + const { fn: unconfiguredBase, calls: unconfiguredCalls } = captureBase(); + const unconfiguredWrapped = createSettingsAwareStreamFn(unconfiguredSettings, unconfiguredBase); + + unconfiguredWrapped(stubCodexModel, stubContext, undefined); + unconfiguredWrapped(stubCodexModel, stubContext, { textVerbosity: "medium" }); + + expect(unconfiguredCalls[0]?.options?.textVerbosity).toBeUndefined(); + expect(unconfiguredCalls[1]?.options?.textVerbosity).toBe("medium"); + const settings = Settings.isolated({ textVerbosity: "low" }); const { fn: base, calls } = captureBase(); const wrapped = createSettingsAwareStreamFn(settings, base); diff --git a/packages/coding-agent/test/silent-abort-overlay-render.test.ts b/packages/coding-agent/test/silent-abort-overlay-render.test.ts index 7ef4de7ee..e07706b23 100644 --- a/packages/coding-agent/test/silent-abort-overlay-render.test.ts +++ b/packages/coding-agent/test/silent-abort-overlay-render.test.ts @@ -33,6 +33,7 @@ function makeJsonlSessionFile(dirPath: string, entries: object[]): string { function makeSubagentRegistry(sessions: ObservableSession[]) { return { getSessions: () => sessions, + getSession: (id: string) => sessions.find(session => session.id === id), onChange: () => () => {}, setMainSession: () => {}, getActiveSubagentCount: () => sessions.filter(s => s.status === "active").length, diff --git a/packages/coding-agent/test/silent-abort-print-mode.test.ts b/packages/coding-agent/test/silent-abort-print-mode.test.ts index 32cae2d3f..2240f078b 100644 --- a/packages/coding-agent/test/silent-abort-print-mode.test.ts +++ b/packages/coding-agent/test/silent-abort-print-mode.test.ts @@ -55,6 +55,7 @@ function createMockSession( subscribe: () => () => {}, prompt: async () => {}, prepareForHeadlessAdvisorDrain: () => {}, + setTextOutputCommitted: () => {}, waitForAdvisorCatchup: async () => true, dispose, } as unknown as AgentSession; diff --git a/packages/coding-agent/test/skills.test.ts b/packages/coding-agent/test/skills.test.ts index 5b8c43e6b..148615b79 100644 --- a/packages/coding-agent/test/skills.test.ts +++ b/packages/coding-agent/test/skills.test.ts @@ -4,6 +4,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { type Skill as CapabilitySkill, skillCapability } from "@oh-my-pi/pi-coding-agent/capability/skill"; import { getCapability } from "@oh-my-pi/pi-coding-agent/discovery"; +import { getWslWindowsHomeCandidate } from "@oh-my-pi/pi-coding-agent/discovery/agents"; import { loadSkills, loadSkillsFromDir, @@ -234,6 +235,69 @@ describe("skills", () => { } }); + it("should load Windows host ~/.agents/skills when running under WSL (#3779)", async () => { + const tempHostHome = await fs.mkdtemp(path.join(os.tmpdir(), "pi-agents-wsl-host-")); + const tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "pi-agents-wsl-cwd-")); + const skillDir = path.join(tempHostHome, ".agents", "skills", "wsl-host-skill"); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile( + path.join(skillDir, "SKILL.md"), + ["---", "description: Loaded from WSL host USERPROFILE", "---", "", "# wsl-host-skill"].join("\n"), + ); + const previousWslDistroName = process.env.WSL_DISTRO_NAME; + const previousWslInterop = process.env.WSL_INTEROP; + const previousUserProfile = process.env.USERPROFILE; + const previousPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "linux" }); + process.env.WSL_DISTRO_NAME = "Ubuntu"; + delete process.env.WSL_INTEROP; + process.env.USERPROFILE = tempHostHome; + try { + const { skills } = await loadSkills({ + enableCodexUser: false, + enableClaudeUser: false, + enableClaudeProject: false, + enablePiUser: false, + enablePiProject: false, + cwd: tempCwd, + }); + const skill = skills.find(s => s.name === "wsl-host-skill"); + expect(skill?.source).toBe("agents:user"); + expect(skill?.filePath).toBe(path.join(skillDir, "SKILL.md")); + } finally { + if (previousWslDistroName === undefined) delete process.env.WSL_DISTRO_NAME; + else process.env.WSL_DISTRO_NAME = previousWslDistroName; + if (previousWslInterop === undefined) delete process.env.WSL_INTEROP; + else process.env.WSL_INTEROP = previousWslInterop; + if (previousUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = previousUserProfile; + Object.defineProperty(process, "platform", { value: previousPlatform }); + await removeWithRetries(tempHostHome); + await removeWithRetries(tempCwd); + } + }); + + it("converts Windows USERPROFILE paths to the default WSL mount (#3779)", () => { + const resolved = getWslWindowsHomeCandidate({ + platform: "linux", + env: { WSL_DISTRO_NAME: "Ubuntu", USERPROFILE: "C:\\Users\\alice" }, + wslPath: () => undefined, + }); + + expect(resolved).toBe(path.join("/mnt", "c", "Users", "alice")); + }); + + it("resolves the Windows profile through interop when USERPROFILE is not exported (#3779)", () => { + const resolved = getWslWindowsHomeCandidate({ + platform: "linux", + env: { WSL_DISTRO_NAME: "Ubuntu" }, + windowsUserProfile: () => "C:\\Users\\alice", + wslPath: () => "/mnt/c/Users/alice", + }); + + expect(resolved).toBe("/mnt/c/Users/alice"); + }); + it("respects an explicit enableAgentsUser: false (#2401)", async () => { const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "pi-agents-home-off-")); const tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "pi-agents-cwd-off-")); diff --git a/packages/coding-agent/test/task/executor-async-quiescence.test.ts b/packages/coding-agent/test/task/executor-async-quiescence.test.ts index 69e8e7dc8..ca95f5f5f 100644 --- a/packages/coding-agent/test/task/executor-async-quiescence.test.ts +++ b/packages/coding-agent/test/task/executor-async-quiescence.test.ts @@ -8,6 +8,7 @@ */ import { afterEach, describe, expect, it, vi } from "bun:test"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; +import { AsyncJobManager } from "@oh-my-pi/pi-coding-agent/async/job-manager"; import type { LoadExtensionsResult } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/types"; import type { CreateAgentSessionResult } from "@oh-my-pi/pi-coding-agent/sdk"; import * as sdkModule from "@oh-my-pi/pi-coding-agent/sdk"; @@ -18,7 +19,7 @@ import { EventBus } from "@oh-my-pi/pi-coding-agent/utils/event-bus"; const baseAgent: AgentDefinition = { name: "task", description: "test", systemPrompt: "test", source: "bundled" }; -function assistantStopMessage(text: string): AssistantMessage { +function assistantStopMessage(text: string, totalTokens = 0): AssistantMessage { return { role: "assistant", content: [{ type: "text", text }], @@ -27,10 +28,10 @@ function assistantStopMessage(text: string): AssistantMessage { model: "mock", usage: { input: 0, - output: 0, + output: totalTokens, cacheRead: 0, cacheWrite: 0, - totalTokens: 0, + totalTokens, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "stop", @@ -44,9 +45,15 @@ interface AsyncQuiescenceHarness { abortCalls: () => number; settleCalls: () => number; emitTerminalYield: (data: unknown) => void; + emitAssistant: (text: string, totalTokens?: number) => void; finishJob: () => void; } +interface AsyncSessionOptions { + abort?: () => Promise; + dispose?: () => Promise; +} + /** * Mock session with the owner-async surface the barrier drives: * `hasPendingAsyncWork` / `getAsyncJobSnapshot` / `settleAsyncWork`. The job @@ -56,6 +63,7 @@ interface AsyncQuiescenceHarness { */ function createAsyncSession( onPrompt: (params: { text: string; promptIndex: number; harness: AsyncQuiescenceHarness }) => void, + options: AsyncSessionOptions = {}, ): AsyncQuiescenceHarness { const listeners: Array<(event: AgentSessionEvent) => void> = []; const state = { messages: [] as AssistantMessage[] }; @@ -103,6 +111,11 @@ function createAsyncSession( state.messages.push(reaction); emit({ type: "message_end", message: reaction } as AgentSessionEvent); }; + const emitAssistant = (text: string, totalTokens = 0) => { + const message = assistantStopMessage(text, totalTokens); + state.messages.push(message); + emit({ type: "message_end", message } as AgentSessionEvent); + }; const harness: AsyncQuiescenceHarness = { session: undefined as unknown as AgentSession, @@ -110,6 +123,7 @@ function createAsyncSession( abortCalls: () => abortCount, settleCalls: () => settleCount, emitTerminalYield, + emitAssistant, finishJob, }; @@ -143,8 +157,9 @@ function createAsyncSession( }, abort: async () => { abortCount += 1; + await options.abort?.(); }, - dispose: async () => {}, + dispose: options.dispose ?? (async () => {}), setIrcWakeTurnObserver: () => {}, }; harness.session = session as unknown as AgentSession; @@ -163,6 +178,7 @@ function mockCreateAgentSession(session: AgentSession) { describe("runSubprocess async quiescence fresh-yield contract", () => { afterEach(() => { vi.restoreAllMocks(); + AsyncJobManager.resetForTests(); }); it("parks a pending yield, injects the result, and completes on the fresh yield", async () => { @@ -250,4 +266,125 @@ describe("runSubprocess async quiescence fresh-yield contract", () => { expect(result.exitCode).toBe(0); expect(result.output).toContain("done"); }); + + it("does not wait on a second idle barrier after a terminal yield", async () => { + const harness = createAsyncSession(({ promptIndex, harness: h }) => { + if (promptIndex === 1) { + h.finishJob(); + h.emitTerminalYield({ report: "done" }); + } + }); + const idleStarted = Promise.withResolvers(); + const releaseIdle = Promise.withResolvers(); + let idleCalls = 0; + harness.session.waitForIdle = async () => { + idleCalls += 1; + idleStarted.resolve(); + await releaseIdle.promise; + }; + mockCreateAgentSession(harness.session); + + const run = runSubprocess({ + cwd: "/tmp", + agent: baseAgent, + task: "do the work", + index: 0, + id: "quiescence-no-second-idle", + }); + const outcome = await Promise.race([ + run.then(() => "completed" as const), + idleStarted.promise.then(() => "blocked" as const), + ]); + releaseIdle.resolve(); + const result = await run; + + expect(outcome).toBe("completed"); + expect(idleCalls).toBe(0); + expect(result.exitCode).toBe(0); + expect(result.output).toContain("done"); + }); + + it("returns an aborted result after cleanup grace and waits for every late resource", async () => { + const abortStarted = Promise.withResolvers(); + const abortGate = Promise.withResolvers(); + const disposeGate = Promise.withResolvers(); + const lateJobGate = Promise.withResolvers(); + const manager = new AsyncJobManager({}); + AsyncJobManager.setInstance(manager); + let lateJobId: string | undefined; + let deferredCleanup: Promise | undefined; + const harness = createAsyncSession( + ({ promptIndex, harness: h }) => { + if (promptIndex !== 1) return; + h.finishJob(); + h.emitAssistant("captured before cleanup", 7); + h.emitTerminalYield({ report: "yielded output" }); + }, + { + abort: async () => { + abortStarted.resolve(); + await abortGate.promise; + }, + dispose: async () => { + lateJobId = manager.register( + "task", + "shutdown-time job", + async () => { + await lateJobGate.promise; + return "late result"; + }, + { ownerId: "cleanup-timeout" }, + ); + await disposeGate.promise; + }, + }, + ); + mockCreateAgentSession(harness.session); + + const run = runSubprocess({ + cwd: "/tmp", + agent: baseAgent, + task: "do the work", + index: 0, + id: "cleanup-timeout", + keepAlive: false, + onCleanupDeferred: completion => { + deferredCleanup = completion; + }, + }); + await abortStarted.promise; + + const result = await run; + expect(result.exitCode).toBe(1); + expect(result.aborted).toBe(true); + expect(result.abortReason).toBe("cleanup exceeded 10000 ms"); + expect(result.error).toBe( + "Task aborted. Cleanup did not finish within 10000 ms. This task was not isolated, so its changes may remain in the working directory.", + ); + expect(result.output).toContain("yielded output"); + expect(result.usage?.totalTokens).toBe(7); + expect(lateJobId).toBeDefined(); + expect(deferredCleanup).toBeDefined(); + + let cleanupSettled = false; + const cleanupOutcome = deferredCleanup?.then( + () => { + cleanupSettled = true; + }, + () => { + cleanupSettled = true; + }, + ); + abortGate.resolve(); + disposeGate.reject(new Error("dispose failed")); + for (let attempt = 0; attempt < 10 && manager.getJob(lateJobId ?? "")?.status === "running"; attempt += 1) { + await Promise.resolve(); + } + expect(cleanupSettled).toBe(false); + expect(manager.getJob(lateJobId ?? "")?.status).toBe("cancelled"); + + lateJobGate.resolve(); + await cleanupOutcome; + expect(cleanupSettled).toBe(true); + }, 15_000); }); diff --git a/packages/coding-agent/test/task/isolation-runner.test.ts b/packages/coding-agent/test/task/isolation-runner.test.ts index f29dc52de..591d396ec 100644 --- a/packages/coding-agent/test/task/isolation-runner.test.ts +++ b/packages/coding-agent/test/task/isolation-runner.test.ts @@ -149,6 +149,63 @@ describe("runIsolatedSubprocess", () => { expect(cleanupSpy).toHaveBeenCalledTimes(1); expect(AgentRegistry.global().get("PreserveBranchFailure")?.history?.patchPath).toBe(patchPath); }); + + it("keeps an isolated worktree until deferred child cleanup settles", async () => { + const cleanupGate = Promise.withResolvers(); + vi.spyOn(worktreeModule, "ensureIsolation").mockResolvedValue({ + mergedDir: "/repo/isolated", + backend: natives.IsoBackendKind.Rcopy, + fellBack: false, + fallbackReason: null, + }); + vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { + options.onCleanupDeferred?.(cleanupGate.promise); + return result({ exitCode: 1, aborted: true, error: "cleanup exceeded its deadline" }); + }); + const cleanupSpy = vi.spyOn(worktreeModule, "cleanupIsolation").mockResolvedValue(); + + const outcome = await runIsolatedSubprocess({ + baseOptions: { + cwd: "/repo", + agent: { + name: "task", + description: "Task agent", + systemPrompt: "test", + source: "bundled", + }, + task: "Do work", + index: 0, + id: "DeferredCleanup", + }, + context: { + repoRoot: "/repo", + baseline: { + root: { + repoRoot: "/repo", + headCommit: "base", + staged: "", + unstaged: "", + untracked: [], + untrackedPatch: "", + }, + nested: [], + }, + }, + preferredBackend: undefined, + agentId: "DeferredCleanup", + mergeMode: "patch", + artifactsDir: "/artifacts", + buildFailureResult: error => result({ exitCode: 1, error: String(error) }), + }); + + expect(outcome.exitCode).toBe(1); + expect(cleanupSpy).not.toHaveBeenCalled(); + cleanupGate.resolve(); + await cleanupGate.promise; + await Promise.resolve(); + await Promise.resolve(); + expect(cleanupSpy).toHaveBeenCalledTimes(1); + }); }); describe("mergeIsolatedChanges", () => { diff --git a/packages/coding-agent/test/tools/bash-interceptor.test.ts b/packages/coding-agent/test/tools/bash-interceptor.test.ts index 104e53bea..924aacd6d 100644 --- a/packages/coding-agent/test/tools/bash-interceptor.test.ts +++ b/packages/coding-agent/test/tools/bash-interceptor.test.ts @@ -77,13 +77,19 @@ describe("compound command interception", () => { "git add file && git commit -m message", "git add file; git commit -m message", "git add file || git commit -m message", - "git add file | git commit -m message", "git add file & git commit -m message", "git add file\ngit commit -m message", ])("blocks a later command after %s", command => { expect(checkBashInterception(command, ["commit"], rules).block).toBe(true); }); + it("does not intercept a downstream pipe stage that consumes piped stdin", () => { + // `git commit` after a single `|` reads the previous stage's stdout, so + // the dedicated tool cannot replace it. `||` still starts a fresh command. + expect(checkBashInterception("git add file | git commit -m message", ["commit"], rules).block).toBe(false); + expect(checkBashInterception("git add file || git commit -m message", ["commit"], rules).block).toBe(true); + }); + it("removes one or more leading environment assignments before matching", () => { expect( checkBashInterception('GIT_AUTHOR_EMAIL="a@example.com" git commit -m message', ["commit"], rules).block, @@ -203,6 +209,40 @@ describe("default echo/printf redirect rule", () => { }); }); +describe("default grep rule and pipeline stdin", () => { + const tools = ["grep"]; + + it("blocks standalone file searches", () => { + expect(checkBashInterception("grep pattern path", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(true); + expect(checkBashInterception("rg pattern src", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(true); + }); + + it("blocks a first-stage grep that produces pipeline input", () => { + expect(checkBashInterception("grep x file | wc -l", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(true); + }); + + it("does not block grep consuming pipeline stdin", () => { + expect(checkBashInterception("printf 'x\\n' | grep x", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(false); + expect( + checkBashInterception("tr -d '\\r' < input.log | grep -v '^ *foo'", tools, DEFAULT_BASH_INTERCEPTOR_RULES) + .block, + ).toBe(false); + expect(checkBashInterception("printf 'x\\n' |\n grep x", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe( + false, + ); + expect( + checkBashInterception("printf 'x\\n' |\n # filter\n grep x", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block, + ).toBe(false); + expect(checkBashInterception("printf 'x\\n' |& grep x", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(false); + }); + + it("still blocks a standalone grep sequenced after a pipeline", () => { + expect( + checkBashInterception("cat log | tr a b && grep err file", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block, + ).toBe(true); + }); +}); + describe("default hub start rules", () => { const tools = ["hub"]; diff --git a/packages/coding-agent/test/tools/browser-launch.test.ts b/packages/coding-agent/test/tools/browser-launch.test.ts index 1d677507f..0026a43fc 100644 --- a/packages/coding-agent/test/tools/browser-launch.test.ts +++ b/packages/coding-agent/test/tools/browser-launch.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from "bun:test"; -import { stealthIgnoreDefaultArgsForTest } from "@oh-my-pi/pi-coding-agent/tools/browser/launch"; + +import * as path from "node:path"; +import { + stealthIgnoreDefaultArgsForTest, + systemChromiumCandidatesForTest, +} from "@oh-my-pi/pi-coding-agent/tools/browser/launch"; +import { TempDir } from "@oh-my-pi/pi-utils"; + +const EXECUTABLE_PROBE = path.resolve(import.meta.dir, "../fixtures/browser-executable-probe.ts"); const AUTOMATION_FLAG = "--enable-automation"; @@ -33,3 +41,88 @@ describe("browser launch stealth defaults", () => { } }); }); + +const UNGOOGLED_CHROMIUM_FLATPAK_ID = "io.github.ungoogled_software.ungoogled_chromium"; + +describe("system Chromium candidates", () => { + const linuxCandidates = (which: (name: string) => string | undefined = () => undefined) => + systemChromiumCandidatesForTest("linux", "/home/test", which); + + it("offers Ungoogled Chromium executables on Linux", () => { + const candidates = linuxCandidates(); + + expect(candidates).toContain("/usr/bin/ungoogled-chromium"); + expect(candidates).toContain("/usr/bin/ungoogled-chromium-browser"); + expect(candidates).toContain(`/var/lib/flatpak/exports/bin/${UNGOOGLED_CHROMIUM_FLATPAK_ID}`); + expect(candidates).toContain(`/home/test/.local/share/flatpak/exports/bin/${UNGOOGLED_CHROMIUM_FLATPAK_ID}`); + }); + + it("keeps the previously supported Linux executables", () => { + const candidates = linuxCandidates(); + + for (const executablePath of [ + "/usr/bin/google-chrome-stable", + "/usr/bin/google-chrome", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/snap/bin/chromium", + "/var/lib/flatpak/exports/bin/com.google.Chrome", + "/var/lib/flatpak/exports/bin/org.chromium.Chromium", + ]) { + expect(candidates).toContain(executablePath); + } + }); + + it("ranks PATH-resolved Ungoogled Chromium below stock builds", () => { + const ungoogledPath = "/custom/bin/ungoogled-chromium"; + const candidates = linuxCandidates(name => (name === "ungoogled-chromium" ? ungoogledPath : undefined)); + const ungoogled = candidates.indexOf(ungoogledPath); + + for (const executablePath of [ + "/usr/bin/google-chrome-stable", + "/usr/bin/chromium", + "/snap/bin/chromium", + "/var/lib/flatpak/exports/bin/org.chromium.Chromium", + ]) { + expect(ungoogled).toBeGreaterThan(candidates.indexOf(executablePath)); + } + }); + + it("does not add Ungoogled Chromium candidates on macOS or Windows", () => { + for (const platform of ["darwin", "win32"] as const) { + const candidates = systemChromiumCandidatesForTest(platform, "/home/test", () => "/custom/ungoogled"); + expect(candidates.some(candidate => candidate.toLowerCase().includes("ungoogled"))).toBeFalse(); + } + }); +}); + +describe("browser executable selection", () => { + it("honors PUPPETEER_EXECUTABLE_PATH before a detected Windows system Chrome", async () => { + const tempDir = TempDir.createSync("@browser-executable-"); + try { + const override = path.join(tempDir.path(), "chrome-headless-shell.exe"); + const systemChrome = path.join(tempDir.path(), "Google\\Chrome\\Application\\chrome.exe"); + await Bun.write(override, "override"); + await Bun.write(systemChrome, "system"); + + const result = Bun.spawnSync([process.execPath, EXECUTABLE_PROBE], { + env: { + ...process.env, + OMP_BROWSER_PROBE_PLATFORM: "win32", + ProgramFiles: tempDir.path(), + "ProgramFiles(x86)": path.join(tempDir.path(), "missing-x86"), + LOCALAPPDATA: path.join(tempDir.path(), "missing-local"), + PUPPETEER_EXECUTABLE_PATH: override, + }, + stdout: "pipe", + stderr: "pipe", + }); + const stderr = new TextDecoder().decode(result.stderr); + + expect(result.exitCode, stderr).toBe(0); + expect(new TextDecoder().decode(result.stdout)).toBe(override); + } finally { + await tempDir.remove(); + } + }); +}); diff --git a/packages/coding-agent/test/tools/glob-validate-paths.test.ts b/packages/coding-agent/test/tools/glob-validate-paths.test.ts index 91187f689..567eec7ac 100644 --- a/packages/coding-agent/test/tools/glob-validate-paths.test.ts +++ b/packages/coding-agent/test/tools/glob-validate-paths.test.ts @@ -106,6 +106,24 @@ describe("delimited path expansion", () => { ).toEqual(["apps/**/*.txt", "packages/**/*.txt"]); }); + it("splits a semicolon list whose joined string exceeds NAME_MAX (issue #7597)", async () => { + // Bare filenames in one directory form a single slash-free run once joined, + // so ~12 short entries already push the run past NAME_MAX (255). lstat on + // the joined string then throws ENAMETOOLONG, which used to be read as an + // inconclusive probe and suppress the split, collapsing the whole list to + // one non-existent literal path. + const names: string[] = []; + for (let i = 0; i < 20; i++) { + const name = `enametoolong-probe-${String(i).padStart(2, "0")}.txt`; + await Bun.write(path.join(tempDir, name), "needle\n"); + names.push(name); + } + const joined = names.join("; "); + expect(joined.length).toBeGreaterThan(255); + expect(await splitDelimitedPathEntry(joined, tempDir)).toEqual(names); + expect(await expandDelimitedPathEntries([joined], tempDir)).toEqual(names); + }); + it("normalizes Windows path separators before parsing find globs", async () => { expect(parseFindPattern("apps\\**\\*.txt")).toEqual({ basePath: "apps", diff --git a/packages/coding-agent/test/tools/grep-path-lists.test.ts b/packages/coding-agent/test/tools/grep-path-lists.test.ts index 17ab6597f..adbe10449 100644 --- a/packages/coding-agent/test/tools/grep-path-lists.test.ts +++ b/packages/coding-agent/test/tools/grep-path-lists.test.ts @@ -93,6 +93,7 @@ async function makeJsonlSessionFile(dirPath: string, entries: object[]): Promise function makeSubagentRegistry(sessions: ObservableSession[]): SessionObserverRegistry { return { getSessions: () => sessions, + getSession: (id: string) => sessions.find(session => session.id === id), onChange: () => () => {}, setMainSession: () => {}, getActiveSubagentCount: () => sessions.filter(session => session.status === "active").length, diff --git a/packages/coding-agent/test/tools/lsp-diagnostics-freshness.test.ts b/packages/coding-agent/test/tools/lsp-diagnostics-freshness.test.ts index 13ed0a57c..4f967c1ad 100644 --- a/packages/coding-agent/test/tools/lsp-diagnostics-freshness.test.ts +++ b/packages/coding-agent/test/tools/lsp-diagnostics-freshness.test.ts @@ -5,7 +5,7 @@ import { createLspWritethrough, type FileDiagnosticsResult, FileFormatResult } f import * as lspClient from "@oh-my-pi/pi-coding-agent/lsp/client"; import * as lspConfig from "@oh-my-pi/pi-coding-agent/lsp/config"; import type { Diagnostic, LinterClient, LspClient, ServerConfig } from "@oh-my-pi/pi-coding-agent/lsp/types"; -import { fileToUri } from "@oh-my-pi/pi-coding-agent/lsp/utils"; +import { EquivalentUriMap, fileToUri } from "@oh-my-pi/pi-coding-agent/lsp/utils"; import type { DeferredDiagnosticsEntry, ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { WriteTool } from "@oh-my-pi/pi-coding-agent/tools/write"; import { type ptree, TempDir } from "@oh-my-pi/pi-utils"; @@ -47,7 +47,7 @@ function createClient(cwd: string, config: ServerConfig): LspClient { config, proc: {} as ptree.ChildProcess<"pipe">, requestId: 0, - diagnostics: new Map(), + diagnostics: new EquivalentUriMap(), diagnosticsVersion: 0, openFiles: new Map(), pendingRequests: new Map(), @@ -438,6 +438,51 @@ describe("LSP diagnostics freshness", () => { expect(result?.messages.some(m => m.includes("stale error"))).toBe(false); }); + it("matches published diagnostics when the server renormalizes the document URI", async () => { + const filePath = path.join(tempDir.path(), "renormalized.ts"); + const uri = fileToUri(filePath); + const serverUri = uri.replace("/renormalized.ts", "/%72enormalized.ts"); + const client = createClient(tempDir.path(), TEST_SERVER); + const clock = new VirtualClock(Date.now()); + installVirtualTime(clock); + + vi.spyOn(lspConfig, "loadConfig").mockReturnValue({ servers: {}, idleTimeoutMs: undefined }); + vi.spyOn(lspConfig, "getServersForFile").mockReturnValue([["test-lsp", TEST_SERVER]]); + vi.spyOn(lspClient, "getOrCreateClient").mockResolvedValue(client); + vi.spyOn(lspClient, "syncContent").mockImplementation(async (mockClient, syncedFilePath) => { + const syncedUri = fileToUri(syncedFilePath); + mockClient.openFiles.set(syncedUri, { version: 1, languageId: "typescript" }); + }); + vi.spyOn(lspClient, "notifySaved").mockImplementation(async mockClient => { + clock.in(10, () => { + publishDiagnostics(mockClient, serverUri, [createDiagnostic("renormalized URI error")], 1); + }); + }); + + const writethrough = createLspWritethrough(tempDir.path(), { + enableFormat: false, + enableDiagnostics: true, + }); + const result = await writethrough(filePath, "export const value = missing;\n"); + + expect(result?.errored).toBe(true); + expect(result?.messages.some(message => message.includes("renormalized URI error"))).toBe(true); + }); + + it("matches Windows drive-letter case and percent-encoding differences", () => { + const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + if (!platformDescriptor) throw new Error("process.platform descriptor is unavailable"); + Object.defineProperty(process, "platform", { ...platformDescriptor, value: "win32" }); + try { + const diagnostics = new EquivalentUriMap(); + diagnostics.set("file:///c%3A/Users/serge/doc.md", "published"); + + expect(diagnostics.get("file:///C:/Users/serge/doc.md")).toBe("published"); + } finally { + Object.defineProperty(process, "platform", platformDescriptor); + } + }); + it("returns completed pull diagnostics inside the inline write window", async () => { const filePath = path.join(tempDir.path(), "pull-only.ts"); const uri = fileToUri(filePath); diff --git a/packages/coding-agent/test/tools/path-literal-colon-selector.test.ts b/packages/coding-agent/test/tools/path-literal-colon-selector.test.ts index e79abfbde..6e82c64fd 100644 --- a/packages/coding-agent/test/tools/path-literal-colon-selector.test.ts +++ b/packages/coding-agent/test/tools/path-literal-colon-selector.test.ts @@ -121,6 +121,13 @@ describe("literal colon filename resolution (issue #4618)", () => { await fs.symlink(path.join(tmpDir, "nowhere"), literal); expect(await probeLiteralPathExists(literal, tmpDir)).toBe("exists"); }); + + it('returns "missing" for an ENAMETOOLONG path (issue #7597)', async () => { + // A single component past NAME_MAX can never name a real entry, so the + // probe must report "missing" (not "unknown") to let delimited splits run. + const overlong = path.join(tmpDir, "x".repeat(300)); + expect(await probeLiteralPathExists(overlong, tmpDir)).toBe("missing"); + }); }); describe("read tool", () => { diff --git a/packages/coding-agent/test/tools/read-artifact-large.test.ts b/packages/coding-agent/test/tools/read-artifact-large.test.ts index 46831a30f..389bebc2b 100644 --- a/packages/coding-agent/test/tools/read-artifact-large.test.ts +++ b/packages/coding-agent/test/tools/read-artifact-large.test.ts @@ -104,6 +104,12 @@ describe("read tool large artifact handling", () => { expect(output).not.toContain("line-032"); }); + it("records the source line count for an open-ended artifact range that reaches EOF", async () => { + const result = await tool.execute("call-raw-tail", { path: "artifact://0:raw:301-" }); + + expect(result.details?.totalLines).toBe(400); + }); + it("shortens artifact paths under the user's home dir instead of leaking the absolute path", async () => { const homeSpy = spyOn(os, "homedir").mockReturnValue(testDir); try { diff --git a/packages/coding-agent/test/tools/read-raw-range.test.ts b/packages/coding-agent/test/tools/read-raw-range.test.ts index 75d5c3cc9..10f4b292b 100644 --- a/packages/coding-agent/test/tools/read-raw-range.test.ts +++ b/packages/coding-agent/test/tools/read-raw-range.test.ts @@ -59,6 +59,12 @@ describe("read tool raw range exactness", () => { expect(output.trimEnd()).toBe("L01\nL02"); }); + it("records the source line count for an open-ended range that reaches EOF", async () => { + const result = await tool.execute("call-raw-tail", { path: `${filePath}:raw:31-` }); + + expect(result.details?.totalLines).toBe(60); + }); + it("keeps context padding for numbered range reads", async () => { // Numbered mode intentionally pads (leading anchor buffer + trailing // disambiguation lines) — line numbers make the padding self-describing. diff --git a/packages/coding-agent/test/turn-recovery-replay-unsafe.test.ts b/packages/coding-agent/test/turn-recovery-replay-unsafe.test.ts index b9494bab4..2d5f91eee 100644 --- a/packages/coding-agent/test/turn-recovery-replay-unsafe.test.ts +++ b/packages/coding-agent/test/turn-recovery-replay-unsafe.test.ts @@ -40,9 +40,12 @@ function makeMessage(content: AssistantMessage["content"], model: Model): Assist function createHost( model: Model, modelRegistry: ModelRegistry, - fallbackChains?: Record, + options: { + fallbackChains?: Record; + textOutputCommitted?: boolean; + } = {}, ): TurnRecoveryHost { - const settings = Settings.isolated(fallbackChains ? { "retry.fallbackChains": fallbackChains } : {}); + const settings = Settings.isolated(options.fallbackChains ? { "retry.fallbackChains": options.fallbackChains } : {}); return { agent: undefined as never, sessionManager: undefined as never, @@ -51,6 +54,7 @@ function createHost( modelRegistry, configWarnings: [], model: () => model, + textOutputCommitted: () => options.textOutputCommitted !== false, thinkingLevel: () => undefined, configuredThinkingLevel: () => undefined, setThinkingLevel: () => {}, @@ -95,18 +99,160 @@ describe("TurnRecovery replay-unsafe output classification", () => { tempDir.removeSync(); }); + it("rolls back a usage fallback cancelled during model reconciliation", async () => { + const fallback = getBundledModel("openai", "gpt-4o-mini"); + if (!fallback) throw new Error("Expected bundled fallback model"); + let activeModel = model; + const fallbackApplied = Promise.withResolvers(); + const releaseReconciliation = Promise.withResolvers(); + const modelChanges: string[] = []; + const emittedEvents: string[] = []; + const host = createHost(model, modelRegistry); + host.model = () => activeModel; + host.sessionManager = { + appendModelChange: (selector: string) => modelChanges.push(selector), + } as never; + host.setModelWithProviderSessionReset = async nextModel => { + activeModel = nextModel; + if (nextModel.provider === fallback.provider && nextModel.id === fallback.id) { + fallbackApplied.resolve(); + await releaseReconciliation.promise; + } + }; + host.emitSessionEvent = async event => { + emittedEvents.push(event.type); + }; + const recovery = new TurnRecovery(host); + const controller = new AbortController(); + const applying = recovery.applyRetryFallbackCandidate( + "default", + { + raw: `${fallback.provider}/${fallback.id}`, + provider: fallback.provider, + id: fallback.id, + thinkingLevel: undefined, + }, + `${model.provider}/${model.id}`, + { pinFallback: true, apiKey: "test-key", signal: controller.signal }, + ); + + await fallbackApplied.promise; + controller.abort(); + releaseReconciliation.resolve(); + const committed = await applying; + + expect(committed).toBe(false); + expect(activeModel).toBe(model); + expect(modelChanges).toEqual([]); + expect(emittedEvents).toEqual([]); + }); + + it("does not commit a fallback superseded during model reconciliation", async () => { + const fallback = getBundledModel("openai", "gpt-4o-mini"); + if (!fallback) throw new Error("Expected bundled fallback race model"); + const selectedModel = { ...fallback, baseUrl: "https://user-selected-route.example" }; + let activeModel = model; + const fallbackApplied = Promise.withResolvers(); + const releaseReconciliation = Promise.withResolvers(); + const modelChanges: string[] = []; + const emittedEvents: string[] = []; + const thinkingChanges: unknown[] = []; + const host = createHost(model, modelRegistry); + host.model = () => activeModel; + host.sessionManager = { + appendModelChange: (selector: string) => modelChanges.push(selector), + } as never; + host.setThinkingLevel = level => thinkingChanges.push(level); + host.setModelWithProviderSessionReset = async nextModel => { + activeModel = nextModel; + if (nextModel.provider === fallback.provider && nextModel.id === fallback.id) { + fallbackApplied.resolve(); + await releaseReconciliation.promise; + } + }; + host.emitSessionEvent = async event => { + emittedEvents.push(event.type); + }; + const recovery = new TurnRecovery(host); + const applying = recovery.applyRetryFallbackCandidate( + "default", + { + raw: `${fallback.provider}/${fallback.id}`, + provider: fallback.provider, + id: fallback.id, + thinkingLevel: undefined, + }, + `${model.provider}/${model.id}`, + { pinFallback: true, apiKey: "test-key" }, + ); + + await fallbackApplied.promise; + activeModel = selectedModel; + releaseReconciliation.resolve(); + const committed = await applying; + + expect(committed).toBe(false); + expect(activeModel).toBe(selectedModel); + expect(modelChanges).toEqual([]); + expect(thinkingChanges).toEqual([]); + expect(emittedEvents).toEqual([]); + }); + it("keeps a committed fallback when cancellation arrives during applied-event delivery", async () => { + const fallback = getBundledModel("openai", "gpt-4o-mini"); + if (!fallback) throw new Error("Expected bundled fallback model"); + let activeModel = model; + const eventStarted = Promise.withResolvers(); + const releaseEvent = Promise.withResolvers(); + const modelChanges: string[] = []; + const host = createHost(model, modelRegistry); + host.model = () => activeModel; + host.sessionManager = { + appendModelChange: (selector: string) => modelChanges.push(selector), + } as never; + host.setModelWithProviderSessionReset = async nextModel => { + activeModel = nextModel; + }; + host.emitSessionEvent = async event => { + if (event.type !== "retry_fallback_applied") return; + eventStarted.resolve(); + await releaseEvent.promise; + }; + const recovery = new TurnRecovery(host); + const controller = new AbortController(); + const applying = recovery.applyRetryFallbackCandidate( + "default", + { + raw: `${fallback.provider}/${fallback.id}`, + provider: fallback.provider, + id: fallback.id, + thinkingLevel: undefined, + }, + `${model.provider}/${model.id}`, + { pinFallback: true, apiKey: "test-key", signal: controller.signal }, + ); + + await eventStarted.promise; + controller.abort(); + releaseEvent.resolve(); + const committed = await applying; + + expect(committed).toBe(true); + expect(activeModel.provider).toBe(fallback.provider); + expect(activeModel.id).toBe(fallback.id); + expect(modelChanges).toEqual([`${fallback.provider}/${fallback.id}`]); + }); + it("treats a failed turn with partial non-whitespace text as NOT retriable", () => { const recovery = new TurnRecovery(createHost(model, modelRegistry)); const message = makeMessage([{ type: "text", text: "Here is the first part of my answer" }], model); expect(recovery.isRetryableError(message)).toBe(false); }); - it("allows replay-safe hard fallback and excludes visible text with a configured chain", () => { - const recovery = new TurnRecovery( - createHost(model, modelRegistry, { - [`${model.provider}/${model.id}`]: ["openai/gpt-4o-mini"], - }), - ); + it("allows replay-safe hard fallback and excludes committed text with a configured chain", () => { + const fallbackChains = { + [`${model.provider}/${model.id}`]: ["openai/gpt-4o-mini"], + }; + const recovery = new TurnRecovery(createHost(model, modelRegistry, { fallbackChains })); // Thinking-only output is replay-safe: nothing visible reached the user. const message = makeMessage([{ type: "thinking", thinking: "safe reasoning before failing" }], model); const visible = makeMessage([{ type: "text", text: "Already shown" }], model); @@ -114,6 +260,18 @@ describe("TurnRecovery replay-unsafe output classification", () => { expect(recovery.isHardErrorFallbackEligible(message)).toBe(true); }); + it("retries partial text while its buffered output remains uncommitted", () => { + const fallbackChains = { + [`${model.provider}/${model.id}`]: ["openai/gpt-4o-mini"], + }; + const recovery = new TurnRecovery( + createHost(model, modelRegistry, { fallbackChains, textOutputCommitted: false }), + ); + const message = makeMessage([{ type: "text", text: "Buffered partial answer" }], model); + expect(recovery.isRetryableError(message)).toBe(true); + expect(recovery.isHardErrorFallbackEligible(message)).toBe(true); + }); + it("excludes a Fireworks Fast failed turn with partial visible text from Fast→base fallback", () => { const fastModel = getBundledModel("fireworks", "kimi-k2.6-fast"); if (!fastModel) throw new Error("Expected bundled model kimi-k2.6-fast"); @@ -154,6 +312,19 @@ describe("TurnRecovery replay-unsafe output classification", () => { expect(recovery.isHardErrorFallbackEligible(message)).toBe(false); }); + it("keeps side-effecting output replay-unsafe while text is uncommitted", () => { + const recovery = new TurnRecovery(createHost(model, modelRegistry, { textOutputCommitted: false })); + const message = makeMessage( + [ + { type: "text", text: "Buffered partial answer" }, + { type: "toolCall", id: "call-1", name: "bash", arguments: { command: "ls" } }, + ], + model, + ); + expect(recovery.isRetryableError(message)).toBe(false); + expect(recovery.isHardErrorFallbackEligible(message)).toBe(false); + }); + it("keeps an empty-content error retriable (baseline)", () => { const recovery = new TurnRecovery(createHost(model, modelRegistry)); const message = makeMessage([], model); diff --git a/packages/coding-agent/test/unexpected-stop-classifier.test.ts b/packages/coding-agent/test/unexpected-stop-classifier.test.ts index 26c988f71..8024fd30c 100644 --- a/packages/coding-agent/test/unexpected-stop-classifier.test.ts +++ b/packages/coding-agent/test/unexpected-stop-classifier.test.ts @@ -76,6 +76,30 @@ describe("isUnexpectedStopCandidate", () => { }); expect(isUnexpectedStopCandidate(message)).toBe(false); }); + + it("returns true for a signed thinking-only stop", () => { + const message = makeAssistantMessage({ + stopReason: "stop", + content: [{ type: "thinking", thinking: " 响应", thinkingSignature: "reasoning_content" }], + }); + expect(isUnexpectedStopCandidate(message)).toBe(true); + }); + + it("returns false for an unsigned thinking-only stop (empty-stop path owns it)", () => { + const message = makeAssistantMessage({ + stopReason: "stop", + content: [{ type: "thinking", thinking: "responseAll four reviewers complete." }], + }); + expect(isUnexpectedStopCandidate(message)).toBe(false); + }); + + it("returns false when the thinking block is only whitespace", () => { + const message = makeAssistantMessage({ + stopReason: "stop", + content: [{ type: "thinking", thinking: " \n\t ", thinkingSignature: "reasoning_content" }], + }); + expect(isUnexpectedStopCandidate(message)).toBe(false); + }); }); describe("classifyUnexpectedStop", () => { diff --git a/packages/coding-agent/test/update-cli.test.ts b/packages/coding-agent/test/update-cli.test.ts index 4bf380ce0..4d731d4cc 100644 --- a/packages/coding-agent/test/update-cli.test.ts +++ b/packages/coding-agent/test/update-cli.test.ts @@ -380,6 +380,30 @@ describe("update-cli bun cache pruning", () => { expect(await Bun.file(path.join(dir, "pkg", "1.0.0@@@1")).exists()).toBe(true); expect(await Bun.file(path.join(dir, "pkg@1.0.0@@@1", "package.json")).exists()).toBe(true); }); + + it("compares numeric version segments without precision loss", async () => { + const dir = await makeTempDir(); + const older = "1.0.99999999999999999999"; + const newer = "1.0.100000000000000000000"; + await Bun.write(path.join(dir, "pkg", `${older}@@@1`), ""); + await Bun.write(path.join(dir, "pkg", `${newer}@@@1`), ""); + await Bun.write( + path.join(dir, `pkg@${older}@@@1`, "package.json"), + JSON.stringify({ name: "pkg", version: older }), + ); + await Bun.write( + path.join(dir, `pkg@${newer}@@@1`, "package.json"), + JSON.stringify({ name: "pkg", version: newer }), + ); + + const result = await pruneBunInstallCache(dir, new Set(["pkg"])); + + expect(result).toEqual({ scannedPackages: 1, removedEntries: 2 }); + expect(await Bun.file(path.join(dir, "pkg", `${older}@@@1`)).exists()).toBe(false); + expect(await Bun.file(path.join(dir, `pkg@${older}@@@1`, "package.json")).exists()).toBe(false); + expect(await Bun.file(path.join(dir, "pkg", `${newer}@@@1`)).exists()).toBe(true); + expect(await Bun.file(path.join(dir, `pkg@${newer}@@@1`, "package.json")).exists()).toBe(true); + }); }); describe("update-cli release binary integrity", () => { diff --git a/packages/coding-agent/test/vibe/vibe-runtime.test.ts b/packages/coding-agent/test/vibe/vibe-runtime.test.ts deleted file mode 100644 index 8e442a73f..000000000 --- a/packages/coding-agent/test/vibe/vibe-runtime.test.ts +++ /dev/null @@ -1,2206 +0,0 @@ -/** - * Contracts: vibe worker-session registry lifecycle. - * - * 1. `spawn` returns immediately (session id + turn job id) while the turn - * runs in the background; the settled turn self-delivers a result carrying - * the activity trace AND the worker's response, and the session stays - * addressable (idle) afterwards. - * 2. `send` routes by state: steering into a streaming mid-turn worker, - * queueing when the worker is mid-turn but not steerable (drained into the - * next turn automatically), and starting a follow-up turn on the SAME - * worker id when idle. - * 3. `runSubagentFollowUpTurn` continues a live session in place: consecutive - * turns hit the same AgentSession instance (context retained) and the - * finalized result carries the yield payload + tool trace. - * 4. `wait` wakes on the FIRST settling turn among concurrent sessions and - * acknowledges its delivery so the result is not delivered twice. - * 5. `kill` cancels the in-flight turn job and releases the worker session. - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; -import * as fs from "node:fs/promises"; -import * as os from "node:os"; -import * as path from "node:path"; -import { AsyncJobManager } from "@oh-my-pi/pi-coding-agent/async/job-manager"; -import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { AgentLifecycleManager } from "@oh-my-pi/pi-coding-agent/registry/agent-lifecycle"; -import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; -import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; -import { - SessionManager, - SessionPersistenceIndeterminateError, -} from "@oh-my-pi/pi-coding-agent/session/session-manager"; -import { - FileSessionStorage, - type SessionStorage, - type SessionStorageWriter, - type WriteTextAtomicOptions, -} from "@oh-my-pi/pi-coding-agent/session/session-storage"; -import * as executorModule from "@oh-my-pi/pi-coding-agent/task/executor"; -import type { AgentProgress, SingleResult } from "@oh-my-pi/pi-coding-agent/task/types"; -import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; -import { VibeSessionRegistry } from "@oh-my-pi/pi-coding-agent/vibe/runtime"; - -const PERSISTED_WORKER_SYSTEM_PROMPT = "Persisted vibe worker"; -const PERSISTED_WORKER_TOOLS = ["read", "yield"]; -const INITIAL_VIBE_TASK = "Complete the first persisted turn."; -const FOLLOW_UP_VIBE_TASK = "Continue from the persisted conversation."; -const RESTORED_VIBE_RESPONSE = "Continued from persisted context."; - -async function fileExists(file: string): Promise { - try { - return (await fs.stat(file)).isFile(); - } catch { - return false; - } -} - -type AtomicWriteHook = (commit: () => Promise) => Promise; - -class FaultInjectingSessionStorage extends FileSessionStorage { - readonly atomicWriteHooks: AtomicWriteHook[] = []; - atomicWriteAttempts = 0; - failedWriterClosed = false; - #appendFault: { error: Error; prefixBytes: number } | undefined; - - failNextAppendWithPrefix(error: Error, prefixBytes: number): void { - this.#appendFault = { error, prefixBytes }; - } - - override async writeTextAtomic(filePath: string, content: string, options?: WriteTextAtomicOptions): Promise { - this.atomicWriteAttempts++; - const hook = this.atomicWriteHooks.shift(); - const commit = () => super.writeTextAtomic(filePath, content, options); - if (hook) await hook(commit); - else await commit(); - } - - override openWriter( - filePath: string, - options?: { flags?: "a" | "w"; onError?: (error: Error) => void }, - ): SessionStorageWriter { - const inner = super.openWriter(filePath, options); - let injectedError: Error | undefined; - let pendingFault: Promise = Promise.resolve(); - let faulted = false; - return { - append: line => { - const fault = this.#appendFault; - if (!fault) return inner.append(line); - this.#appendFault = undefined; - faulted = true; - const prefix = Buffer.from(line).subarray(0, fault.prefixBytes); - const operation = fs.appendFile(filePath, prefix).then(() => { - injectedError = fault.error; - options?.onError?.(fault.error); - throw fault.error; - }); - pendingFault = operation.catch(() => {}); - return operation; - }, - flush: async () => { - await pendingFault; - if (injectedError) throw injectedError; - await inner.flush(); - }, - isOpen: () => inner.isOpen(), - close: async () => { - await pendingFault; - await inner.close(); - if (faulted) this.failedWriterClosed = true; - }, - getError: () => injectedError ?? inner.getError(), - }; - } -} - -class SwitchGatedSessionStorage extends FaultInjectingSessionStorage { - #readGate: - | { - filePath: string; - started: ReturnType>; - release: ReturnType>; - } - | undefined; - - gateNextRead(filePath: string): { started: Promise; release: () => void } { - const started = Promise.withResolvers(); - const release = Promise.withResolvers(); - this.#readGate = { filePath, started, release }; - return { started: started.promise, release: release.resolve }; - } - - override async readTextSlices( - filePath: string, - prefixBytes: number, - suffixBytes: number, - ): Promise<[string, string]> { - const gate = this.#readGate; - if (gate?.filePath === filePath) { - this.#readGate = undefined; - gate.started.resolve(); - await gate.release.promise; - } - return super.readTextSlices(filePath, prefixBytes, suffixBytes); - } -} - -interface TestSessionOptions { - manager?: AsyncJobManager; - sessionManager?: SessionManager; - ownerId?: string; - parentSessionId?: string; -} - -function createSession(options: TestSessionOptions = {}): ToolSession { - const sessionManager = options.sessionManager; - return { - cwd: sessionManager?.getCwd() ?? "/tmp", - hasUI: false, - settings: Settings.isolated({}), - getSessionFile: () => sessionManager?.getSessionFile() ?? null, - getSessionId: () => options.parentSessionId ?? sessionManager?.getSessionId() ?? "vibe-test-parent", - getAgentId: () => options.ownerId ?? "Main", - getArtifactsDir: () => sessionManager?.getArtifactsDir() ?? null, - getSessionSpawns: () => "*", - sessionManager, - asyncJobManager: options.manager, - }; -} - -interface PersistWorkerOptions { - cwd: string; - artifactsDir: string; - id: string; - task: string; -} - -async function persistWorkerSession(options: PersistWorkerOptions): Promise { - const childSessionFile = path.join(options.artifactsDir, `${options.id}.jsonl`); - const childManager = SessionManager.create(options.cwd, options.artifactsDir); - await childManager.setSessionFile(childSessionFile); - childManager.appendSessionInit({ - systemPrompt: PERSISTED_WORKER_SYSTEM_PROMPT, - task: options.task, - tools: [...PERSISTED_WORKER_TOOLS], - spawns: "", - }); - await childManager.flush(); - await childManager.close(); - return childSessionFile; -} - -function makeResult(id: string, overrides: Partial = {}): SingleResult { - return { - index: 0, - id, - agent: "task", - agentSource: "bundled", - task: "prompt", - exitCode: 0, - output: "All done.", - stderr: "", - truncated: false, - durationMs: 5, - tokens: 0, - requests: 1, - ...overrides, - }; -} - -interface Deferred { - promise: Promise; - resolve: () => void; -} - -function deferred(): Deferred { - const { promise, resolve } = Promise.withResolvers(); - return { promise, resolve }; -} - -async function flushMicrotasks(): Promise { - for (let index = 0; index < 5; index++) await Promise.resolve(); -} - -async function pollUntil(predicate: () => boolean, timeoutMs = 2000): Promise { - const start = Date.now(); - while (!predicate()) { - if (Date.now() - start > timeoutMs) throw new Error("pollUntil timed out"); - await Bun.sleep(5); - } -} - -/** - * Minimal stand-in for a worker AgentSession: records prompts/steers, replays - * a scripted event stream through subscribed listeners on each prompt, and - * reports a final assistant message — enough surface for the executor's run - * monitor + driveSessionToYield. - */ -function createFakeWorkerSession(options: { streaming?: boolean; onDispose?: () => void | Promise } = {}) { - const listeners = new Set<(event: unknown) => void>(); - const prompts: string[] = []; - const steers: string[] = []; - let disposed = false; - let lastAssistant: { stopReason: string; content: Array<{ type: string; text: string }> } | undefined; - let script: { events: unknown[]; responseText: string } | undefined; - const fake = { - isStreaming: options.streaming ?? false, - model: undefined, - subscribe(listener: (event: unknown) => void): () => void { - listeners.add(listener); - return () => listeners.delete(listener); - }, - async prompt(text: string): Promise { - prompts.push(text); - const active = script; - script = undefined; - if (active) { - for (const event of active.events) { - for (const listener of [...listeners]) listener(event); - } - lastAssistant = { stopReason: "stop", content: [{ type: "text", text: active.responseText }] }; - const end = { type: "message_end", message: { role: "assistant", content: lastAssistant.content } }; - for (const listener of [...listeners]) listener(end); - } - return true; - }, - async steer(text: string): Promise { - steers.push(text); - }, - async waitForIdle(): Promise {}, - getLastAssistantMessage() { - return lastAssistant; - }, - async abort(): Promise {}, - async dispose(): Promise { - disposed = true; - await options.onDispose?.(); - }, - }; - return { - session: fake as unknown as AgentSession, - prompts, - steers, - isDisposed: () => disposed, - setStreaming(value: boolean) { - fake.isStreaming = value; - }, - setScript(next: { events: unknown[]; responseText: string }) { - script = next; - }, - }; -} - -/** Scripted turn: one `read` tool call, then a successful `yield` carrying `data`. */ -function yieldTurnEvents(data: unknown): unknown[] { - return [ - { type: "tool_execution_start", toolName: "read", args: { path: "src/foo.ts" }, intent: "Reading foo" }, - { type: "tool_execution_end", toolName: "read", result: {}, isError: false }, - { type: "tool_execution_start", toolName: "yield", args: {} }, - { - type: "tool_execution_end", - toolName: "yield", - result: { details: { status: "success", data } }, - isError: false, - }, - ]; -} - -/** Progress snapshot in the shape the executor's run monitor emits. */ -function progressSnapshot(id: string, overrides: Partial = {}): AgentProgress { - return { - index: 0, - id, - agent: "task", - agentSource: "bundled", - status: "running", - task: "prompt", - recentTools: [], - recentOutput: [], - toolCount: 0, - requests: 0, - tokens: 0, - cost: 0, - durationMs: 0, - ...overrides, - }; -} - -describe("vibe session registry", () => { - const managers: AsyncJobManager[] = []; - const persistedManagers: SessionManager[] = []; - const tempRoots: string[] = []; - - async function createPersistedParent(storage?: SessionStorage): Promise { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "omp-vibe-resume-")); - tempRoots.push(root); - const cwd = path.join(root, "workspace"); - await fs.mkdir(cwd, { recursive: true }); - const manager = SessionManager.create(cwd, path.join(root, "sessions"), storage); - persistedManagers.push(manager); - return manager; - } - - function createManager(): AsyncJobManager { - const manager = new AsyncJobManager({ onJobComplete: () => {} }); - managers.push(manager); - return manager; - } - - function installPersistedSpawnMock(): void { - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - const artifactsDir = options.artifactsDir; - if (!artifactsDir) throw new Error("Persisted vibe test requires an artifacts directory"); - const parentSessionFile = options.sessionFile; - if (!parentSessionFile) throw new Error("Persisted vibe test requires a parent session file"); - const snapshot = await SessionManager.open(parentSessionFile, undefined, undefined, { - suppressBreadcrumb: true, - }); - try { - const actions = snapshot.getEntries().flatMap(entry => { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) return []; - const data = entry.data as Record; - return data.id === options.id && typeof data.action === "string" ? [data.action] : []; - }); - expect(actions).toEqual(["spawn", "turn-started"]); - } finally { - await snapshot.close(); - } - const childSessionFile = await persistWorkerSession({ - cwd: options.cwd, - artifactsDir, - id: options.id, - task: options.task, - }); - const worker = createFakeWorkerSession(); - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: options.parentAgentId ?? "Main", - session: worker.session, - sessionFile: childSessionFile, - status: "running", - }); - AgentRegistry.global().setStatus(options.id, "idle"); - AgentLifecycleManager.global().adopt(options.id, { - idleTtlMs: 0, - revive: async () => worker.session, - }); - return makeResult(options.id, { output: "Persisted first turn." }); - }); - } - - function installPersistedReviver(capture: { sessionFile?: string; prompts?: string[] }): void { - AgentLifecycleManager.global().setPersistedSubagentReviverFactory(async ref => { - if (!ref.sessionFile) return undefined; - const persisted = await SessionManager.peekSessionInit(ref.sessionFile); - if (!persisted?.init) return undefined; - const worker = createFakeWorkerSession(); - worker.prompts.push(persisted.init.task); - worker.setScript({ - events: yieldTurnEvents({ report: RESTORED_VIBE_RESPONSE }), - responseText: RESTORED_VIBE_RESPONSE, - }); - capture.sessionFile = ref.sessionFile; - capture.prompts = worker.prompts; - return async () => worker.session; - }, 0); - } - - async function simulateProcessBoundary(): Promise { - await AgentLifecycleManager.global().dispose(); - VibeSessionRegistry.resetGlobalForTests(); - AgentLifecycleManager.resetGlobalForTests(); - AgentRegistry.resetGlobalForTests(); - } - - beforeEach(() => { - AgentRegistry.resetGlobalForTests(); - AgentLifecycleManager.resetGlobalForTests(); - VibeSessionRegistry.resetGlobalForTests(); - }); - - afterEach(async () => { - vi.restoreAllMocks(); - for (const manager of managers.splice(0)) { - await manager.dispose({ timeoutMs: 1000 }); - } - for (const manager of persistedManagers.splice(0)) { - await manager.close(); - } - for (const root of tempRoots.splice(0)) { - await fs.rm(root, { recursive: true, force: true }); - } - VibeSessionRegistry.resetGlobalForTests(); - AgentLifecycleManager.resetGlobalForTests(); - AgentRegistry.resetGlobalForTests(); - }); - - it("spawn returns immediately and self-delivers a turn result with activity trace + response", async () => { - const gate = deferred(); - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: "Main", - session: createFakeWorkerSession().session, - status: "running", - }); - options.onProgress?.( - progressSnapshot(options.id, { - toolCount: 2, - recentTools: [ - { tool: "bash", args: "bun test", endMs: 2 }, - { tool: "read", args: "src/foo.ts", endMs: 1 }, - ], - lastIntent: "Running tests", - resolvedModel: "prov/fast-model", - }), - ); - await gate.promise; - AgentRegistry.global().setStatus(options.id, "idle"); - return makeResult(options.id, { output: "Implemented the widget.", requests: 3 }); - }); - - const manager = createManager(); - const session = createSession({ manager }); - const registry = VibeSessionRegistry.global(); - - const { id, jobId } = await registry.spawn(session, { cli: "fast", name: "Fast", prompt: "Build the widget." }); - expect(id).toBe("Fast"); - - // Ack is immediate: the job is still running behind the gate. - const job = manager.getJob(jobId)!; - expect(job.status).toBe("running"); - expect(registry.screens(session)[0]?.cli).toBe("fast"); - - gate.resolve(); - await job.promise; - - expect(job.status).toBe("completed"); - const text = job.resultText ?? ""; - // Envelope + summarized activity (compressed tool trace, oldest first) + response. - expect(text).toContain(' { - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const originalFlush = parentManager.flush.bind(parentManager); - const flush = vi - .spyOn(parentManager, "flush") - .mockImplementationOnce(originalFlush) - .mockRejectedValueOnce(new Error("tombstone write failed")); - const register = vi.spyOn(jobs, "register").mockImplementation(() => { - throw new Error("job registry failed"); - }); - - await expect( - VibeSessionRegistry.global().spawn(session, { - cli: "fast", - name: "failed-spawn", - prompt: INITIAL_VIBE_TASK, - }), - ).rejects.toThrow("tombstone write failed"); - - expect(VibeSessionRegistry.global().screens(session)[0]).toMatchObject({ - id: "failed-spawn", - state: "dead", - lastActivity: "spawn failed", - }); - expect(parentManager.buildSessionContext().mode).toBe("vibe"); - flush.mockRestore(); - register.mockRestore(); - expect(await VibeSessionRegistry.global().killAll(session)).toBe(1); - expect(VibeSessionRegistry.global().listIds(session)).toEqual([]); - expect(parentManager.buildSessionContext().mode).toBe("none"); - }); - - it("send steers a streaming mid-turn worker and queues for a non-steerable one", async () => { - const gate = deferred(); - const fake = createFakeWorkerSession({ streaming: true }); - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: "Main", - session: fake.session, - status: "running", - }); - await gate.promise; - AgentRegistry.global().setStatus(options.id, "idle"); - return makeResult(options.id); - }); - const followUps: Array<{ id: string; message: string }> = []; - vi.spyOn(executorModule, "runSubagentFollowUpTurn").mockImplementation(async options => { - followUps.push({ id: options.id, message: options.message }); - return makeResult(options.id, { output: "queued work done" }); - }); - - const manager = createManager(); - const session = createSession({ manager }); - const registry = VibeSessionRegistry.global(); - const { jobId } = await registry.spawn(session, { cli: "good", name: "Good", prompt: "Design it." }); - await pollUntil(() => AgentRegistry.global().get("Good") !== undefined); - - // Streaming worker → steering. - const steered = await registry.send(session, { session: "Good", message: "Focus on the API first." }); - expect(steered.mode).toBe("steered"); - expect(fake.steers).toEqual(["Focus on the API first."]); - - // Not streaming → queued for the next turn. - fake.setStreaming(false); - const queued = await registry.send(session, { session: "Good", message: "Then write tests." }); - expect(queued.mode).toBe("queued"); - expect(registry.screens(session)[0]?.queued).toBe(1); - - // Settling the turn drains the queue into an automatic follow-up turn. - gate.resolve(); - await manager.getJob(jobId)!.promise; - await pollUntil(() => followUps.length === 1); - expect(followUps[0]).toEqual({ id: "Good", message: "Then write tests." }); - }); - - it("send to an idle session starts a follow-up turn on the same worker", async () => { - const gate = deferred(); - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: "Main", - session: createFakeWorkerSession().session, - status: "running", - }); - await gate.promise; - AgentRegistry.global().setStatus(options.id, "idle"); - return makeResult(options.id); - }); - const followUps: Array<{ id: string; message: string }> = []; - vi.spyOn(executorModule, "runSubagentFollowUpTurn").mockImplementation(async options => { - followUps.push({ id: options.id, message: options.message }); - options.onProgress?.( - progressSnapshot(options.id, { - toolCount: 1, - recentTools: [{ tool: "edit", args: "src/foo.ts", endMs: 1 }], - }), - ); - return makeResult(options.id, { output: "Renamed everything." }); - }); - - const manager = createManager(); - const session = createSession({ manager }); - const registry = VibeSessionRegistry.global(); - const spawn = await registry.spawn(session, { cli: "fast", name: "Fast", prompt: "First task." }); - gate.resolve(); - await manager.getJob(spawn.jobId)!.promise; - - const outcome = await registry.send(session, { session: "Fast", message: "Now rename the helpers." }); - expect(outcome.mode).toBe("turn"); - const turnJob = manager.getJob(outcome.jobId!)!; - await turnJob.promise; - - expect(followUps).toEqual([{ id: "Fast", message: "Now rename the helpers." }]); - const text = turnJob.resultText ?? ""; - expect(text).toContain('turn="2"'); - expect(text).toContain("edit(src/foo.ts)"); - expect(text).toContain("Renamed everything."); - expect(registry.screens(session)[0]?.turns).toBe(2); - }); - - it("rehydrates an idle worker after a process boundary and continues turn two with prior context", async () => { - installPersistedSpawnMock(); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - expect(await fileExists(parentSessionFile)).toBe(false); - const firstManager = createManager(); - const firstSession = createSession({ manager: firstManager, sessionManager: parentManager }); - const firstRegistry = VibeSessionRegistry.global(); - const spawned = await firstRegistry.spawn(firstSession, { - cli: "fast", - name: "push-fixes", - prompt: INITIAL_VIBE_TASK, - }); - expect(await fileExists(parentSessionFile)).toBe(true); - await firstManager.getJob(spawned.jobId)!.promise; - expect(firstRegistry.screens(firstSession)[0]).toMatchObject({ id: "push-fixes", state: "idle", turns: 1 }); - - const wrongOwner = createSession({ manager: firstManager, sessionManager: parentManager, ownerId: "Other" }); - await expect( - firstRegistry.send(wrongOwner, { session: "push-fixes", message: FOLLOW_UP_VIBE_TASK }), - ).rejects.toThrow('Unknown vibe session "push-fixes"'); - const wrongParent = createSession({ - manager: firstManager, - sessionManager: parentManager, - parentSessionId: "different-parent", - }); - await expect( - firstRegistry.send(wrongParent, { session: "push-fixes", message: FOLLOW_UP_VIBE_TASK }), - ).rejects.toThrow('Unknown vibe session "push-fixes"'); - - await parentManager.flush(); - await parentManager.close(); - await simulateProcessBoundary(); - const resumedManager = await SessionManager.open(parentSessionFile, undefined, undefined, { - suppressBreadcrumb: true, - }); - persistedManagers.push(resumedManager); - const resumedJobs = createManager(); - const resumedSession = createSession({ manager: resumedJobs, sessionManager: resumedManager }); - const revived: { sessionFile?: string; prompts?: string[] } = {}; - installPersistedReviver(revived); - - const resumedRegistry = VibeSessionRegistry.global(); - expect(await resumedRegistry.rehydrate(resumedSession)).toBe(1); - expect(AgentRegistry.global().get("push-fixes")?.status).toBe("parked"); - expect(resumedRegistry.screens(resumedSession)[0]).toMatchObject({ - id: "push-fixes", - state: "idle", - turns: 1, - }); - - const outcome = await resumedRegistry.send(resumedSession, { - session: "push-fixes", - message: FOLLOW_UP_VIBE_TASK, - }); - expect(outcome.mode).toBe("turn"); - const turnJob = resumedJobs.getJob(outcome.jobId!)!; - await turnJob.promise; - expect(revived.sessionFile).toBe(path.join(parentSessionFile.slice(0, -6), "push-fixes.jsonl")); - expect(revived.prompts).toEqual([INITIAL_VIBE_TASK, FOLLOW_UP_VIBE_TASK]); - expect(turnJob.resultText).toContain('turn="2"'); - expect(turnJob.resultText).toContain(RESTORED_VIBE_RESPONSE); - }); - - it("suspends an idle persisted worker for same-process disposal and resumes turn two", async () => { - installPersistedSpawnMock(); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const firstJobs = createManager(); - const firstSession = createSession({ manager: firstJobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const spawned = await registry.spawn(firstSession, { - cli: "fast", - name: "idle-dispose", - prompt: INITIAL_VIBE_TASK, - }); - await firstJobs.getJob(spawned.jobId)!.promise; - expect(await registry.suspendScope(registry.ownerScope(firstSession), firstJobs)).toBe(1); - expect(AgentRegistry.global().get("idle-dispose")).toBeUndefined(); - expect( - parentManager.getEntries().some(entry => { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) return false; - return (entry.data as Record).action === "tombstone"; - }), - ).toBe(false); - await parentManager.close(); - const reopened = await SessionManager.open(parentSessionFile, undefined, undefined, { suppressBreadcrumb: true }); - persistedManagers.push(reopened); - const resumedJobs = createManager(); - const resumedSession = createSession({ manager: resumedJobs, sessionManager: reopened }); - const revived: { sessionFile?: string; prompts?: string[] } = {}; - installPersistedReviver(revived); - expect(await registry.rehydrate(resumedSession)).toBe(1); - const outcome = await registry.send(resumedSession, { - session: "idle-dispose", - message: FOLLOW_UP_VIBE_TASK, - }); - const turnJob = resumedJobs.getJob(outcome.jobId!)!; - await turnJob.promise; - expect(revived.prompts).toEqual([INITIAL_VIBE_TASK, FOLLOW_UP_VIBE_TASK]); - expect(turnJob.resultText).toContain('turn="2"'); - }); - - it("suspends a blocked in-flight worker for fresh-process disposal without tombstoning it", async () => { - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - const artifactsDir = options.artifactsDir; - if (!artifactsDir) throw new Error("Persisted vibe test requires an artifacts directory"); - const childSessionFile = await persistWorkerSession({ - cwd: options.cwd, - artifactsDir, - id: options.id, - task: options.task, - }); - const worker = createFakeWorkerSession(); - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: options.parentAgentId ?? "Main", - session: worker.session, - sessionFile: childSessionFile, - status: "running", - }); - const signal = options.signal; - if (!signal) throw new Error("Persisted blocked worker requires a cancellation signal"); - await new Promise(resolve => { - if (signal.aborted) resolve(); - else signal.addEventListener("abort", () => resolve(), { once: true }); - }); - return makeResult(options.id, { output: "Interrupted by disposal.", aborted: true }); - }); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const firstJobs = createManager(); - const firstSession = createSession({ manager: firstJobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - await registry.spawn(firstSession, { - cli: "fast", - name: "running-dispose", - prompt: INITIAL_VIBE_TASK, - }); - await pollUntil(() => AgentRegistry.global().get("running-dispose")?.status === "running"); - expect(await registry.suspendScope(registry.ownerScope(firstSession), firstJobs)).toBe(1); - expect( - parentManager.getEntries().some(entry => { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) return false; - return (entry.data as Record).action === "tombstone"; - }), - ).toBe(false); - await parentManager.close(); - await simulateProcessBoundary(); - - const reopened = await SessionManager.open(parentSessionFile, undefined, undefined, { suppressBreadcrumb: true }); - persistedManagers.push(reopened); - const resumedJobs = createManager(); - const resumedSession = createSession({ manager: resumedJobs, sessionManager: reopened }); - const revived: { sessionFile?: string; prompts?: string[] } = {}; - installPersistedReviver(revived); - const freshRegistry = VibeSessionRegistry.global(); - expect(await freshRegistry.rehydrate(resumedSession)).toBe(1); - expect(freshRegistry.screens(resumedSession)[0]).toMatchObject({ - id: "running-dispose", - state: "idle", - turns: 1, - lastActivity: "turn 1 interrupted by process restart", - }); - const outcome = await freshRegistry.send(resumedSession, { - session: "running-dispose", - message: FOLLOW_UP_VIBE_TASK, - }); - const turnJob = resumedJobs.getJob(outcome.jobId!)!; - await turnJob.promise; - expect(revived.prompts).toEqual([INITIAL_VIBE_TASK, FOLLOW_UP_VIBE_TASK]); - expect(turnJob.resultText).toContain('turn="2"'); - }); - - it("bounds parent-session suspension when a cancelled turn ignores abort and settles late", async () => { - const gate = deferred(); - const started = deferred(); - const disposed = deferred(); - const fake = createFakeWorkerSession({ onDispose: disposed.resolve }); - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: "Main", - session: fake.session, - status: "running", - }); - started.resolve(); - await gate.promise; - return makeResult(options.id); - }); - - const manager = createManager(); - const session = createSession({ manager }); - const registry = VibeSessionRegistry.global(); - const { jobId } = await registry.spawn(session, { - cli: "fast", - name: "IgnoresSuspendAbort", - prompt: "Keep working through a parent-session switch.", - }); - await started.promise; - - vi.useFakeTimers(); - try { - const suspension = registry.suspendScope(registry.ownerScope(session), manager); - await disposed.promise; - await flushMicrotasks(); - expect(vi.getTimerCount()).toBeGreaterThan(0); - vi.advanceTimersByTime(250); - - expect(await suspension).toBe(1); - expect(manager.getJob(jobId)!.status).toBe("cancelled"); - expect(fake.isDisposed()).toBe(true); - expect(AgentRegistry.global().get("IgnoresSuspendAbort")).toBeUndefined(); - expect(registry.listIds(session)).toEqual([]); - - gate.resolve(); - await manager.getJob(jobId)!.promise; - expect(manager.getJob(jobId)!.status).toBe("cancelled"); - expect(fake.isDisposed()).toBe(true); - expect(AgentRegistry.global().get("IgnoresSuspendAbort")).toBeUndefined(); - expect(registry.listIds(session)).toEqual([]); - } finally { - gate.resolve(); - vi.useRealTimers(); - } - }); - - it("restores an interrupted turn as idle without replay and continues only after send", async () => { - installPersistedSpawnMock(); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const firstJobs = createManager(); - const firstSession = createSession({ manager: firstJobs, sessionManager: parentManager }); - const spawned = await VibeSessionRegistry.global().spawn(firstSession, { - cli: "fast", - name: "interrupted", - prompt: INITIAL_VIBE_TASK, - }); - await firstJobs.getJob(spawned.jobId)!.promise; - const turnStarted = parentManager.getEntries().find(entry => { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) return false; - const data = entry.data as Record; - return data.id === "interrupted" && data.action === "turn-started"; - }); - if (!turnStarted) throw new Error("Expected a persisted turn-started lifecycle event"); - parentManager.branch(turnStarted.id); - parentManager.appendModeChange("vibe"); - await parentManager.flush(); - await parentManager.close(); - await simulateProcessBoundary(); - - const resumedManager = await SessionManager.open(parentSessionFile, undefined, undefined, { - suppressBreadcrumb: true, - }); - persistedManagers.push(resumedManager); - const resumedJobs = createManager(); - const resumedSession = createSession({ manager: resumedJobs, sessionManager: resumedManager }); - const revived: { sessionFile?: string; prompts?: string[] } = {}; - installPersistedReviver(revived); - const registry = VibeSessionRegistry.global(); - expect(await registry.rehydrate(resumedSession)).toBe(1); - expect(registry.screens(resumedSession)[0]).toMatchObject({ - id: "interrupted", - state: "idle", - turns: 1, - lastActivity: "turn 1 interrupted by process restart", - }); - expect(revived).toEqual({}); - - const outcome = await registry.send(resumedSession, { - session: "interrupted", - message: FOLLOW_UP_VIBE_TASK, - }); - const turnJob = resumedJobs.getJob(outcome.jobId!)!; - await turnJob.promise; - expect(revived.prompts).toEqual([INITIAL_VIBE_TASK, FOLLOW_UP_VIBE_TASK]); - expect(turnJob.resultText).toContain('turn="2"'); - }); - - it("never lets an old lifecycle append cross into a target session during its load window", async () => { - installPersistedSpawnMock(); - const storage = new SwitchGatedSessionStorage(); - const sourceManager = await createPersistedParent(storage); - sourceManager.appendModeChange("vibe"); - const sourceSessionId = sourceManager.getSessionId(); - const sourceSessionFile = sourceManager.getSessionFile(); - if (!sourceSessionFile) throw new Error("Persisted source session file was not created"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: sourceManager }); - const registry = VibeSessionRegistry.global(); - const spawned = await registry.spawn(session, { - cli: "fast", - name: "switch-race", - prompt: INITIAL_VIBE_TASK, - }); - await jobs.getJob(spawned.jobId)!.promise; - - const targetManager = await createPersistedParent(storage); - targetManager.appendModeChange("goal"); - targetManager.appendCustomEntry("target-only"); - await targetManager.ensureOnDisk(); - const targetSessionFile = targetManager.getSessionFile(); - if (!targetSessionFile) throw new Error("Persisted target session file was not created"); - await targetManager.close(); - - const lifecycleAppendStarted = Promise.withResolvers(); - const releaseLifecycleAppend = Promise.withResolvers(); - const originalEnsureOnDisk = sourceManager.ensureOnDisk.bind(sourceManager); - let gateLifecycleAppend = true; - const ensureOnDisk = vi.spyOn(sourceManager, "ensureOnDisk").mockImplementation(async () => { - if (gateLifecycleAppend) { - gateLifecycleAppend = false; - lifecycleAppendStarted.resolve(); - await releaseLifecycleAppend.promise; - } - await originalEnsureOnDisk(); - }); - const followUp = await registry.send(session, { - session: "switch-race", - message: FOLLOW_UP_VIBE_TASK, - }); - const turnJob = jobs.getJob(followUp.jobId!)!; - await lifecycleAppendStarted.promise; - - const targetRead = storage.gateNextRead(targetSessionFile); - const switching = sourceManager.setSessionFile(targetSessionFile); - await targetRead.started; - releaseLifecycleAppend.resolve(); - await turnJob.promise.catch(() => undefined); - targetRead.release(); - await switching; - ensureOnDisk.mockRestore(); - - expect(turnJob.status).toBe("failed"); - expect(sourceManager.buildSessionContext().mode).toBe("goal"); - expect( - sourceManager - .getBranch() - .map(entry => - entry.type === "mode_change" - ? `mode:${entry.mode}` - : `custom:${entry.type === "custom" ? entry.customType : entry.type}`, - ), - ).toEqual(["mode:goal", "custom:target-only"]); - expect( - sourceManager.getEntries().some(entry => { - if (entry.type !== "custom" || entry.customType !== "vibe-session-lifecycle") return false; - if (typeof entry.data !== "object" || entry.data === null) return false; - return (entry.data as Record).parentSessionId === sourceSessionId; - }), - ).toBe(false); - expect(await fs.readFile(targetSessionFile, "utf8")).not.toContain(sourceSessionId); - }); - - it("never sends through or releases a registry ref with the wrong child session file", async () => { - installPersistedSpawnMock(); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const spawned = await registry.spawn(session, { - cli: "fast", - name: "exact-ref", - prompt: INITIAL_VIBE_TASK, - }); - await jobs.getJob(spawned.jobId)!.promise; - await AgentLifecycleManager.global().release("exact-ref"); - const mismatchedFile = path.join(parentSessionFile.slice(0, -6), "another-parent.jsonl"); - AgentRegistry.global().register({ - id: "exact-ref", - displayName: "exact-ref", - kind: "sub", - parentId: "Main", - session: null, - sessionFile: mismatchedFile, - status: "parked", - }); - - await expect(registry.send(session, { session: "exact-ref", message: FOLLOW_UP_VIBE_TASK })).rejects.toThrow( - "no longer resolves to this parent session", - ); - await registry.kill(session, "exact-ref"); - expect(AgentRegistry.global().get("exact-ref")).toMatchObject({ - status: "parked", - sessionFile: mismatchedFile, - }); - }); - - it("suspends a parent without tombstones so the same worker id can move between isolated scopes", async () => { - installPersistedSpawnMock(); - const parentA = await createPersistedParent(); - const parentB = await createPersistedParent(); - parentA.appendModeChange("vibe"); - parentB.appendModeChange("vibe"); - const jobsA = createManager(); - const jobsB = createManager(); - const sessionA = createSession({ manager: jobsA, sessionManager: parentA }); - const sessionB = createSession({ manager: jobsB, sessionManager: parentB }); - const registry = VibeSessionRegistry.global(); - const workerA = await registry.spawn(sessionA, { - cli: "fast", - name: "shared-name", - prompt: INITIAL_VIBE_TASK, - }); - await jobsA.getJob(workerA.jobId)!.promise; - expect(await registry.suspendScope(registry.ownerScope(sessionA), jobsA)).toBe(1); - expect(AgentRegistry.global().get("shared-name")).toBeUndefined(); - - const workerB = await registry.spawn(sessionB, { - cli: "fast", - name: "shared-name", - prompt: INITIAL_VIBE_TASK, - }); - expect(workerB.id).toBe("shared-name"); - await jobsB.getJob(workerB.jobId)!.promise; - expect(await registry.suspendScope(registry.ownerScope(sessionB), jobsB)).toBe(1); - const revived: { sessionFile?: string; prompts?: string[] } = {}; - installPersistedReviver(revived); - expect(await registry.rehydrate(sessionA)).toBe(1); - expect(registry.listIds(sessionA)).toEqual(["shared-name"]); - await expect(registry.send(sessionB, { session: "shared-name", message: FOLLOW_UP_VIBE_TASK })).rejects.toThrow( - 'Unknown vibe session "shared-name"', - ); - - const outcome = await registry.send(sessionA, { - session: "shared-name", - message: FOLLOW_UP_VIBE_TASK, - }); - await jobsA.getJob(outcome.jobId!)!.promise; - expect(revived.prompts).toEqual([INITIAL_VIBE_TASK, FOLLOW_UP_VIBE_TASK]); - }); - - it("does not let late teardown from a suspended parent mutate a new same-id worker", async () => { - const parentA = await createPersistedParent(); - const parentB = await createPersistedParent(); - parentA.appendModeChange("vibe"); - parentB.appendModeChange("vibe"); - const parentAFile = parentA.getSessionFile(); - if (!parentAFile) throw new Error("Persisted parent A session file was not created"); - let workerA: ReturnType | undefined; - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - const artifactsDir = options.artifactsDir; - if (!artifactsDir) throw new Error("Persisted vibe test requires an artifacts directory"); - const childSessionFile = await persistWorkerSession({ - cwd: options.cwd, - artifactsDir, - id: options.id, - task: options.task, - }); - const worker = createFakeWorkerSession(); - if (options.sessionFile === parentAFile) workerA = worker; - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: options.parentAgentId ?? "Main", - session: worker.session, - sessionFile: childSessionFile, - status: "running", - }); - if (options.sessionFile === parentAFile) { - const signal = options.signal; - if (!signal) throw new Error("Persisted blocked worker requires a cancellation signal"); - await new Promise(resolve => { - if (signal.aborted) resolve(); - else signal.addEventListener("abort", () => resolve(), { once: true }); - }); - return makeResult(options.id, { output: "Parent A suspended.", aborted: true }); - } - AgentRegistry.global().setStatus(options.id, "idle"); - return makeResult(options.id, { output: "Parent B finished." }); - }); - const jobsA = createManager(); - const jobsB = createManager(); - const sessionA = createSession({ manager: jobsA, sessionManager: parentA }); - const sessionB = createSession({ manager: jobsB, sessionManager: parentB }); - const registry = VibeSessionRegistry.global(); - await registry.spawn(sessionA, { cli: "fast", name: "reused", prompt: INITIAL_VIBE_TASK }); - await pollUntil(() => workerA !== undefined); - const oldRef = AgentRegistry.global().get("reused"); - if (!oldRef) throw new Error("Expected parent A worker ref"); - expect(await registry.suspendScope(registry.ownerScope(sessionA), jobsA)).toBe(1); - - const second = await registry.spawn(sessionB, { cli: "fast", name: "reused", prompt: INITIAL_VIBE_TASK }); - await jobsB.getJob(second.jobId)!.promise; - const replacement = AgentRegistry.global().get("reused"); - expect(replacement).toMatchObject({ status: "idle" }); - expect(replacement).not.toBe(oldRef); - - await executorModule.finalizeSubagentLifecycle({ - id: "reused", - session: workerA!.session, - aborted: true, - keepAlive: true, - isolated: false, - agentIdleTtlMs: 0, - reviveSession: null, - }); - await AgentLifecycleManager.global().release("reused", oldRef); - expect(AgentRegistry.global().get("reused")).toBe(replacement); - expect(registry.screens(sessionB)[0]).toMatchObject({ id: "reused", state: "idle" }); - }); - - it("rejects escaped child paths and JSONL files without persisted worker init", async () => { - installPersistedSpawnMock(); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const spawned = await VibeSessionRegistry.global().spawn(session, { - cli: "fast", - name: "valid-worker", - prompt: INITIAL_VIBE_TASK, - }); - await jobs.getJob(spawned.jobId)!.promise; - - const lifecycleEntries = parentManager.getEntries().filter(entry => { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) return false; - return (entry.data as Record).id === "valid-worker"; - }); - if (lifecycleEntries.length < 3) throw new Error("Expected complete persisted worker lifecycle events"); - const artifactsDir = parentSessionFile.slice(0, -6); - const invalidManager = SessionManager.create(parentManager.getCwd(), artifactsDir); - persistedManagers.push(invalidManager); - await invalidManager.ensureOnDisk(); - const invalidSource = invalidManager.getSessionFile(); - if (!invalidSource) throw new Error("Expected an invalid child fixture path"); - await invalidManager.close(); - await fs.rename(invalidSource, path.join(artifactsDir, "invalid-init.jsonl")); - - for (const { id, childSessionFile } of [ - { id: "escaped", childSessionFile: "../escaped.jsonl" }, - { id: "invalid-init", childSessionFile: "invalid-init.jsonl" }, - ]) { - for (const entry of lifecycleEntries) { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) continue; - const data: Record = { ...(entry.data as Record), id }; - if (data.action === "spawn") data.childSessionFile = childSessionFile; - parentManager.appendCustomEntry(entry.customType, data); - } - } - await parentManager.flush(); - await parentManager.close(); - await simulateProcessBoundary(); - - const resumedManager = await SessionManager.open(parentSessionFile, undefined, undefined, { - suppressBreadcrumb: true, - }); - persistedManagers.push(resumedManager); - const resumedSession = createSession({ manager: createManager(), sessionManager: resumedManager }); - const registry = VibeSessionRegistry.global(); - expect(await registry.rehydrate(resumedSession)).toBe(1); - expect(registry.listIds(resumedSession)).toEqual(["valid-worker"]); - expect(AgentRegistry.global().get("escaped")).toBeUndefined(); - expect(AgentRegistry.global().get("invalid-init")).toBeUndefined(); - }); - - it("reserves orphan JSONL and lifecycle-known ids before allocating a worker name", async () => { - installPersistedSpawnMock(); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const artifactsDir = parentSessionFile.slice(0, -6); - await fs.mkdir(artifactsDir, { recursive: true }); - await fs.writeFile(path.join(artifactsDir, "orphan.jsonl"), "orphaned transcript"); - const firstJobs = createManager(); - const firstSession = createSession({ manager: firstJobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const orphanCollision = await registry.spawn(firstSession, { - cli: "fast", - name: "orphan", - prompt: INITIAL_VIBE_TASK, - }); - expect(orphanCollision.id).toBe("orphan-2"); - await firstJobs.getJob(orphanCollision.jobId)!.promise; - - const metadataOnly = await registry.spawn(firstSession, { - cli: "fast", - name: "metadata-only", - prompt: INITIAL_VIBE_TASK, - }); - await firstJobs.getJob(metadataOnly.jobId)!.promise; - await registry.kill(firstSession, metadataOnly.id); - await fs.rm(path.join(artifactsDir, "metadata-only.jsonl"), { force: true }); - await simulateProcessBoundary(); - - const resumedJobs = createManager(); - const resumedSession = createSession({ manager: resumedJobs, sessionManager: parentManager }); - const metadataCollision = await VibeSessionRegistry.global().spawn(resumedSession, { - cli: "fast", - name: "metadata-only", - prompt: INITIAL_VIBE_TASK, - }); - expect(metadataCollision.id).toBe("metadata-only-2"); - await resumedJobs.getJob(metadataCollision.jobId)!.promise; - }); - - it("retains a cold candidate blocked by another parent id collision so mode exit can tombstone it", async () => { - installPersistedSpawnMock(); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const spawned = await registry.spawn(session, { - cli: "fast", - name: "collision", - prompt: INITIAL_VIBE_TASK, - }); - await jobs.getJob(spawned.jobId)!.promise; - await registry.suspendScope(registry.ownerScope(session), jobs); - const otherWorker = createFakeWorkerSession(); - const otherSessionFile = path.join(path.dirname(parentSessionFile), "other-parent", "collision.jsonl"); - const otherRef = AgentRegistry.global().register({ - id: "collision", - displayName: "collision", - kind: "sub", - parentId: "Main", - session: otherWorker.session, - sessionFile: otherSessionFile, - status: "idle", - }); - - expect(await registry.rehydrate(session)).toBe(1); - expect(registry.screens(session)[0]?.lastActivity).toBe("blocked by an agent id collision"); - expect(await registry.killAll(session)).toBe(1); - expect(AgentRegistry.global().get("collision")).toBe(otherRef); - expect(otherWorker.isDisposed()).toBe(false); - expect( - parentManager.getEntries().some(entry => { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) return false; - const data = entry.data as Record; - return data.id === "collision" && data.action === "tombstone"; - }), - ).toBe(true); - - AgentRegistry.global().unregister("collision", otherRef); - await parentManager.close(); - await simulateProcessBoundary(); - const reopened = await SessionManager.open(parentSessionFile, undefined, undefined, { suppressBreadcrumb: true }); - persistedManagers.push(reopened); - const resumedSession = createSession({ manager: createManager(), sessionManager: reopened }); - expect(await VibeSessionRegistry.global().rehydrate(resumedSession)).toBe(0); - expect(AgentRegistry.global().get("collision")).toMatchObject({ status: "aborted", session: null }); - }); - - it("rejects a spawn queued behind mode exit and leaves no live or untombstoned worker", async () => { - const storage = new FaultInjectingSessionStorage(); - const parentManager = await createPersistedParent(storage); - parentManager.appendModeChange("vibe"); - await parentManager.ensureOnDisk(); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const exitWriteStarted = Promise.withResolvers(); - const releaseExitWrite = Promise.withResolvers(); - storage.atomicWriteHooks.push(async commit => { - exitWriteStarted.resolve(); - await releaseExitWrite.promise; - await commit(); - }); - - const exiting = registry.killAll(session); - await exitWriteStarted.promise; - let spawnSettled = false; - const lateSpawn = registry - .spawn(session, { cli: "fast", name: "late-after-exit", prompt: INITIAL_VIBE_TASK }) - .then( - () => ({ error: undefined }), - error => ({ error }), - ) - .finally(() => { - spawnSettled = true; - }); - await flushMicrotasks(); - expect(spawnSettled).toBe(false); - - releaseExitWrite.resolve(); - expect(await exiting).toBe(0); - const { error } = await lateSpawn; - expect(error).toBeInstanceOf(Error); - expect(String(error)).toContain("Vibe mode has exited"); - expect(registry.listIds(session)).toEqual([]); - expect(AgentRegistry.global().get("late-after-exit")).toBeUndefined(); - expect(parentManager.buildSessionContext().mode).toBe("none"); - expect( - parentManager.getEntries().some(entry => { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) return false; - const data = entry.data as Record; - return data.id === "late-after-exit" && data.action === "spawn"; - }), - ).toBe(false); - }); - - it("keeps mode and workers live after a real atomic mode-exit failure, then retries cleanly", async () => { - installPersistedSpawnMock(); - const storage = new FaultInjectingSessionStorage(); - const parentManager = await createPersistedParent(storage); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const first = await registry.spawn(session, { - cli: "fast", - name: "retry-exit-one", - prompt: INITIAL_VIBE_TASK, - }); - const second = await registry.spawn(session, { - cli: "good", - name: "retry-exit-two", - prompt: INITIAL_VIBE_TASK, - }); - await Promise.all([jobs.getJob(first.jobId)!.promise, jobs.getJob(second.jobId)!.promise]); - const firstRef = AgentRegistry.global().get("retry-exit-one"); - const secondRef = AgentRegistry.global().get("retry-exit-two"); - if (!firstRef || !secondRef) throw new Error("Expected both live worker refs"); - await parentManager.flush(); - const beforeBytes = await fs.readFile(parentSessionFile); - const beforeSize = (await fs.stat(parentSessionFile)).size; - storage.atomicWriteHooks.push(async () => { - throw Object.assign(new Error("atomic publish unavailable"), { code: "ENOSPC" }); - }); - - await expect(registry.killAll(session)).rejects.toThrow("atomic publish unavailable"); - - expect((await fs.stat(parentSessionFile)).size).toBe(beforeSize); - expect(await fs.readFile(parentSessionFile)).toEqual(beforeBytes); - expect(registry.screens(session).map(screen => [screen.id, screen.state])).toEqual([ - ["retry-exit-one", "idle"], - ["retry-exit-two", "idle"], - ]); - expect(AgentRegistry.global().get("retry-exit-one")?.status).toBe("idle"); - expect(AgentRegistry.global().get("retry-exit-two")?.status).toBe("idle"); - expect(parentManager.buildSessionContext().mode).toBe("vibe"); - const failedExitActions = parentManager.getEntries().flatMap(entry => { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) return []; - const data = entry.data as Record; - return typeof data.id === "string" && data.id.startsWith("retry-exit-") && data.action === "tombstone" - ? [data.action] - : []; - }); - expect(failedExitActions).toEqual([]); - - VibeSessionRegistry.resetGlobalForTests(); - const reloadedRegistry = VibeSessionRegistry.global(); - expect(await reloadedRegistry.rehydrate(session)).toBe(2); - expect(reloadedRegistry.listIds(session)).toEqual(["retry-exit-one", "retry-exit-two"]); - expect(AgentRegistry.global().get("retry-exit-one")).toBe(firstRef); - expect(AgentRegistry.global().get("retry-exit-two")).toBe(secondRef); - - expect(await reloadedRegistry.killAll(session)).toBe(2); - expect(reloadedRegistry.listIds(session)).toEqual([]); - expect(AgentRegistry.global().get("retry-exit-one")?.status).toBe("aborted"); - expect(AgentRegistry.global().get("retry-exit-two")?.status).toBe("aborted"); - expect(parentManager.buildSessionContext().mode).toBe("none"); - - await parentManager.close(); - await simulateProcessBoundary(); - const reopened = await SessionManager.open(parentSessionFile, undefined, undefined, { suppressBreadcrumb: true }); - persistedManagers.push(reopened); - const resumedSession = createSession({ manager: createManager(), sessionManager: reopened }); - expect(reopened.buildSessionContext().mode).toBe("none"); - expect(await VibeSessionRegistry.global().rehydrate(resumedSession)).toBe(0); - expect(AgentRegistry.global().get("retry-exit-one")).toMatchObject({ status: "aborted", session: null }); - expect(AgentRegistry.global().get("retry-exit-two")).toMatchObject({ status: "aborted", session: null }); - }); - - it("fail-closes workers when mode-exit rollback durability is indeterminate", async () => { - installPersistedSpawnMock(); - const storage = new FaultInjectingSessionStorage(); - const parentManager = await createPersistedParent(storage); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const spawned = await registry.spawn(session, { - cli: "fast", - name: "indeterminate-exit", - prompt: INITIAL_VIBE_TASK, - }); - await jobs.getJob(spawned.jobId)!.promise; - storage.atomicWriteHooks.push( - async commit => { - await commit(); - throw new Error("mode exit committed but acknowledgement failed"); - }, - async () => { - throw new Error("authoritative rollback publish failed"); - }, - ); - - const failure = await registry.killAll(session).catch(error => error); - - expect(failure).toBeInstanceOf(SessionPersistenceIndeterminateError); - expect(registry.listIds(session)).toEqual([]); - expect(AgentRegistry.global().get("indeterminate-exit")).toMatchObject({ status: "aborted", session: null }); - expect(parentManager.buildSessionContext().mode).toBe("vibe"); - const durableText = await fs.readFile(parentSessionFile, "utf8"); - expect(durableText).toContain('"reason":"mode-exit"'); - expect(durableText).toContain('"mode":"none"'); - - await parentManager.recoverPersistenceFromCurrentState(); - expect(await registry.killAll(session)).toBe(1); - expect(parentManager.buildSessionContext().mode).toBe("none"); - }); - - it("tears down and repairs a partial writer failure while persisting an explicit tombstone", async () => { - installPersistedSpawnMock(); - const storage = new FaultInjectingSessionStorage(); - const parentManager = await createPersistedParent(storage); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const spawned = await registry.spawn(session, { - cli: "fast", - name: "explicit-io-failure", - prompt: INITIAL_VIBE_TASK, - }); - await jobs.getJob(spawned.jobId)!.promise; - const liveRef = AgentRegistry.global().get("explicit-io-failure"); - if (!liveRef?.session) throw new Error("Expected a live worker ref"); - const dispose = vi.spyOn(liveRef.session, "dispose"); - await parentManager.flush(); - const beforeSize = (await fs.stat(parentSessionFile)).size; - storage.failNextAppendWithPrefix(Object.assign(new Error("explicit tombstone ENOSPC"), { code: "ENOSPC" }), 23); - - await expect(registry.kill(session, "explicit-io-failure")).rejects.toThrow("explicit tombstone ENOSPC"); - - expect(dispose).toHaveBeenCalled(); - expect(storage.failedWriterClosed).toBe(true); - expect(registry.listIds(session)).toEqual([]); - expect(AgentRegistry.global().get("explicit-io-failure")).toMatchObject({ status: "aborted", session: null }); - await parentManager.flush(); - expect((await fs.stat(parentSessionFile)).size).toBeGreaterThan(beforeSize); - const repairedLines = (await fs.readFile(parentSessionFile, "utf8")).trimEnd().split("\n"); - expect(repairedLines.every(line => Boolean(JSON.parse(line)))).toBe(true); - expect( - parentManager.getEntries().some(entry => { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) return false; - const data = entry.data as Record; - return data.id === "explicit-io-failure" && data.action === "tombstone" && data.reason === "explicit-kill"; - }), - ).toBe(true); - - await parentManager.close(); - await simulateProcessBoundary(); - const reopened = await SessionManager.open(parentSessionFile, undefined, undefined, { suppressBreadcrumb: true }); - persistedManagers.push(reopened); - const resumedSession = createSession({ manager: createManager(), sessionManager: reopened }); - expect(await VibeSessionRegistry.global().rehydrate(resumedSession)).toBe(0); - expect(AgentRegistry.global().get("explicit-io-failure")).toMatchObject({ status: "aborted", session: null }); - }); - - it("serializes explicit kill ahead of a failing mode exit so rollback cannot erase it", async () => { - installPersistedSpawnMock(); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const explicit = await registry.spawn(session, { - cli: "fast", - name: "overlap-explicit", - prompt: INITIAL_VIBE_TASK, - }); - const survivor = await registry.spawn(session, { - cli: "good", - name: "overlap-survivor", - prompt: INITIAL_VIBE_TASK, - }); - await Promise.all([jobs.getJob(explicit.jobId)!.promise, jobs.getJob(survivor.jobId)!.promise]); - - const originalFlush = parentManager.flush.bind(parentManager); - const explicitFlushStarted = Promise.withResolvers(); - const releaseExplicitFlush = Promise.withResolvers(); - const explicitTeardownStarted = Promise.withResolvers(); - const releaseExplicitTeardown = Promise.withResolvers(); - const lifecycle = AgentLifecycleManager.global(); - const originalRelease = lifecycle.release.bind(lifecycle); - const releaseSpy = vi.spyOn(lifecycle, "release").mockImplementation(async (id, expected) => { - if (id === "overlap-explicit") { - explicitTeardownStarted.resolve(); - await releaseExplicitTeardown.promise; - } - return originalRelease(id, expected); - }); - let flushCalls = 0; - const flush = vi.spyOn(parentManager, "flush").mockImplementation(async () => { - flushCalls++; - if (flushCalls === 1) { - explicitFlushStarted.resolve(); - await releaseExplicitFlush.promise; - await originalFlush(); - return; - } - if (flushCalls === 2) throw new Error("overlapping mode exit failed"); - await originalFlush(); - }); - - const explicitKill = registry.kill(session, "overlap-explicit"); - await explicitFlushStarted.promise; - const failedExit = registry.killAll(session).catch(error => error); - await flushMicrotasks(); - expect(flushCalls).toBe(1); - releaseExplicitFlush.resolve(); - await explicitTeardownStarted.promise; - await flushMicrotasks(); - expect(flushCalls).toBe(1); - releaseExplicitTeardown.resolve(); - expect((await explicitKill).id).toBe("overlap-explicit"); - const exitError = await failedExit; - expect(exitError).toBeInstanceOf(Error); - expect(String(exitError)).toContain("overlapping mode exit failed"); - expect(flushCalls).toBe(2); - expect(parentManager.buildSessionContext().mode).toBe("vibe"); - expect(AgentRegistry.global().get("overlap-explicit")).toMatchObject({ status: "aborted", session: null }); - expect(AgentRegistry.global().get("overlap-survivor")?.status).toBe("idle"); - - flush.mockRestore(); - releaseSpy.mockRestore(); - await parentManager.close(); - await simulateProcessBoundary(); - const reopened = await SessionManager.open(parentSessionFile, undefined, undefined, { suppressBreadcrumb: true }); - persistedManagers.push(reopened); - const resumedSession = createSession({ manager: createManager(), sessionManager: reopened }); - expect(await VibeSessionRegistry.global().rehydrate(resumedSession)).toBe(1); - expect(AgentRegistry.global().get("overlap-explicit")).toMatchObject({ status: "aborted", session: null }); - expect(AgentRegistry.global().get("overlap-survivor")).toMatchObject({ status: "parked", session: null }); - }); - - it("serializes overlapping mode exits so a later success cannot be revoked by an earlier failure", async () => { - installPersistedSpawnMock(); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const spawned = await registry.spawn(session, { - cli: "fast", - name: "overlap-mode-exit", - prompt: INITIAL_VIBE_TASK, - }); - await jobs.getJob(spawned.jobId)!.promise; - - const originalFlush = parentManager.flush.bind(parentManager); - const firstExitFlushStarted = Promise.withResolvers(); - const releaseFirstExitFlush = Promise.withResolvers(); - let flushCalls = 0; - const flush = vi.spyOn(parentManager, "flush").mockImplementation(async () => { - flushCalls++; - if (flushCalls === 1) { - firstExitFlushStarted.resolve(); - await releaseFirstExitFlush.promise; - throw new Error("first mode exit failed"); - } - await originalFlush(); - }); - - const firstExit = registry.killAll(session).catch(error => error); - await firstExitFlushStarted.promise; - const successfulExit = registry.killAll(session); - await flushMicrotasks(); - expect(flushCalls).toBe(1); - releaseFirstExitFlush.resolve(); - const firstError = await firstExit; - expect(firstError).toBeInstanceOf(Error); - expect(String(firstError)).toContain("first mode exit failed"); - expect(await successfulExit).toBe(1); - expect(flushCalls).toBe(2); - expect(parentManager.buildSessionContext().mode).toBe("none"); - expect(registry.listIds(session)).toEqual([]); - expect(AgentRegistry.global().get("overlap-mode-exit")).toMatchObject({ status: "aborted", session: null }); - expect(await registry.killAll(session)).toBe(0); - expect(flushCalls).toBe(2); - - flush.mockRestore(); - await parentManager.close(); - await simulateProcessBoundary(); - const reopened = await SessionManager.open(parentSessionFile, undefined, undefined, { suppressBreadcrumb: true }); - persistedManagers.push(reopened); - const resumedSession = createSession({ manager: createManager(), sessionManager: reopened }); - expect(await VibeSessionRegistry.global().rehydrate(resumedSession)).toBe(0); - expect(AgentRegistry.global().get("overlap-mode-exit")).toMatchObject({ status: "aborted", session: null }); - }); - - it("persists mode none when re-exiting a rewound pre-exit vibe branch", async () => { - installPersistedSpawnMock(); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const spawned = await registry.spawn(session, { - cli: "fast", - name: "rewound-mode-exit", - prompt: INITIAL_VIBE_TASK, - }); - await jobs.getJob(spawned.jobId)!.promise; - const preExitLeaf = parentManager.getLeafId(); - if (!preExitLeaf) throw new Error("Expected a pre-exit Vibe branch leaf"); - - expect(await registry.killAll(session)).toBe(1); - expect(parentManager.buildSessionContext().mode).toBe("none"); - parentManager.branch(preExitLeaf); - expect(parentManager.buildSessionContext().mode).toBe("vibe"); - expect(await registry.rehydrate(session)).toBe(0); - expect(registry.listIds(session)).toEqual([]); - - expect(await registry.killAll(session)).toBe(0); - expect(parentManager.buildSessionContext().mode).toBe("none"); - - await parentManager.close(); - await simulateProcessBoundary(); - const reopened = await SessionManager.open(parentSessionFile, undefined, undefined, { suppressBreadcrumb: true }); - persistedManagers.push(reopened); - expect(reopened.buildSessionContext().mode).toBe("none"); - }); - - it("does not rehydrate workers tombstoned by explicit kill or deliberate mode exit", async () => { - installPersistedSpawnMock(); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const firstManager = createManager(); - const firstSession = createSession({ manager: firstManager, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const explicitlyKilled = await registry.spawn(firstSession, { - cli: "fast", - name: "explicitly-killed", - prompt: INITIAL_VIBE_TASK, - }); - const modeExited = await registry.spawn(firstSession, { - cli: "good", - name: "mode-exited", - prompt: INITIAL_VIBE_TASK, - }); - await Promise.all([ - firstManager.getJob(explicitlyKilled.jobId)!.promise, - firstManager.getJob(modeExited.jobId)!.promise, - ]); - const preKillLeaf = parentManager.getLeafId(); - if (!preKillLeaf) throw new Error("Expected a persisted lifecycle leaf before kill"); - expect((await registry.kill(firstSession, "explicitly-killed")).cancelledTurn).toBe(false); - parentManager.appendCustomEntry("vibe-session-lifecycle", { - version: 1, - action: "tombstone-revoked", - id: "explicitly-killed", - ownerId: "Main", - parentSessionId: parentManager.getSessionId(), - reason: "mode-exit", - }); - await parentManager.flush(); - expect(await registry.killAll(firstSession)).toBe(1); - const killedSnapshot = await SessionManager.open(parentSessionFile, undefined, undefined, { - suppressBreadcrumb: true, - }); - try { - const tombstonedIds = killedSnapshot.getEntries().flatMap(entry => { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) return []; - const data = entry.data as Record; - return data.action === "tombstone" && typeof data.id === "string" ? [data.id] : []; - }); - expect(tombstonedIds.toSorted()).toEqual(["explicitly-killed", "mode-exited"]); - } finally { - await killedSnapshot.close(); - } - parentManager.branch(preKillLeaf); - - parentManager.appendModeChange("none"); - await parentManager.flush(); - await parentManager.close(); - await simulateProcessBoundary(); - const artifactsDir = parentSessionFile.slice(0, -6); - for (const id of ["explicitly-killed", "mode-exited"]) { - AgentRegistry.global().register({ - id, - displayName: id, - kind: "sub", - parentId: "Main", - session: null, - sessionFile: path.join(artifactsDir, `${id}.jsonl`), - status: "parked", - }); - } - const resumedManager = await SessionManager.open(parentSessionFile, undefined, undefined, { - suppressBreadcrumb: true, - }); - persistedManagers.push(resumedManager); - expect(resumedManager.buildSessionContext().mode).toBe("none"); - const resumedSession = createSession({ manager: createManager(), sessionManager: resumedManager }); - const resumedRegistry = VibeSessionRegistry.global(); - expect(await resumedRegistry.rehydrate(resumedSession)).toBe(0); - expect(resumedRegistry.listIds(resumedSession)).toEqual([]); - const explicitRef = AgentRegistry.global().get("explicitly-killed"); - const modeExitRef = AgentRegistry.global().get("mode-exited"); - expect(explicitRef).toMatchObject({ status: "aborted", session: null }); - expect(modeExitRef).toMatchObject({ status: "aborted", session: null }); - if (!explicitRef?.sessionFile || !modeExitRef?.sessionFile) { - throw new Error("Tombstoned workers must retain readable transcript references"); - } - expect((await SessionManager.peekSessionInit(explicitRef.sessionFile))?.init?.task).toBe(INITIAL_VIBE_TASK); - expect((await SessionManager.peekSessionInit(modeExitRef.sessionFile))?.init?.task).toBe(INITIAL_VIBE_TASK); - await expect(AgentLifecycleManager.global().ensureLive("explicitly-killed")).rejects.toThrow( - "aborted and cannot be revived", - ); - await expect( - resumedRegistry.send(resumedSession, { session: "explicitly-killed", message: FOLLOW_UP_VIBE_TASK }), - ).rejects.toThrow('Unknown vibe session "explicitly-killed"'); - await expect( - resumedRegistry.send(resumedSession, { session: "mode-exited", message: FOLLOW_UP_VIBE_TASK }), - ).rejects.toThrow('Unknown vibe session "mode-exited"'); - }); - - it("runSubagentFollowUpTurn continues the same live session and finalizes trace + yield response", async () => { - const fake = createFakeWorkerSession(); - AgentRegistry.global().register({ - id: "Worker", - displayName: "Worker", - kind: "sub", - parentId: "Main", - session: fake.session, - status: "idle", - }); - const agent = { name: "task", description: "worker", systemPrompt: "sp", source: "bundled" as const }; - - fake.setScript({ events: yieldTurnEvents({ report: "did the first thing" }), responseText: "first summary" }); - const progressSnapshots: AgentProgress[] = []; - const first = await executorModule.runSubagentFollowUpTurn({ - id: "Worker", - agent, - message: "do the first thing", - onProgress: progress => progressSnapshots.push({ ...progress, recentTools: progress.recentTools.slice() }), - }); - expect(first.exitCode).toBe(0); - expect(first.output).toContain("did the first thing"); - expect(progressSnapshots.some(progress => progress.recentTools.some(entry => entry.tool === "read"))).toBe(true); - - // Second turn lands on the SAME session instance — prior context retained. - fake.setScript({ events: yieldTurnEvents({ report: "built on prior work" }), responseText: "second summary" }); - const second = await executorModule.runSubagentFollowUpTurn({ id: "Worker", agent, message: "now extend it" }); - expect(second.exitCode).toBe(0); - expect(second.output).toContain("built on prior work"); - expect(fake.prompts).toEqual(["do the first thing", "now extend it"]); - expect(fake.isDisposed()).toBe(false); - }); - - it("wait wakes on the first settling turn among concurrent sessions and suppresses its re-delivery", async () => { - const gates = new Map(); - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: "Main", - session: createFakeWorkerSession().session, - status: "running", - }); - const gate = deferred(); - gates.set(options.id, gate); - await gate.promise; - AgentRegistry.global().setStatus(options.id, "idle"); - return makeResult(options.id, { output: `${options.id} finished.` }); - }); - - const manager = createManager(); - const session = createSession({ manager }); - const registry = VibeSessionRegistry.global(); - const fast = await registry.spawn(session, { cli: "fast", name: "Fast", prompt: "Task A." }); - const good = await registry.spawn(session, { cli: "good", name: "Good", prompt: "Task B." }); - await pollUntil(() => gates.size === 2); - - const waitPromise = registry.wait(session, { sessions: ["Fast", "Good"], timeoutMs: 5000 }); - gates.get("Fast")!.resolve(); - const outcome = await waitPromise; - - expect(outcome.timedOut).toBe(false); - expect(outcome.settled.map(entry => entry.id)).toEqual(["Fast"]); - expect(outcome.settled[0]!.resultText).toContain("Fast finished."); - expect(outcome.stillRunning).toEqual(["Good"]); - // The reported result must not be delivered a second time as a follow-up. - expect(manager.isDeliverySuppressed(fast.jobId)).toBe(true); - expect(manager.isDeliverySuppressed(good.jobId)).toBe(false); - - gates.get("Good")!.resolve(); - await manager.getJob(good.jobId)!.promise; - }); - - it("wait reports the settled turn even when a queued follow-up starts immediately", async () => { - const firstGate = deferred(); - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: "Main", - session: createFakeWorkerSession().session, - status: "running", - }); - await firstGate.promise; - AgentRegistry.global().setStatus(options.id, "idle"); - return makeResult(options.id, { output: "First turn done." }); - }); - const followUpGate = deferred(); - vi.spyOn(executorModule, "runSubagentFollowUpTurn").mockImplementation(async options => { - await followUpGate.promise; - return makeResult(options.id, { output: "Follow-up done." }); - }); - - const manager = createManager(); - const session = createSession({ manager }); - const registry = VibeSessionRegistry.global(); - const { jobId } = await registry.spawn(session, { cli: "fast", name: "Fast", prompt: "Task A." }); - await pollUntil(() => AgentRegistry.global().get("Fast") !== undefined); - - // Queued while mid-turn: #finishTurn starts this follow-up turn inside - // the settling job's callback, BEFORE the watched job's promise resolves. - const queued = await registry.send(session, { session: "Fast", message: "Task B." }); - expect(queued.mode).toBe("queued"); - - const waitPromise = registry.wait(session, { sessions: ["Fast"], timeoutMs: 5000 }); - firstGate.resolve(); - const outcome = await waitPromise; - - // The settled first turn is reported (not shadowed by the new in-flight - // turn) and acknowledged so it is not re-delivered … - expect(outcome.settled.map(entry => entry.jobId)).toEqual([jobId]); - expect(outcome.settled[0]!.resultText).toContain("First turn done."); - expect(manager.isDeliverySuppressed(jobId)).toBe(true); - // … while the drained-queue follow-up shows as still running. - expect(outcome.stillRunning).toEqual(["Fast"]); - - followUpGate.resolve(); - await manager.getJob("Fast-t2")!.promise; - }); - - it("bounds kill teardown when a cancelled turn ignores abort and settles late", async () => { - const gate = deferred(); - const started = deferred(); - const disposed = deferred(); - const fake = createFakeWorkerSession({ onDispose: disposed.resolve }); - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: "Main", - session: fake.session, - status: "running", - }); - started.resolve(); - await gate.promise; - return makeResult(options.id); - }); - - const manager = createManager(); - const session = createSession({ manager }); - const registry = VibeSessionRegistry.global(); - const { jobId } = await registry.spawn(session, { - cli: "fast", - name: "IgnoresKillAbort", - prompt: "Keep working through explicit termination.", - }); - await started.promise; - - vi.useFakeTimers(); - try { - const kill = registry.kill(session, "IgnoresKillAbort"); - await disposed.promise; - await flushMicrotasks(); - expect(vi.getTimerCount()).toBeGreaterThan(0); - vi.advanceTimersByTime(250); - - const outcome = await kill; - expect(outcome.cancelledTurn).toBe(true); - expect(manager.getJob(jobId)!.status).toBe("cancelled"); - expect(fake.isDisposed()).toBe(true); - expect(AgentRegistry.global().get("IgnoresKillAbort")).toBeUndefined(); - expect(registry.screens(session)[0]?.state).toBe("dead"); - await expect(registry.send(session, { session: "IgnoresKillAbort", message: "hello?" })).rejects.toThrow( - "dead", - ); - - gate.resolve(); - await manager.getJob(jobId)!.promise; - expect(manager.getJob(jobId)!.status).toBe("cancelled"); - expect(fake.isDisposed()).toBe(true); - expect(AgentRegistry.global().get("IgnoresKillAbort")).toBeUndefined(); - expect(registry.screens(session)[0]?.state).toBe("dead"); - await expect(registry.send(session, { session: "IgnoresKillAbort", message: "still there?" })).rejects.toThrow( - "dead", - ); - } finally { - gate.resolve(); - vi.useRealTimers(); - } - }); - - it("finalizer persists a hard-abort tombstone before disposing the live ref", async () => { - const parentManager = await createPersistedParent(); - await parentManager.ensureOnDisk(); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Expected a persisted parent session file"); - const workerId = "finalizer-hard-abort"; - const workerSessionFile = path.join(parentSessionFile.slice(0, -6), `${workerId}.jsonl`); - await Bun.write(workerSessionFile, ""); - const worker = createFakeWorkerSession(); - const ref = AgentRegistry.global().register({ - id: workerId, - displayName: workerId, - kind: "sub", - parentId: "Main", - session: worker.session, - sessionFile: workerSessionFile, - status: "running", - }); - - await executorModule.finalizeSubagentLifecycle({ - id: workerId, - session: worker.session, - aborted: true, - keepAlive: true, - isolated: false, - agentIdleTtlMs: 0, - reviveSession: null, - }); - - expect(worker.isDisposed()).toBe(true); - expect(AgentRegistry.global().get(workerId)).toMatchObject({ status: "aborted", session: null }); - expect(AgentRegistry.global().get(workerId)).toBe(ref); - expect(await fileExists(`${workerSessionFile}.tombstone`)).toBe(true); - }); - - it("keeps a persisted in-flight kill terminal when the old executor finalizes late", async () => { - let worker: ReturnType | undefined; - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - const artifactsDir = options.artifactsDir; - if (!artifactsDir) throw new Error("Persisted vibe test requires an artifacts directory"); - const childSessionFile = await persistWorkerSession({ - cwd: options.cwd, - artifactsDir, - id: options.id, - task: options.task, - }); - worker = createFakeWorkerSession(); - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: options.parentAgentId ?? "Main", - session: worker.session, - sessionFile: childSessionFile, - status: "running", - }); - const signal = options.signal; - if (!signal) throw new Error("Persisted blocked worker requires a cancellation signal"); - await new Promise(resolve => { - if (signal.aborted) resolve(); - else signal.addEventListener("abort", () => resolve(), { once: true }); - }); - return makeResult(options.id, { output: "Killed during work.", aborted: true }); - }); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - await registry.spawn(session, { cli: "fast", name: "persisted-kill", prompt: "Keep working." }); - await pollUntil(() => worker !== undefined); - - expect((await registry.kill(session, "persisted-kill")).cancelledTurn).toBe(true); - const terminal = AgentRegistry.global().get("persisted-kill"); - expect(terminal).toMatchObject({ status: "aborted", session: null }); - - await executorModule.finalizeSubagentLifecycle({ - id: "persisted-kill", - session: worker!.session, - aborted: true, - keepAlive: true, - isolated: false, - agentIdleTtlMs: 0, - reviveSession: null, - }); - expect(AgentRegistry.global().get("persisted-kill")).toBe(terminal); - expect(AgentRegistry.global().get("persisted-kill")).toMatchObject({ status: "aborted", session: null }); - await expect(AgentLifecycleManager.global().ensureLive("persisted-kill")).rejects.toThrow("cannot be revived"); - }); - - it("does not terminalize a same-path replacement installed while the killed worker disposes", async () => { - let worker: ReturnType | undefined; - let replacement: ReturnType | undefined; - let replacementWorker: ReturnType | undefined; - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - const artifactsDir = options.artifactsDir; - if (!artifactsDir) throw new Error("Persisted vibe test requires an artifacts directory"); - const childSessionFile = await persistWorkerSession({ - cwd: options.cwd, - artifactsDir, - id: options.id, - task: options.task, - }); - replacementWorker = createFakeWorkerSession(); - worker = createFakeWorkerSession({ - onDispose: () => { - replacement = AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: options.parentAgentId ?? "Main", - session: replacementWorker!.session, - sessionFile: childSessionFile, - status: "idle", - }); - }, - }); - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: options.parentAgentId ?? "Main", - session: worker.session, - sessionFile: childSessionFile, - status: "running", - }); - const signal = options.signal; - if (!signal) throw new Error("Persisted blocked worker requires a cancellation signal"); - await new Promise(resolve => { - if (signal.aborted) resolve(); - else signal.addEventListener("abort", () => resolve(), { once: true }); - }); - return makeResult(options.id, { output: "Old worker killed.", aborted: true }); - }); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - await registry.spawn(session, { cli: "fast", name: "same-path-replacement", prompt: "Keep working." }); - await pollUntil(() => worker !== undefined); - - expect((await registry.kill(session, "same-path-replacement")).cancelledTurn).toBe(true); - expect(replacement).toBeDefined(); - expect(AgentRegistry.global().get("same-path-replacement")).toBe(replacement); - expect(replacement).toMatchObject({ status: "idle", session: replacementWorker!.session }); - expect(replacementWorker!.isDisposed()).toBe(false); - }); - - it("persists a kill issued before child initialization and terminalizes the worker if it registers late", async () => { - // Handshake so kill() lands while the worker is mid-init, not before its - // job body has started executing: on a loaded runner the job may not have - // dispatched yet when spawn() returns, and kill would then observe no - // registration at all (the mock registers only after the abort lands), - // failing the registry assertion below. Resolving once the body is inside - // the abort-wait keeps the test exercising exactly the late-registration - // path it names. - const workerStarted = deferred(); - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - const artifactsDir = options.artifactsDir; - if (!artifactsDir) throw new Error("Persisted vibe test requires an artifacts directory"); - const signal = options.signal; - if (!signal) throw new Error("Pre-initialization worker requires a cancellation signal"); - workerStarted.resolve(); - await new Promise(resolve => { - if (signal.aborted) resolve(); - else signal.addEventListener("abort", () => resolve(), { once: true }); - }); - const childSessionFile = await persistWorkerSession({ - cwd: options.cwd, - artifactsDir, - id: options.id, - task: options.task, - }); - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: options.parentAgentId ?? "Main", - session: createFakeWorkerSession().session, - sessionFile: childSessionFile, - status: "idle", - }); - return makeResult(options.id, { output: "Killed before initialization.", aborted: true }); - }); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - await registry.spawn(session, { cli: "fast", name: "pre-init-kill", prompt: "Start later." }); - // Wait for the worker to be mid-init (inside the abort-wait) before - // issuing the kill — see the handshake comment at the top of the test. - await workerStarted.promise; - - expect((await registry.kill(session, "pre-init-kill")).cancelledTurn).toBe(true); - expect(AgentRegistry.global().get("pre-init-kill")).toMatchObject({ status: "aborted", session: null }); - expect( - parentManager.getEntries().some(entry => { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) return false; - const data = entry.data as Record; - return data.id === "pre-init-kill" && data.action === "tombstone"; - }), - ).toBe(true); - - await parentManager.close(); - await simulateProcessBoundary(); - const reopened = await SessionManager.open(parentSessionFile, undefined, undefined, { suppressBreadcrumb: true }); - persistedManagers.push(reopened); - const resumedSession = createSession({ manager: createManager(), sessionManager: reopened }); - expect(await VibeSessionRegistry.global().rehydrate(resumedSession)).toBe(0); - expect(AgentRegistry.global().get("pre-init-kill")).toMatchObject({ status: "aborted", session: null }); - }); - - it("killAll terminates every session for the owner (mode-exit path)", async () => { - const gates = new Map(); - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: "Main", - session: createFakeWorkerSession().session, - status: "running", - }); - const gate = deferred(); - gates.set(options.id, gate); - await gate.promise; - return makeResult(options.id); - }); - - const manager = createManager(); - const session = createSession({ manager }); - const registry = VibeSessionRegistry.global(); - const one = await registry.spawn(session, { cli: "fast", name: "One", prompt: "A." }); - const two = await registry.spawn(session, { cli: "good", name: "Two", prompt: "B." }); - await pollUntil(() => gates.size === 2); - - const killPromise = registry.killAll(session); - await pollUntil(() => manager.getJob(one.jobId)?.status === "cancelled"); - gates.get("One")!.resolve(); - await pollUntil(() => manager.getJob(two.jobId)?.status === "cancelled"); - gates.get("Two")!.resolve(); - const killed = await killPromise; - expect(killed).toBe(2); - expect(registry.listIds(session)).toEqual([]); - expect(AgentRegistry.global().get("One")).toBeUndefined(); - expect(AgentRegistry.global().get("Two")).toBeUndefined(); - }); -}); diff --git a/packages/hashline/src/fs.ts b/packages/hashline/src/fs.ts index bca93496a..6294fadc3 100644 --- a/packages/hashline/src/fs.ts +++ b/packages/hashline/src/fs.ts @@ -150,11 +150,11 @@ export class InMemoryFilesystem extends Filesystem { return { text: content }; } - async delete(path: string): Promise { + override async delete(path: string): Promise { if (!this.#files.delete(path)) throw new NotFoundError(path); } - async move(from: string, to: string, content?: string): Promise { + override async move(from: string, to: string, content?: string): Promise { const existing = this.#files.get(from); if (existing === undefined) throw new NotFoundError(from); const finalContent = content ?? existing; @@ -162,7 +162,7 @@ export class InMemoryFilesystem extends Filesystem { this.#files.delete(from); } - async exists(path: string): Promise { + override async exists(path: string): Promise { return this.#files.has(path); } @@ -199,7 +199,7 @@ export class NodeFilesystem extends Filesystem { return file.text(); } - async readBinary(path: string): Promise { + override async readBinary(path: string): Promise { try { return await fs.readFile(path); } catch (error) { @@ -213,7 +213,7 @@ export class NodeFilesystem extends Filesystem { return { text: content }; } - async delete(path: string): Promise { + override async delete(path: string): Promise { try { await fs.rm(path); } catch (error) { @@ -222,7 +222,7 @@ export class NodeFilesystem extends Filesystem { } } - async move(from: string, to: string, content?: string): Promise { + override async move(from: string, to: string, content?: string): Promise { if (content !== undefined) { await Bun.write(to, content); await this.delete(from); @@ -236,11 +236,11 @@ export class NodeFilesystem extends Filesystem { } } - canonicalPath(path: string): string { + override canonicalPath(path: string): string { return pathModule.resolve(path); } - async exists(path: string): Promise { + override async exists(path: string): Promise { return Bun.file(path).exists(); } } diff --git a/packages/hashline/src/snapshots.ts b/packages/hashline/src/snapshots.ts index 874433b2b..f755cfd93 100644 --- a/packages/hashline/src/snapshots.ts +++ b/packages/hashline/src/snapshots.ts @@ -180,7 +180,7 @@ export class InMemorySnapshotStore extends SnapshotStore { return history?.find(version => version.text === fullText) ?? null; } - findByHash(hash: string): Snapshot[] { + override findByHash(hash: string): Snapshot[] { const matches: Snapshot[] = []; for (const history of this.#versions.values()) { for (const version of history) { diff --git a/packages/hashline/test/core-contracts.test.ts b/packages/hashline/test/core-contracts.test.ts index 817469925..bdb343ccf 100644 --- a/packages/hashline/test/core-contracts.test.ts +++ b/packages/hashline/test/core-contracts.test.ts @@ -71,7 +71,7 @@ class BlockingFilesystem extends InMemoryFilesystem { for (const filePath of blocked) this.#blocked.add(filePath); } - async preflightWrite(filePath: string): Promise { + override async preflightWrite(filePath: string): Promise { if (this.#blocked.has(filePath)) throw new Error(`blocked write: ${filePath}`); } } diff --git a/packages/hashline/test/patcher.test.ts b/packages/hashline/test/patcher.test.ts index 67b8478d4..452d91c60 100644 --- a/packages/hashline/test/patcher.test.ts +++ b/packages/hashline/test/patcher.test.ts @@ -158,7 +158,7 @@ describe("Patcher snapshot tag integrity", () => { // call returned), but `writeText` echoes back a *reformatted* copy — spaces // turned into tabs, exactly the corruption reported against the ACP bridge. class DriftingFilesystem extends InMemoryFilesystem { - async writeText(path: string, content: string): Promise { + override async writeText(path: string, content: string): Promise { const drifted = content.replace(/^ {4}/gm, "\t"); await super.writeText(path, drifted); return { text: drifted }; diff --git a/packages/mnemopi/test/recovery.test.ts b/packages/mnemopi/test/recovery.test.ts index aff4ec8ee..d6c772f0f 100644 --- a/packages/mnemopi/test/recovery.test.ts +++ b/packages/mnemopi/test/recovery.test.ts @@ -52,7 +52,7 @@ function withFrozenNow(iso: string, fn: () => T): T { else super(value); } - static now(): number { + static override now(): number { return fixedMs; } } diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index 5f4c8f991..c9966b01f 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Changed + +- Bounded fuzzy-find scored-match retention to the top-K results (worst-first heap) instead of collecting and fully sorting every hit; ranking and totals are unchanged ([#7415](https://github.com/can1357/oh-my-pi/issues/7415)). + +### Fixed + +- Fixed newer OMP versions deleting a freshly created older native addon cache directory during concurrent startup, which could interrupt extraction with `ENOENT`. + ## [17.2.7] - 2026-08-03 ### Added diff --git a/packages/natives/native/loader-state.d.ts b/packages/natives/native/loader-state.d.ts index 63a042cd7..e61d2da21 100644 --- a/packages/natives/native/loader-state.d.ts +++ b/packages/natives/native/loader-state.d.ts @@ -88,6 +88,8 @@ export interface CleanupStaleNativeVersionsInput { export function cleanupStaleNativeVersions(input: CleanupStaleNativeVersionsInput): string[]; +export function prepareNativeVersionDir(versionedDir: string): void; + export interface ExtractEmbeddedAddonArchiveInput { archivePath: string; files: EmbeddedAddonFile[]; diff --git a/packages/natives/native/loader-state.js b/packages/natives/native/loader-state.js index e77ff2ea1..385aed862 100644 --- a/packages/natives/native/loader-state.js +++ b/packages/natives/native/loader-state.js @@ -196,6 +196,26 @@ function isOlderReleaseVersion(candidate, current) { return false; } +// A concurrently starting older OMP binary creates or refreshes this directory +// before extracting its addon. Keep fresh directories long enough for that +// startup to finish; a later launch can reclaim them once they are genuinely +// stale. +const NATIVE_CACHE_CLEANUP_GRACE_MS = 10 * 60_000; + +/** + * Create a version cache directory and refresh its activity timestamp before + * extraction or staging begins. Recursive mkdir does not update the mtime of + * an existing directory, so the explicit touch is what protects interrupted + * or partially populated caches from concurrent cleanup. + * + * @param {string} versionedDir + */ +export function prepareNativeVersionDir(versionedDir) { + fs.mkdirSync(versionedDir, { recursive: true }); + const now = new Date(); + fs.utimesSync(versionedDir, now, now); +} + /** * Remove version-pinned native cache directories older than the loaded package. * Best-effort by design: permission errors and concurrent processes must not @@ -217,6 +237,8 @@ export function cleanupStaleNativeVersions({ nativesDir, currentVersion }) { if (!entry.isDirectory() || !isOlderReleaseVersion(entry.name, currentVersion)) continue; const targetPath = path.join(nativesDir, entry.name); try { + const stat = fs.statSync(targetPath); + if (Date.now() - stat.mtimeMs < NATIVE_CACHE_CLEANUP_GRACE_MS) continue; fs.rmSync(targetPath, { recursive: true, force: true }); removed.push(targetPath); } catch { @@ -514,7 +536,7 @@ function maybeExtractEmbeddedAddon(ctx, errors) { startupMarker("native:extractEmbeddedAddon:start"); try { - fs.mkdirSync(ctx.versionedDir, { recursive: true }); + prepareNativeVersionDir(ctx.versionedDir); } catch (err) { const message = err instanceof Error ? err.message : String(err); errors.push(`embedded addon dir: ${message}`); @@ -581,7 +603,7 @@ function maybeStageNodeModulesAddon(ctx, errors) { if (!fs.existsSync(sourcePath)) continue; try { - fs.mkdirSync(ctx.versionedDir, { recursive: true }); + prepareNativeVersionDir(ctx.versionedDir); } catch (err) { const message = err instanceof Error ? err.message : String(err); errors.push(`staged addon dir: ${message}`); diff --git a/packages/natives/test/windows-staging.test.ts b/packages/natives/test/windows-staging.test.ts index 01be98647..a951ba5b7 100644 --- a/packages/natives/test/windows-staging.test.ts +++ b/packages/natives/test/windows-staging.test.ts @@ -27,6 +27,7 @@ import { cleanupStaleNativeVersions, getAddonFilenames, initLoaderContext, + prepareNativeVersionDir, resolveLoaderCandidates, shouldStageNodeModulesAddon, } from "../native/loader-state.js"; @@ -195,18 +196,24 @@ describe("windows native addon staging", () => { const nativesDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-natives-cache-")); const currentMajor = Number.parseInt(packageJson.version, 10); const futureVersion = `${currentMajor + 1}.0.0`; + const staleVersion = "15.10.11"; + const freshVersion = "15.10.12"; try { - await fs.mkdir(path.join(nativesDir, "15.10.11")); + await fs.mkdir(path.join(nativesDir, staleVersion)); + await fs.mkdir(path.join(nativesDir, freshVersion)); await fs.mkdir(path.join(nativesDir, packageJson.version)); await fs.mkdir(path.join(nativesDir, futureVersion)); await fs.mkdir(path.join(nativesDir, "not-a-version")); await Bun.write(path.join(nativesDir, "README.txt"), "not a version directory"); + await fs.utimes(path.join(nativesDir, staleVersion), new Date(0), new Date(0)); + await fs.utimes(path.join(nativesDir, freshVersion), new Date(0), new Date(0)); + prepareNativeVersionDir(path.join(nativesDir, freshVersion)); const removed = cleanupStaleNativeVersions({ nativesDir, currentVersion: packageJson.version }); - expect(removed.map(filePath => path.basename(filePath))).toEqual(["15.10.11"]); + expect(removed.map(filePath => path.basename(filePath))).toEqual([staleVersion]); expect((await fs.readdir(nativesDir)).sort()).toEqual( - ["README.txt", packageJson.version, futureVersion, "not-a-version"].sort(), + ["README.txt", freshVersion, packageJson.version, futureVersion, "not-a-version"].sort(), ); } finally { await fs.rm(nativesDir, { recursive: true, force: true }); diff --git a/packages/omptype/CHANGELOG.md b/packages/omptype/CHANGELOG.md index 9c6e03621..555d1af30 100644 --- a/packages/omptype/CHANGELOG.md +++ b/packages/omptype/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed the TypeBox adapter emitting an invalid left-bound-only DSL for min-only numeric schemas (e.g. `Type.Integer({ minimum: 1 })`), which threw `left bound requires a corresponding right bound` and broke extension tool loading ([#7648](https://github.com/can1357/oh-my-pi/issues/7648)). + ## [17.2.8] - 2026-08-04 ### Added diff --git a/packages/omptype/src/typebox.ts b/packages/omptype/src/typebox.ts index 2654f5372..7e252a556 100644 --- a/packages/omptype/src/typebox.ts +++ b/packages/omptype/src/typebox.ts @@ -267,9 +267,19 @@ function tNumber(opts?: NumberOpts, integer = false): TNumber { upper = { value: opts.exclusiveMaximum, exclusive: true }; } const keyword = integer ? "number.integer" : "number"; - const lowerDsl = lower ? `${lower.value} ${lower.exclusive ? "<" : "<="} ` : ""; - const upperDsl = upper ? ` ${upper.exclusive ? "<" : "<="} ${upper.value}` : ""; - let schema = asRuntime(type.raw(`${lowerDsl}${keyword}${upperDsl}`)); + // The `LO <= TYPE <= HI` range spelling requires both bounds; a min-only + // bound must use the postfix `TYPE >= LO` form (see parseBounded in ir.ts). + let src: string; + if (lower && upper) { + src = `${lower.value} ${lower.exclusive ? "<" : "<="} ${keyword} ${upper.exclusive ? "<" : "<="} ${upper.value}`; + } else if (lower) { + src = `${keyword} ${lower.exclusive ? ">" : ">="} ${lower.value}`; + } else if (upper) { + src = `${keyword} ${upper.exclusive ? "<" : "<="} ${upper.value}`; + } else { + src = keyword; + } + let schema = asRuntime(type.raw(src)); if (opts?.multipleOf !== undefined) { const divisor = opts.multipleOf; schema = schema.narrow((value, ctx) => { diff --git a/packages/omptype/test/typebox.test.ts b/packages/omptype/test/typebox.test.ts index f41e7911f..0ded2d33f 100644 --- a/packages/omptype/test/typebox.test.ts +++ b/packages/omptype/test/typebox.test.ts @@ -56,6 +56,22 @@ describe("TypeBox adapter", () => { expect(valid(exclusive, 1)).toBe(false); expect(valid(exclusive, 3)).toBe(false); expect(exclusive.toJsonSchema()).toEqual({ type: "number", exclusiveMinimum: 1, exclusiveMaximum: 3 }); + + const minOnly = Type.Integer({ minimum: 1 }); + expect(valid(minOnly, 1)).toBe(true); + expect(valid(minOnly, 0)).toBe(false); + expect(valid(minOnly, 1.5)).toBe(false); + expect(minOnly.toJsonSchema()).toEqual({ type: "integer", minimum: 1 }); + const exclusiveMinOnly = Type.Number({ exclusiveMinimum: 0 }); + expect(valid(exclusiveMinOnly, 0)).toBe(false); + expect(valid(exclusiveMinOnly, 0.5)).toBe(true); + const maxOnly = Type.Number({ maximum: 5 }); + expect(valid(maxOnly, 5)).toBe(true); + expect(valid(maxOnly, 6)).toBe(false); + const minWithMultiple = Type.Integer({ minimum: 1, multipleOf: 2 }); + expect(valid(minWithMultiple, 4)).toBe(true); + expect(valid(minWithMultiple, 3)).toBe(false); + expect(valid(minWithMultiple, 0)).toBe(false); }); test("arrays, tuples, objects, records and intersections validate", () => { diff --git a/packages/stats/CHANGELOG.md b/packages/stats/CHANGELOG.md index 74d8616a2..85d43e7ae 100644 --- a/packages/stats/CHANGELOG.md +++ b/packages/stats/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Restricted the stats dashboard to IPv4 loopback and removed wildcard CORS access to its API ([#7633](https://github.com/can1357/oh-my-pi/issues/7633)). + ## [17.2.4] - 2026-08-01 ### Fixed diff --git a/packages/stats/src/index.ts b/packages/stats/src/index.ts index d74c4080b..5894f73fb 100755 --- a/packages/stats/src/index.ts +++ b/packages/stats/src/index.ts @@ -171,8 +171,8 @@ Examples: // Start server const port = parseInt(values.port || "3847", 10); - const { port: actualPort } = await startServer(port); - console.log(`Dashboard available at: http://localhost:${actualPort}`); + const { hostname, port: actualPort } = await startServer(port); + console.log(`Dashboard available at: http://${hostname}:${actualPort}`); console.log("Press Ctrl+C to stop\n"); // Keep process running diff --git a/packages/stats/src/port-conflict.ts b/packages/stats/src/port-conflict.ts index c49045d64..74b2273a1 100644 --- a/packages/stats/src/port-conflict.ts +++ b/packages/stats/src/port-conflict.ts @@ -18,30 +18,27 @@ interface PortHolder { /** Header stamped on every dashboard response so reuse probes can identify us. */ export const STATS_DASHBOARD_HEADER = "x-omp-stats-dashboard"; -async function probeStatsDashboard(port: number): Promise { +/** Identity-header value for dashboards enforcing loopback-only, same-origin access. */ +export const STATS_DASHBOARD_SECURITY_VERSION = "2"; + +/** IPv4 loopback address shared by the dashboard server and reuse probe. */ +export const STATS_DASHBOARD_HOSTNAME = "127.0.0.1"; + +type StatsDashboardProbe = "reusable" | "occupied" | "unreachable"; + +async function probeStatsDashboard(port: number): Promise { try { - const response = await fetch(`http://localhost:${port}/api/stats/models`, { + const response = await fetch(`http://${STATS_DASHBOARD_HOSTNAME}:${port}/api/stats/models`, { signal: AbortSignal.timeout(STATS_PROBE_TIMEOUT_MS), }); - if (response.status !== 200) { - await response.body?.cancel(); - return false; - } - // A live omp-stats dashboard stamps this header on every response. - if (response.headers.get(STATS_DASHBOARD_HEADER)) { - await response.body?.cancel(); - return true; - } - // Older dashboards predate the header; fall back to the response shape - // (`/api/stats/models` returns a JSON array) so we never reuse — or later - // kill — a foreign 200 responder such as an SPA dev server catch-all. - if (!(response.headers.get("content-type") ?? "").includes("application/json")) { - await response.body?.cancel(); - return false; - } - return Array.isArray(await response.json()); + const reusable = + response.status === 200 && + response.headers.get(STATS_DASHBOARD_HEADER) === STATS_DASHBOARD_SECURITY_VERSION && + !response.headers.has("Access-Control-Allow-Origin"); + await response.body?.cancel(); + return reusable ? "reusable" : "occupied"; } catch { - return false; + return "unreachable"; } } @@ -216,10 +213,7 @@ async function terminatePortHolder(holder: PortHolder): Promise { await Bun.sleep(PROCESS_EXIT_POLL_MS); } -/** Reuse a live stats dashboard or reclaim the port from a stale omp runtime. */ -export async function recoverStatsPort(port: number): Promise<"retry" | "reuse"> { - if (await probeStatsDashboard(port)) return "reuse"; - +async function reclaimStatsPort(port: number): Promise<"retry"> { const holder = await findPortHolder(port); if (!holder) { throw new Error(`Port ${port} is in use, but the listening process could not be identified.`); @@ -248,3 +242,22 @@ export async function recoverStatsPort(port: number): Promise<"retry" | "reuse"> await terminatePortHolder(holder); return "retry"; } + +/** + * Reuse a secure dashboard or reclaim an insecure HTTP dashboard before binding. + * The preflight is needed on platforms that permit wildcard and loopback-specific + * listeners to coexist on one port. + */ +export async function prepareStatsPort(port: number): Promise<"retry" | "reuse"> { + if (port === 0) return "retry"; + const probe = await probeStatsDashboard(port); + if (probe === "reusable") return "reuse"; + if (probe === "occupied") return reclaimStatsPort(port); + return "retry"; +} + +/** Reuse or reclaim a listener found after the server bind reports EADDRINUSE. */ +export async function recoverStatsPort(port: number): Promise<"retry" | "reuse"> { + if ((await probeStatsDashboard(port)) === "reusable") return "reuse"; + return reclaimStatsPort(port); +} diff --git a/packages/stats/src/server.ts b/packages/stats/src/server.ts index 8349a868d..33dd1097d 100644 --- a/packages/stats/src/server.ts +++ b/packages/stats/src/server.ts @@ -21,7 +21,13 @@ import { import { decodeEmbeddedClientArchive } from "./embedded-client"; import embeddedClientArchiveTxt from "./embedded-client.generated.txt"; import { getGainDashboardStats } from "./gain-aggregator"; -import { recoverStatsPort, STATS_DASHBOARD_HEADER } from "./port-conflict"; +import { + prepareStatsPort, + recoverStatsPort, + STATS_DASHBOARD_HEADER, + STATS_DASHBOARD_HOSTNAME, + STATS_DASHBOARD_SECURITY_VERSION, +} from "./port-conflict"; const EMBEDDED_CLIENT_ARCHIVE = decodeEmbeddedClientArchive(embeddedClientArchiveTxt); @@ -303,21 +309,19 @@ async function handleStatic(requestPath: string): Promise { function createDashboardServer(port: number) { const server = Bun.serve({ port, + hostname: STATS_DASHBOARD_HOSTNAME, async fetch(req) { const url = new URL(req.url); const path = url.pathname; - // CORS headers for local development; the identity header lets another - // omp session's reuse probe positively recognize this dashboard. - const corsHeaders: Record = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", - [STATS_DASHBOARD_HEADER]: "1", + // The identity header lets another omp session's reuse probe positively + // recognize this dashboard without allowing cross-origin API reads. + const dashboardHeaders: Record = { + [STATS_DASHBOARD_HEADER]: STATS_DASHBOARD_SECURITY_VERSION, }; if (req.method === "OPTIONS") { - return new Response(null, { headers: corsHeaders }); + return new Response(null, { headers: dashboardHeaders }); } try { @@ -329,10 +333,10 @@ function createDashboardServer(port: number) { response = await handleStatic(path); } - // Add CORS headers to all responses + // Add the dashboard identity header to all responses. const headers = new Headers(response.headers); - for (const key in corsHeaders) { - headers.set(key, corsHeaders[key]); + for (const key in dashboardHeaders) { + headers.set(key, dashboardHeaders[key]); } return new Response(response.body, { @@ -343,7 +347,7 @@ function createDashboardServer(port: number) { console.error("Server error:", error); return Response.json( { error: error instanceof Error ? error.message : "Unknown error" }, - { status: 500, headers: corsHeaders }, + { status: 500, headers: dashboardHeaders }, ); } }, @@ -354,12 +358,17 @@ function createDashboardServer(port: number) { /** * Start the HTTP server, reusing a live dashboard or reclaiming a stale omp listener. */ -export async function startServer(port = 3847): Promise<{ port: number; stop: () => void }> { +export async function startServer(port = 3847): Promise<{ hostname: string; port: number; stop: () => void }> { await ensureClientBuild(); + const preparation = await prepareStatsPort(port); + if (preparation === "reuse") { + return { hostname: STATS_DASHBOARD_HOSTNAME, port, stop: () => {} }; + } try { const server = createDashboardServer(port); return { + hostname: STATS_DASHBOARD_HOSTNAME, port: server.port ?? port, stop: () => server.stop(), }; @@ -368,12 +377,13 @@ export async function startServer(port = 3847): Promise<{ port: number; stop: () const recovery = await recoverStatsPort(port); if (recovery === "reuse") { - return { port, stop: () => {} }; + return { hostname: STATS_DASHBOARD_HOSTNAME, port, stop: () => {} }; } try { const server = createDashboardServer(port); return { + hostname: STATS_DASHBOARD_HOSTNAME, port: server.port ?? port, stop: () => server.stop(), }; diff --git a/packages/stats/test/server-port-conflict.test.ts b/packages/stats/test/server-port-conflict.test.ts index 771953bdd..ae92163bc 100644 --- a/packages/stats/test/server-port-conflict.test.ts +++ b/packages/stats/test/server-port-conflict.test.ts @@ -1,22 +1,44 @@ import { afterEach, describe, expect, it } from "bun:test"; -import type { Subprocess } from "bun"; -import { STATS_DASHBOARD_HEADER } from "../src/port-conflict"; +import { networkInterfaces } from "node:os"; +import { connect, type Subprocess } from "bun"; +import { + STATS_DASHBOARD_HEADER, + STATS_DASHBOARD_HOSTNAME, + STATS_DASHBOARD_SECURITY_VERSION, +} from "../src/port-conflict"; import { startServer } from "../src/server"; +import { installStatsTestIsolation } from "./helpers/temp-agent"; + +installStatsTestIsolation("@pi-stats-port-conflict-"); + +/** + * Directly probe a TCP endpoint, bypassing any configured HTTP proxy so the + * loopback-only bind is asserted against the real listener rather than a proxy + * response. Resolves true when the connection is accepted, false when refused. + */ +async function tcpConnects(hostname: string, port: number): Promise { + try { + const socket = await connect({ hostname, port, socket: { data() {}, open() {}, close() {}, error() {} } }); + socket.end(); + return true; + } catch { + return false; + } +} const holderProcesses: Array> = []; -async function startBunHolder(responseExpr: string, options?: { statsOwned?: boolean }) { - // Bind the wildcard address: `startServer` binds the wildcard too, and on - // macOS SO_REUSEADDR lets a wildcard bind coexist with a 127.0.0.1-only - // listener, which would bypass the EADDRINUSE path this suite exercises. +async function startBunHolder(responseExpr: string, options?: { hostname?: string; statsOwned?: boolean }) { + const hostname = options?.hostname ?? STATS_DASHBOARD_HOSTNAME; const reservation = Bun.serve({ port: 0, + hostname: STATS_DASHBOARD_HOSTNAME, fetch: () => new Response("reserved"), }); const port = reservation.port; reservation.stop(true); - const source = `Bun.serve({ port: ${port}, fetch: () => ${responseExpr} }); process.stdout.write("ready"); await Promise.withResolvers().promise;`; + const source = `Bun.serve({ port: ${port}, hostname: "${hostname}", fetch: () => ${responseExpr} }); process.stdout.write("ready"); await Promise.withResolvers().promise;`; const args = [process.execPath, "-e", source]; if (options?.statsOwned) args.push("omp-stats"); const child = Bun.spawn(args, { @@ -46,13 +68,49 @@ afterEach(async () => { holderProcesses.length = 0; }); +describe("startServer access", () => { + it("only serves loopback requests without cross-origin access", async () => { + const server = await startServer(0); + + try { + expect(server.hostname).toBe(STATS_DASHBOARD_HOSTNAME); + const response = await fetch(`http://${server.hostname}:${server.port}/api/stats/models`); + expect(response.status).toBe(200); + expect(response.headers.get(STATS_DASHBOARD_HEADER)).toBe(STATS_DASHBOARD_SECURITY_VERSION); + expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull(); + await response.body?.cancel(); + + let nonLoopbackHostname: string | undefined; + const interfaces = networkInterfaces(); + for (const name in interfaces) { + const addresses = interfaces[name] ?? []; + for (const address of addresses) { + if (address.family === "IPv4" && !address.internal) { + nonLoopbackHostname = address.address; + break; + } + } + if (nonLoopbackHostname) break; + } + expect(nonLoopbackHostname).toBeDefined(); + expect(await tcpConnects(server.hostname, server.port)).toBe(true); + if (nonLoopbackHostname) { + expect(await tcpConnects(nonLoopbackHostname, server.port)).toBe(false); + } + } finally { + server.stop(); + } + }); +}); + describe("startServer port conflicts", () => { it("reuses a live stats dashboard identified by its header", async () => { const existing = Bun.serve({ port: 0, + hostname: STATS_DASHBOARD_HOSTNAME, fetch: request => new URL(request.url).pathname === "/api/stats/models" - ? Response.json([], { headers: { [STATS_DASHBOARD_HEADER]: "1" } }) + ? Response.json([], { headers: { [STATS_DASHBOARD_HEADER]: STATS_DASHBOARD_SECURITY_VERSION } }) : new Response("dashboard"), }); @@ -62,21 +120,52 @@ describe("startServer port conflicts", () => { server.stop(); // The existing dashboard is untouched: it still answers on the port. - const response = await fetch(`http://127.0.0.1:${existing.port}/api/stats/models`); + const response = await fetch(`http://${STATS_DASHBOARD_HOSTNAME}:${existing.port}/api/stats/models`); expect(response.status).toBe(200); - expect(response.headers.get(STATS_DASHBOARD_HEADER)).toBe("1"); + expect(response.headers.get(STATS_DASHBOARD_HEADER)).toBe(STATS_DASHBOARD_SECURITY_VERSION); await response.body?.cancel(); } finally { existing.stop(true); } }); + for (const fixture of [ + { + name: "reclaims a version 1 dashboard with wildcard CORS", + response: `Response.json([], { headers: { "${STATS_DASHBOARD_HEADER}": "1", "Access-Control-Allow-Origin": "*" } })`, + hostname: "0.0.0.0", + }, + { + name: "reclaims a headerless legacy dashboard", + response: "Response.json([])", + hostname: STATS_DASHBOARD_HOSTNAME, + }, + ]) { + it(fixture.name, async () => { + const holder = await startBunHolder(fixture.response, { + hostname: fixture.hostname, + statsOwned: true, + }); + const server = await startServer(holder.port); + + try { + expect(await holder.child.exited).not.toBe(0); + const response = await fetch(`http://${STATS_DASHBOARD_HOSTNAME}:${server.port}/api/stats/models`); + expect(response.headers.get(STATS_DASHBOARD_HEADER)).toBe(STATS_DASHBOARD_SECURITY_VERSION); + expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull(); + await response.body?.cancel(); + } finally { + server.stop(); + } + }); + } + it("refuses to stop a foreign 200 responder", async () => { const holder = await startBunHolder('Response.json({ app: "spa" })'); await expect(startServer(holder.port)).rejects.toThrow("not identifiable as an omp stats dashboard"); expect(holder.child.exitCode).toBeNull(); - const response = await fetch(`http://127.0.0.1:${holder.port}/api/stats/models`); + const response = await fetch(`http://${STATS_DASHBOARD_HOSTNAME}:${holder.port}/api/stats/models`); expect(await response.json()).toEqual({ app: "spa" }); }); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index a3da37fb2..603cdd528 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +### Fixed + +- Fixed table borders (and adjacent cells) inheriting an open inline-code color when a cell's content wraps mid-code-span, by terminating each wrapped cell line's SGR state before the border glyphs ([#7575](https://github.com/can1357/oh-my-pi/issues/7575)). +- Fixed inline images not rendering under WSL + Windows Terminal: the SIXEL capability probe gated on `process.platform === "win32"`, but WSL reports `linux`, so the probe never ran and images fell back to the text placeholder even on Sixel-capable Windows Terminal. The probe now runs on any ConPTY host (native win32 or WSL) ([#6009](https://github.com/can1357/oh-my-pi/issues/6009)). + ## [17.2.5] - 2026-08-03 ### Fixed diff --git a/packages/tui/src/components/cancellable-loader.ts b/packages/tui/src/components/cancellable-loader.ts index c82bfd574..447f053ae 100644 --- a/packages/tui/src/components/cancellable-loader.ts +++ b/packages/tui/src/components/cancellable-loader.ts @@ -34,7 +34,7 @@ export class CancellableLoader extends Loader { } } - dispose(): void { + override dispose(): void { this.stop(); } } diff --git a/packages/tui/src/components/loader.ts b/packages/tui/src/components/loader.ts index 935b3cb49..8c5c0daaf 100644 --- a/packages/tui/src/components/loader.ts +++ b/packages/tui/src/components/loader.ts @@ -55,7 +55,7 @@ export class Loader extends Text { this.start(); } - render(width: number): readonly string[] { + override render(width: number): readonly string[] { const source = super.render(width); if (source !== this.#layoutSource) { const paddingX = getPaddingX(1); diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index a5dec34bd..64ee150fc 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -804,11 +804,11 @@ markdownParser.use({ // (no `m` flag), and stickiness only removes the futile later attempts. The // flags/anchor guard below skips any rule a future marked version changes. class AnchoredAtZero extends RegExp { - exec(str: string): RegExpExecArray | null { + override exec(str: string): RegExpExecArray | null { this.lastIndex = 0; // sticky matches set lastIndex; rules are shared return super.exec(str); } - test(str: string): boolean { + override test(str: string): boolean { this.lastIndex = 0; return super.test(str); } @@ -2816,7 +2816,13 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ while (wrapped.length > 1 && wrapped[wrapped.length - 1] === "") { wrapped.pop(); } - return wrapped; + // The native wrap deliberately leaves fg color and bold/italic open at + // line ends so continuation lines can re-open them. Table rows splice + // every cell line between unstyled border glyphs, so an open style + // (e.g. mdCode) would bleed into the "│" and the following cells. + // Terminate each line at default fg, clearing bold/italic but keeping + // any ambient background (message-bg rendering) intact. + return wrapped.map(line => `${line}\x1b[22m\x1b[23m\x1b[39m`); } /** diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 7b309b8af..22c4cd8e2 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -1670,7 +1670,10 @@ export class TUI extends Container { #querySixelSupport(): void { if (TERMINAL.imageProtocol) return; - if (process.platform !== "win32") return; + // win32 native or WSL under Windows Terminal — both are ConPTY-hosted and + // reach the same WT graphics negotiation. WSL reports process.platform + // "linux", so a bare win32 check silently skips the probe there (#6009). + if (!isConPTYHosted()) return; if (!Bun.env.WT_SESSION) return; if (!process.stdin.isTTY || !process.stdout.isTTY) return; diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index 709ba234f..35057dfa9 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -466,6 +466,56 @@ describe("Markdown component", () => { } }); + it("does not leak inline-code color into table borders when cells wrap", () => { + const markdown = new Markdown( + `| Command | Notes | +| --- | --- | +| \`config.setupgrading(pendingRequests, emptyFlag)\` | plain | +| short | other |`, + 0, + 0, + defaultMarkdownTheme, + ); + + // Narrow enough to force the long codespan to wrap mid-run. + const lines = markdown.render(24); + const joinedOutput = lines.join("\n"); + expect(joinedOutput.includes("\x1b[33m"), "Inline code should be styled (yellow)").toBeTruthy(); + expect(lines.filter(line => line.includes("|")).length).toBeGreaterThan(3); + + // Walk SGR state through every table row: the "|" border glyphs (and + // everything after them on the line) must never be rendered under an + // open fg color or bold attribute. + for (const line of lines) { + if (!line.includes("|")) continue; + let bold = false; + let fgOpen = false; + let i = 0; + while (i < line.length) { + if (line[i] === "\x1b") { + const seq = line.slice(i).match(/^\x1b\[([0-9;]*)m/); + expect(seq, `unparseable SGR in: ${JSON.stringify(line)}`).not.toBeNull(); + for (const p of seq![1]!.split(";")) { + if (p === "1") bold = true; + else if (p === "22") bold = false; + else if (p === "0") { + bold = false; + fgOpen = false; + } else if (p === "39") fgOpen = false; + else if (p === "38" || /^3[0-7]$/.test(p) || /^9[0-7]$/.test(p)) fgOpen = true; + } + i += seq![0].length; + continue; + } + if (line[i] === "|") { + expect(fgOpen, `Border inherits fg color in: ${JSON.stringify(line)}`).toBe(false); + expect(bold, `Border inherits bold in: ${JSON.stringify(line)}`).toBe(false); + } + i++; + } + } + }); + it("should handle extremely narrow width gracefully", () => { const markdown = new Markdown( `| A | B | C | diff --git a/packages/tui/test/render-regressions.test.ts b/packages/tui/test/render-regressions.test.ts index 364b1b660..6ec257d96 100644 --- a/packages/tui/test/render-regressions.test.ts +++ b/packages/tui/test/render-regressions.test.ts @@ -87,7 +87,7 @@ class WrappingLinesComponent implements Component { } class UnknownViewportTerminal extends VirtualTerminal { - isNativeViewportAtBottom(): undefined { + override isNativeViewportAtBottom(): undefined { return undefined; } } @@ -96,7 +96,7 @@ class StaleBottomViewportTerminal extends VirtualTerminal { #previous: boolean | undefined; #returnStale = false; - isNativeViewportAtBottom(): boolean | undefined { + override isNativeViewportAtBottom(): boolean | undefined { const current = super.isNativeViewportAtBottom(); if (this.#returnStale) { this.#returnStale = false; @@ -113,18 +113,18 @@ class StaleBottomViewportTerminal extends VirtualTerminal { class CountingViewportTerminal extends VirtualTerminal { viewportProbeCount = 0; - isNativeViewportAtBottom(): boolean | undefined { + override isNativeViewportAtBottom(): boolean | undefined { this.viewportProbeCount += 1; return super.isNativeViewportAtBottom(); } } class LegacyKeyboardVirtualTerminal extends VirtualTerminal { - get keyboardEnhancementEnterSequence(): string | null { + override get keyboardEnhancementEnterSequence(): string | null { return undefined as unknown as string | null; } - get keyboardEnhancementExitSequence(): string | null { + override get keyboardEnhancementExitSequence(): string | null { return undefined as unknown as string | null; } } diff --git a/packages/tui/test/render-stress-harness.ts b/packages/tui/test/render-stress-harness.ts index 7962af8e2..cd64b950a 100644 --- a/packages/tui/test/render-stress-harness.ts +++ b/packages/tui/test/render-stress-harness.ts @@ -425,7 +425,7 @@ const BURST_STEP_METADATA = { } satisfies Record; class UnknownViewportTerminal extends VirtualTerminal { - isNativeViewportAtBottom(): undefined { + override isNativeViewportAtBottom(): undefined { return undefined; } } @@ -433,7 +433,7 @@ class UnknownViewportTerminal extends VirtualTerminal { class IntermittentUnknownViewportTerminal extends VirtualTerminal { #probeCount = 0; - isNativeViewportAtBottom(): boolean | undefined { + override isNativeViewportAtBottom(): boolean | undefined { this.#probeCount += 1; return this.#probeCount % 3 === 0 ? undefined : super.isNativeViewportAtBottom(); } @@ -443,7 +443,7 @@ class StaleBottomTerminal extends VirtualTerminal { #previous: boolean | undefined; #returnStale = false; - isNativeViewportAtBottom(): boolean | undefined { + override isNativeViewportAtBottom(): boolean | undefined { const current = super.isNativeViewportAtBottom(); if (this.#returnStale) { this.#returnStale = false; diff --git a/packages/tui/test/sixel-probe.test.ts b/packages/tui/test/sixel-probe.test.ts index 8c532908a..45767aeeb 100644 --- a/packages/tui/test/sixel-probe.test.ts +++ b/packages/tui/test/sixel-probe.test.ts @@ -9,6 +9,8 @@ type MutableTerminalInfo = { const terminalInfo = TERMINAL as unknown as MutableTerminalInfo; const originalProtocol = TERMINAL.imageProtocol; const originalWtSession = Bun.env.WT_SESSION; +const originalWslDistro = Bun.env.WSL_DISTRO_NAME; +const originalWslInterop = Bun.env.WSL_INTEROP; const stdinIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); const stdoutIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); @@ -29,6 +31,10 @@ describe("TUI SIXEL capability probe", () => { terminalInfo.imageProtocol = originalProtocol; if (originalWtSession === undefined) delete Bun.env.WT_SESSION; else Bun.env.WT_SESSION = originalWtSession; + if (originalWslDistro === undefined) delete Bun.env.WSL_DISTRO_NAME; + else Bun.env.WSL_DISTRO_NAME = originalWslDistro; + if (originalWslInterop === undefined) delete Bun.env.WSL_INTEROP; + else Bun.env.WSL_INTEROP = originalWslInterop; restoreIsTty(process.stdin, stdinIsTtyDescriptor); restoreIsTty(process.stdout, stdoutIsTtyDescriptor); }); @@ -102,4 +108,27 @@ describe("TUI SIXEL capability probe", () => { expect(TERMINAL.imageProtocol).toBeNull(); tui.stop(); }); + + it("enables SIXEL under WSL + Windows Terminal (process.platform is linux)", () => { + // Regression for #6009: inside WSL, process.platform reports "linux" even + // though the host is Windows Terminal. The probe used to gate on + // process.platform === "win32", so WSL sessions never negotiated SIXEL and + // fell back to the text image card. It now gates on isConPTYHosted(), which + // treats WSL (WSL_DISTRO_NAME/WSL_INTEROP) as a Windows host. + if (process.platform !== "linux") return; + setTerminalImageProtocol(null); + terminalInfo.imageProtocol = null; + Bun.env.WT_SESSION = "test-wt-session"; + Bun.env.WSL_DISTRO_NAME = "Ubuntu"; + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + tui.start(); + terminal.sendInput("\x1b[?1;2;4c"); + + expect(TERMINAL.imageProtocol).toBe(ImageProtocol.Sixel); + tui.stop(); + }); }); diff --git a/packages/typescript-edit-benchmark/src/mutations.ts b/packages/typescript-edit-benchmark/src/mutations.ts index 27d308e55..9f1fecb96 100644 --- a/packages/typescript-edit-benchmark/src/mutations.ts +++ b/packages/typescript-edit-benchmark/src/mutations.ts @@ -489,7 +489,7 @@ class CallArgumentSwapMutation extends BaseAstMutation { return out; } - mutate(content: string, rng: () => number): [string, MutationInfo] { + override mutate(content: string, rng: () => number): [string, MutationInfo] { const parsed = parseCode(content); if (!parsed) return [content, noopInfo()]; const candidates = this.collectCandidates(parsed); @@ -722,7 +722,7 @@ class IdentifierMultiEditMutation extends BaseAstMutation { return out; } - mutate(content: string, rng: () => number): [string, MutationInfo] { + override mutate(content: string, rng: () => number): [string, MutationInfo] { const parsed = parseCode(content); if (!parsed) return [content, noopInfo()]; const candidates = this.collectCandidates(parsed); @@ -1036,7 +1036,7 @@ class SwapAdjacentLinesMutation extends BaseAstMutation { return out; } - mutate(content: string, rng: () => number): [string, MutationInfo] { + override mutate(content: string, rng: () => number): [string, MutationInfo] { const parsed = parseCode(content); if (!parsed) return [content, noopInfo()]; const candidates = this.collectCandidates(parsed); diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index c195b1d2c..f970f939f 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +### Added + +- Added a public `compareVersions` utility (`@oh-my-pi/pi-utils`) that compares two version strings with SemVer-2.0 prerelease ordering, build-metadata stripping, and numeric segment comparison without float overflow; never throws. + +### Fixed + +- Honor the current process `PATH` when caching executable lookups, preventing stale tool paths after environment reloads. +- Parsed account-cap reset windows such as “Your limit will reset in 13 minutes” so credential backoff honors the provider's full reset duration. + ## [17.2.6] - 2026-08-03 ### Added diff --git a/packages/utils/src/fetch-retry.ts b/packages/utils/src/fetch-retry.ts index afe7138e3..51263af31 100644 --- a/packages/utils/src/fetch-retry.ts +++ b/packages/utils/src/fetch-retry.ts @@ -10,6 +10,8 @@ const RETRY_DELAY_FIELD_PATTERN = /"retryDelay":\s*"([0-9.]+)(ms|s)"/i; // "try again in 5 min" / "try again in ~158 min." / "try again in 2h" / // "try again in 90 minutes" / "try again in 1 hour" const TRY_AGAIN_PATTERN = /try again in\s+~?\s*([0-9.]+)\s*(ms|sec|s|minutes?|mins?|m|hours?|hrs?|h)\b/i; +// "Your limit will reset in 13 minutes" / "reset in 13 minutes" / "will reset in 2h" +const WILL_RESET_IN_PATTERN = /(?:will\s+)?reset in\s+~?\s*([0-9.]+)\s*(ms|sec|s|minutes?|mins?|m|hours?|hrs?|h)\b/i; /** * Server-suggested retry delay extraction. Merges the patterns historically used @@ -83,7 +85,11 @@ export function extractRetryHint(source: Response | Headers | null | undefined, if (totalMs > 0) return totalMs; } } - for (const pattern of [PLEASE_RETRY_PATTERN, RETRY_DELAY_FIELD_PATTERN, TRY_AGAIN_PATTERN]) { + // Account-reset hints ("will reset in …") take precedence over short + // retry hints ("please retry in 5s"): a body carrying both must honour the + // longer account window, not the shorter generic one. QUOTA_RESET_PATTERN + // ("reset after …") above already runs first and stays first. + for (const pattern of [WILL_RESET_IN_PATTERN, PLEASE_RETRY_PATTERN, RETRY_DELAY_FIELD_PATTERN, TRY_AGAIN_PATTERN]) { const match = pattern.exec(body); if (match?.[1]) { const value = Number.parseFloat(match[1]); diff --git a/packages/utils/src/frontmatter.ts b/packages/utils/src/frontmatter.ts index 8178172f1..0aad83593 100644 --- a/packages/utils/src/frontmatter.ts +++ b/packages/utils/src/frontmatter.ts @@ -71,7 +71,7 @@ export class FrontmatterError extends Error { this.name = "FrontmatterError"; } - toString(): string { + override toString(): string { // Format the error with stack and detail, including the error message, stack, and source if present const details: string[] = [this.message]; if (this.source !== undefined) { diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 8a2ba3b8c..7d1363b7d 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -34,6 +34,7 @@ export * from "./tab-spacing"; export * from "./temp"; export * from "./tls-fetch"; export * from "./type-guards"; +export * from "./version"; export * from "./which"; function isPlainObject(val: object): val is Record { diff --git a/packages/utils/src/version.ts b/packages/utils/src/version.ts new file mode 100644 index 000000000..15088117e --- /dev/null +++ b/packages/utils/src/version.ts @@ -0,0 +1,99 @@ +const DIGITS = /^\d+$/; + +/** + * Compare two version strings. + * + * Canonical comparator that supersedes the historical in-repo copies + * (update-cli, hackage scraper, release scripts): + * - inputs are trimmed and at most one leading `v`/`V` is stripped + * - dot-separated segments are compared numerically, missing trailing + * segments count as 0, so `1.2` === `1.2.0` and any segment count works + * - a SemVer-2.0 prerelease suffix sorts before the plain release + * (`1.0.0-beta` < `1.0.0`); prerelease identifiers follow SemVer order + * (numeric < alphanumeric, numeric compared by value, alphanumeric + * compared lexically, longer sets of equal fields win) + * - SemVer build metadata begins at the first `+` and does not participate + * in precedence; it is stripped before core/prerelease parsing + * - malformed numeric segments compare as 0 (`1.2.x` === `1.2.0`) + * - never throws; returns only -1 | 0 | 1 + */ +export function compareVersions(a: string, b: string): number { + const pa = parseVersion(a); + const pb = parseVersion(b); + + const core = compareNumericParts(pa.core, pb.core); + if (core !== 0) return core; + + return comparePrerelease(pa.prerelease, pb.prerelease); +} + +interface ParsedVersion { + core: string[]; + prerelease: string[] | null; +} + +function parseVersion(version: string): ParsedVersion { + const trimmed = version.trim(); + const stripped = trimmed.startsWith("v") || trimmed.startsWith("V") ? trimmed.slice(1) : trimmed; + const plusIndex = stripped.indexOf("+"); + const withoutBuild = plusIndex === -1 ? stripped : stripped.slice(0, plusIndex); + const dashIndex = withoutBuild.indexOf("-"); + if (dashIndex === -1) { + return { core: withoutBuild.split("."), prerelease: null }; + } + return { + core: withoutBuild.slice(0, dashIndex).split("."), + prerelease: withoutBuild.slice(dashIndex + 1).split("."), + }; +} + +/** Compare dot-separated numeric segments; missing/malformed segments count as 0. */ +function compareNumericParts(a: string[], b: string[]): number { + const length = Math.max(a.length, b.length); + for (let i = 0; i < length; i++) { + // Missing or malformed segments compare as 0. + const sa = a[i]; + const sb = b[i]; + const result = compareDigits( + sa !== undefined && DIGITS.test(sa) ? sa : "0", + sb !== undefined && DIGITS.test(sb) ? sb : "0", + ); + if (result !== 0) return result; + } + return 0; +} + +/** Exact integer comparison of digit strings, avoiding float overflow. */ +function compareDigits(a: string, b: string): number { + const na = a.replace(/^0+/, "") || "0"; + const nb = b.replace(/^0+/, "") || "0"; + if (na.length !== nb.length) return na.length < nb.length ? -1 : 1; + if (na < nb) return -1; + if (na > nb) return 1; + return 0; +} + +/** SemVer-2.0 prerelease ordering; null means a plain release, which wins. */ +function comparePrerelease(a: string[] | null, b: string[] | null): number { + if (a === null || b === null) { + return a === b ? 0 : a === null ? 1 : -1; + } + const length = Math.max(a.length, b.length); + for (let i = 0; i < length; i++) { + const ia = a[i]; + const ib = b[i]; + if (ia === undefined) return -1; + if (ib === undefined) return 1; + const aNumeric = DIGITS.test(ia); + const bNumeric = DIGITS.test(ib); + if (aNumeric && bNumeric) { + const result = compareDigits(ia, ib); + if (result !== 0) return result; + } else if (aNumeric !== bNumeric) { + return aNumeric ? -1 : 1; + } else if (ia !== ib) { + return ia < ib ? -1 : 1; + } + } + return 0; +} diff --git a/packages/utils/src/which.ts b/packages/utils/src/which.ts index e1facff59..327c60fb4 100644 --- a/packages/utils/src/which.ts +++ b/packages/utils/src/which.ts @@ -183,8 +183,8 @@ export interface WhichOptions extends Bun.WhichOptions { // Darwin-specific "which" shim: consult Xcode/CLT toolchain directories after $PATH. // Uses cached directory listings instead of per-command existsSync or xcrun subprocesses. -function darwinWhich(command: string, _options?: Bun.WhichOptions): string | null { - const regular = Bun.which(command); +function darwinWhich(command: string, options?: Bun.WhichOptions): string | null { + const regular = Bun.which(command, options); if (regular) return regular; if (isXcodeBin(command)) { return getMacosToolPaths().get(command) ?? null; @@ -214,17 +214,19 @@ function cacheKey(command: string, options?: Bun.WhichOptions): CacheKey { */ export function $which(command: string, options?: WhichOptions): string | null { const cachePolicy = options?.cache ?? WhichCachePolicy.Cached; + const lookupOptions = + options?.PATH !== undefined || process.env.PATH === undefined ? options : { ...options, PATH: process.env.PATH }; let key: CacheKey | undefined; if (cachePolicy !== WhichCachePolicy.Bypass) { - key = cacheKey(command, options); + key = cacheKey(command, lookupOptions); if (cachePolicy !== WhichCachePolicy.Fresh) { const cached = toolCache.get(key); if (cached !== undefined) return cached; } } - const result = whichFresh(command, options); + const result = whichFresh(command, lookupOptions); if (key != null && cachePolicy !== WhichCachePolicy.ReadOnly) { toolCache.set(key, result); } diff --git a/packages/utils/test/fetch-retry.test.ts b/packages/utils/test/fetch-retry.test.ts index cceaf3649..5c8be8e06 100644 --- a/packages/utils/test/fetch-retry.test.ts +++ b/packages/utils/test/fetch-retry.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { fetchWithRetry } from "@oh-my-pi/pi-utils/fetch-retry"; +import { extractRetryHint, fetchWithRetry } from "@oh-my-pi/pi-utils/fetch-retry"; describe("fetchWithRetry", () => { it("routes requests through the `fetch` override when provided", async () => { @@ -79,3 +79,29 @@ describe("fetchWithRetry", () => { expect(attempt).toBe(1); }); }); + +describe("extractRetryHint", () => { + // Devin returns HTTP 403 with "Your limit will reset in 13 minutes" for an + // account-scoped message rate cap. Without recognizing "will reset in", the + // credential is blocked for the 1-minute default instead of 13 minutes and + // can be reselected and hammered while the cap remains active. + it("parses Devin 'Your limit will reset in 13 minutes' as 13 minutes", () => { + expect(extractRetryHint(undefined, "Your limit will reset in 13 minutes")).toBe(13 * 60_000); + }); + + it("parses bare 'reset in 13 minutes' phrasing", () => { + expect(extractRetryHint(undefined, "reset in 13 minutes")).toBe(13 * 60_000); + }); + + it("parses 'will reset in 2h' phrasing", () => { + expect(extractRetryHint(undefined, "will reset in 2h")).toBe(2 * 60 * 60_000); + }); + + // A quota body can carry both a generic retry hint and the account reset + // window ("Please retry in 5s. Your limit will reset in 13 minutes"). The + // account-reset hint must take precedence so the exhausted credential stays + // blocked for the full stated window instead of the short generic retry. + it("prefers the account reset window over a shorter retry hint", () => { + expect(extractRetryHint(undefined, "Please retry in 5s. Your limit will reset in 13 minutes")).toBe(13 * 60_000); + }); +}); diff --git a/packages/utils/test/fixtures/logger-fixed-date-preload.ts b/packages/utils/test/fixtures/logger-fixed-date-preload.ts index 987b09f4b..314e9bac6 100644 --- a/packages/utils/test/fixtures/logger-fixed-date-preload.ts +++ b/packages/utils/test/fixtures/logger-fixed-date-preload.ts @@ -13,7 +13,7 @@ class FixedDate extends NativeDate { super(value === undefined ? fixtureNow() : value); } - static now(): number { + static override now(): number { return fixtureNow(); } } diff --git a/packages/utils/test/version.test.ts b/packages/utils/test/version.test.ts new file mode 100644 index 000000000..bb6d485d3 --- /dev/null +++ b/packages/utils/test/version.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "bun:test"; +import { compareVersions } from "../src/version"; + +describe("compareVersions", () => { + it("trims whitespace and strips one leading v/V", () => { + expect(compareVersions(" 1.2.3 ", "1.2.3")).toBe(0); + expect(compareVersions("v1.2.3", "1.2.3")).toBe(0); + expect(compareVersions("V1.2.3", "v1.2.3")).toBe(0); + expect(compareVersions(" v1.2.3 ", "1.2.3")).toBe(0); + }); + + it("zero-pads missing trailing segments", () => { + expect(compareVersions("1.2", "1.2.0")).toBe(0); + expect(compareVersions("1", "1.0.0.0")).toBe(0); + expect(compareVersions("1.2", "1.2.1")).toBe(-1); + expect(compareVersions("1.2.3", "1.2")).toBe(1); + }); + + it("supports arbitrary segment counts", () => { + expect(compareVersions("1.2.3.4.5", "1.2.3.4.5")).toBe(0); + expect(compareVersions("1.2.3.4", "1.2.3.5")).toBe(-1); + expect(compareVersions("1.0.0.1", "1.0.0")).toBe(1); + }); + + it("orders SemVer prereleases before the plain release", () => { + expect(compareVersions("1.0.0-beta", "1.0.0")).toBe(-1); + expect(compareVersions("1.0.0", "1.0.0-rc.1")).toBe(1); + expect(compareVersions("v1.0.0-beta", "1.0.0")).toBe(-1); + expect(compareVersions("1.1.0-alpha", "1.0.0-beta")).toBe(1); + }); + + it("compares prerelease identifiers per SemVer 2.0", () => { + expect(compareVersions("1.0.0-alpha", "1.0.0-beta")).toBe(-1); + expect(compareVersions("1.0.0-rc.1", "1.0.0-rc.2")).toBe(-1); + // numeric, not lexical: rc.10 > rc.9 + expect(compareVersions("1.0.0-rc.9", "1.0.0-rc.10")).toBe(-1); + // a larger set of equal fields has higher precedence + expect(compareVersions("1.0.0-alpha", "1.0.0-alpha.1")).toBe(-1); + // numeric identifiers sort before alphanumeric ones + expect(compareVersions("1.0.0-1", "1.0.0-alpha")).toBe(-1); + expect(compareVersions("1.0.0-beta.2", "1.0.0-beta.1")).toBe(1); + }); + + it("strips SemVer build metadata before comparing", () => { + // build metadata does not affect precedence + expect(compareVersions("1.0.1+linux", "1.0.0")).toBe(1); + expect(compareVersions("1.0.0-rc.1+abc", "1.0.0-rc.1+xyz")).toBe(0); + expect(compareVersions("1.0.0+build1", "1.0.0+build2")).toBe(0); + expect(compareVersions("1.0.0+linux", "1.0.0+mac")).toBe(0); + expect(compareVersions("1.0.0+linux", "1.0.1+linux")).toBe(-1); + expect(compareVersions("v1.2.3+meta", "1.2.3")).toBe(0); + expect(compareVersions(" 1.0.0+meta ", "1.0.0")).toBe(0); + }); + + it("compares malformed numeric segments as 0", () => { + expect(compareVersions("1.2.x", "1.2.0")).toBe(0); + expect(compareVersions("1.x", "1.0")).toBe(0); + expect(compareVersions("1.2.x", "1.2.1")).toBe(-1); + }); + + it("never throws and always returns -1, 0, or 1", () => { + expect(compareVersions("not_a_version", "0")).toBe(0); + expect(compareVersions("", "")).toBe(0); + expect(compareVersions("v", "")).toBe(0); + // hyphenated garbage parses as a prerelease suffix and loses to the release + expect(compareVersions("not-a-version", "1.0.0")).toBe(-1); + expect(compareVersions("3.0.0", "1.0.0")).toBe(1); + expect(compareVersions("1.0.0", "3.0.0")).toBe(-1); + // exact numeric comparison beyond float precision + expect(compareVersions("1.2.99999999999999999999", "1.2.100000000000000000000")).toBe(-1); + }); +}); diff --git a/packages/utils/test/which.test.ts b/packages/utils/test/which.test.ts new file mode 100644 index 000000000..e054975c2 --- /dev/null +++ b/packages/utils/test/which.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { $which } from "../src/which"; + +describe("$which", () => { + const originalPath = process.env.PATH; + const tempDirs: string[] = []; + + afterEach(() => { + process.env.PATH = originalPath; + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it.skipIf(process.platform === "win32")("uses the current process PATH for each cached lookup", () => { + const firstDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-which-first-")); + const secondDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-which-second-")); + tempDirs.push(firstDir, secondDir); + + const command = `omp-which-${process.pid}`; + const firstExecutable = path.join(firstDir, command); + const secondExecutable = path.join(secondDir, command); + fs.writeFileSync(firstExecutable, "#!/bin/sh\n"); + fs.writeFileSync(secondExecutable, "#!/bin/sh\n"); + fs.chmodSync(firstExecutable, 0o755); + fs.chmodSync(secondExecutable, 0o755); + + process.env.PATH = firstDir; + expect($which(command)).toBe(firstExecutable); + + process.env.PATH = secondDir; + expect($which(command)).toBe(secondExecutable); + }); +}); diff --git a/scripts/ci-release-notes.ts b/scripts/ci-release-notes.ts index 1228d39dc..0f2b7d1fa 100755 --- a/scripts/ci-release-notes.ts +++ b/scripts/ci-release-notes.ts @@ -1,4 +1,5 @@ #!/usr/bin/env bun + /** * Generate aggregated release notes from per-package CHANGELOG.md files. * @@ -29,6 +30,7 @@ */ import { $, Glob } from "bun"; +import { compareVersions } from "../packages/utils/src/version"; const changelogGlob = new Glob("packages/*/CHANGELOG.md"); const REPO = process.env.OMP_REPO ?? process.env.GITHUB_REPOSITORY ?? "can1357/oh-my-pi"; @@ -37,22 +39,6 @@ const REPO = process.env.OMP_REPO ?? process.env.GITHUB_REPOSITORY ?? "can1357/o // alphabetically after these. const CATEGORY_ORDER = ["Breaking Changes", "Added", "Changed", "Fixed", "Removed"] as const; -/** Compare two `X.Y.Z` (or `vX.Y.Z`) version strings; non-semver returns 0. */ -export function compareVersions(a: string, b: string): number { - const am = a - .replace(/^v/, "") - .trim() - .match(/^(\d+)\.(\d+)\.(\d+)$/); - const bm = b - .replace(/^v/, "") - .trim() - .match(/^(\d+)\.(\d+)\.(\d+)$/); - if (!am || !bm) return 0; - if (am[1] !== bm[1]) return Number(am[1]) - Number(bm[1]); - if (am[2] !== bm[2]) return Number(am[2]) - Number(bm[2]); - return Number(am[3]) - Number(bm[3]); -} - export interface ChangelogVersionSpan { version: string; /** 0-indexed line of the `## [X.Y.Z]` heading. */ diff --git a/scripts/release.test.ts b/scripts/release.test.ts new file mode 100644 index 000000000..bc37ffc8b --- /dev/null +++ b/scripts/release.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { validateExplicitVersion } from "./release"; + +describe("validateExplicitVersion", () => { + test("rejects malformed versions", () => { + expect(validateExplicitVersion("999.bad")).toBe(null); + expect(validateExplicitVersion("17")).toBe(null); + expect(validateExplicitVersion("17.2")).toBe(null); + expect(validateExplicitVersion("17.2.8.9")).toBe(null); + expect(validateExplicitVersion("v17.2.8.9")).toBe(null); + expect(validateExplicitVersion("abc")).toBe(null); + expect(validateExplicitVersion("")).toBe(null); + expect(validateExplicitVersion("v")).toBe(null); + expect(validateExplicitVersion("17.2.8-")).toBe(null); + }); + + test("rejects leading zeroes in numeric segments", () => { + expect(validateExplicitVersion("018.0.0")).toBe(null); + expect(validateExplicitVersion("v018.0.0")).toBe(null); + expect(validateExplicitVersion("18.00.0")).toBe(null); + expect(validateExplicitVersion("18.0.00")).toBe(null); + }); + + test("rejects prerelease suffixes (not supported by this release path)", () => { + // Prereleases would be published as npm `latest` because the downstream + // publish runs `npm publish` with no `--tag`. + expect(validateExplicitVersion("17.2.8-rc.1")).toBe(null); + expect(validateExplicitVersion("v17.2.8-beta")).toBe(null); + expect(validateExplicitVersion("1.0.0-alpha")).toBe(null); + expect(validateExplicitVersion("1.0.0-alpha.1.2")).toBe(null); + expect(validateExplicitVersion("1.0.0-0.3.7")).toBe(null); + expect(validateExplicitVersion("1.0.0-x.7.z.92")).toBe(null); + }); + + test("accepts bare three-segment numeric versions and returns them unchanged", () => { + expect(validateExplicitVersion("17.2.8")).toBe("17.2.8"); + expect(validateExplicitVersion("0.0.0")).toBe("0.0.0"); + expect(validateExplicitVersion("1.0.0")).toBe("1.0.0"); + }); + + test("accepts leading v prefix and normalizes to the bare version", () => { + expect(validateExplicitVersion("v17.2.8")).toBe("17.2.8"); + expect(validateExplicitVersion("V17.2.8")).toBe(null); + }); +}); diff --git a/scripts/release.ts b/scripts/release.ts index 0399fce85..00188044e 100755 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -9,11 +9,30 @@ * Example: bun scripts/release.ts minor */ import { $, Glob } from "bun"; +import { compareVersions } from "../packages/utils/src/version.ts"; import { runChangelogFixer } from "./fix-changelogs"; const changelogGlob = new Glob("packages/*/CHANGELOG.md"); const packageJsonGlob = new Glob("packages/*/package.json"); const cargoTomlGlob = new Glob("crates/*/Cargo.toml"); +/** + * Strict explicit-version guard: three numeric dot-segments with an optional + * leading `v` and NO prerelease suffix. Prereleases are rejected because the + * downstream publish (`scripts/ci-release-publish.ts`) runs `npm publish` with + * no `--tag`, which would promote a prerelease to the npm `latest` dist-tag — + * hitting every unqualified install and the `/latest` endpoint `omp update` + * reads. Bump keywords (major/minor/patch) are handled separately and must not + * be routed through this check. + * + * Returns the normalized bare version (leading `v` stripped) when accepted, or + * `null` when rejected. Callers must use the returned value for all writes so + * no downstream manifest (package.json, Cargo.toml, tag) ever sees a `v` + * prefix — Cargo rejects `version = "v17.2.8"`. + */ +export function validateExplicitVersion(version: string): string | null { + const match = /^v?((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))$/.exec(version); + return match ? match[1] : null; +} function git(args: readonly string[]) { return $`git -c core.fsmonitor=false -c core.untrackedCache=false -c fetch.pruneTags=false ${args}`; @@ -186,16 +205,23 @@ function bumpVersion(current: string, bump: "major" | "minor" | "patch"): string } } -function compareVersions(a: string, b: string): number { - const [aMajor, aMinor, aPatch] = parseVersion(a); - const [bMajor, bMinor, bPatch] = parseVersion(b); - if (aMajor !== bMajor) return aMajor - bMajor; - if (aMinor !== bMinor) return aMinor - bMinor; - return aPatch - bPatch; -} - async function cmdRelease(versionOrBump: string): Promise { console.log("\n=== Release Script ===\n"); + // Validate explicit versions before any compare: the shared compareVersions + // never throws, so without this guard garbage like "999.bad" would be + // accepted and written into every package.json / Cargo.toml / tag. The + // validator also normalizes a leading `v` to the bare version so every + // downstream write (manifests, Cargo.toml, tag) uses `17.2.8`, not `v17.2.8`. + if (versionOrBump !== "major" && versionOrBump !== "minor" && versionOrBump !== "patch") { + const normalized = validateExplicitVersion(versionOrBump); + if (normalized === null) { + console.error( + `Error: Invalid version "${versionOrBump}". Expected a semver like 17.2.8 or v17.2.8 (prereleases such as 17.2.8-rc.1 are not supported by this release path), or a bump keyword (major/minor/patch).`, + ); + process.exit(1); + } + versionOrBump = normalized; + } // 1. Pre-flight checks console.log("Pre-flight checks..."); @@ -397,23 +423,25 @@ async function cmdRelease(versionOrBump: string): Promise { // Main // ============================================================================= -const arg = process.argv[2]; +if (import.meta.main) { + const arg = process.argv[2]; -if (!arg) { - console.error("Usage:"); - console.error(" bun scripts/release.ts Full release"); - console.error(" bun scripts/release.ts watch Watch CI for current commit"); - process.exit(1); -} + if (!arg) { + console.error("Usage:"); + console.error(" bun scripts/release.ts Full release"); + console.error(" bun scripts/release.ts watch Watch CI for current commit"); + process.exit(1); + } -if (arg === "watch") { - await cmdWatch(); -} else if (arg === "major" || arg === "minor" || arg === "patch" || /^\d+\.\d+\.\d+$/.test(arg)) { - await cmdRelease(arg); -} else { - console.error(`Unknown command or invalid version: ${arg}`); - console.error("Usage:"); - console.error(" bun scripts/release.ts Full release"); - console.error(" bun scripts/release.ts watch Watch CI for current commit"); - process.exit(1); + if (arg === "watch") { + await cmdWatch(); + } else if (arg === "major" || arg === "minor" || arg === "patch" || validateExplicitVersion(arg) !== null) { + await cmdRelease(arg); + } else { + console.error(`Unknown command or invalid version: ${arg}`); + console.error("Usage:"); + console.error(" bun scripts/release.ts Full release"); + console.error(" bun scripts/release.ts watch Watch CI for current commit"); + process.exit(1); + } } diff --git a/scripts/setup-npm-trust.ts b/scripts/setup-npm-trust.ts index f4959c53c..238369de3 100755 --- a/scripts/setup-npm-trust.ts +++ b/scripts/setup-npm-trust.ts @@ -36,6 +36,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { $ } from "bun"; import { LEAF_TARGETS } from "../packages/natives/scripts/gen-npm-packages.ts"; +import { compareVersions } from "../packages/utils/src/version.ts"; import { packages } from "./ci-release-publish.ts"; const repoRoot = path.join(import.meta.dir, ".."); @@ -179,17 +180,6 @@ async function collectTargets(): Promise<{ names: string[]; repoFromManifest: st return { names, repoFromManifest }; } -/** Compare dotted version numbers; true when `version` >= `minimum`. */ -function meetsMinimum(version: string, minimum: string): boolean { - const a = version.split(".").map(Number); - const b = minimum.split(".").map(Number); - for (let i = 0; i < Math.max(a.length, b.length); i++) { - const diff = (a[i] ?? 0) - (b[i] ?? 0); - if (diff !== 0) return diff > 0; - } - return true; -} - /** Run npm with the terminal attached so the web 2FA flow stays interactive. */ function npmInteractive(args: readonly string[]): Promise { return Bun.spawn(["npm", ...args], { stdin: "inherit", stdout: "inherit", stderr: "inherit" }).exited; @@ -321,7 +311,7 @@ async function main(): Promise { console.error("Could not determine npm version. Is npm installed and on PATH?"); process.exit(1); } - if (!meetsMinimum(npmVersion, MIN_NPM)) { + if (compareVersions(npmVersion, MIN_NPM) < 0) { console.error(`npm ${MIN_NPM}+ is required for trusted publishing (found ${npmVersion}).`); console.error("Upgrade with: npm install -g npm@latest"); process.exit(1); diff --git a/tsconfig.base.json b/tsconfig.base.json index 0639244e8..2a9cbf211 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -6,6 +6,8 @@ "moduleResolution": "Bundler", "moduleDetection": "force", "strict": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, "skipLibCheck": true, "allowArbitraryExtensions": true, "verbatimModuleSyntax": true,