Merge branch 'main' into fix/coding-agent-misc

This commit is contained in:
Can Bölük
2026-05-26 21:27:39 +03:00
committed by GitHub
219 changed files with 10368 additions and 9149 deletions
Generated
+4 -4
View File
@@ -2331,7 +2331,7 @@ dependencies = [
[[package]]
name = "pi-ast"
version = "15.3.2"
version = "15.4.3"
dependencies = [
"anyhow",
"ast-grep-core",
@@ -2399,7 +2399,7 @@ dependencies = [
[[package]]
name = "pi-iso"
version = "15.3.2"
version = "15.4.3"
dependencies = [
"async-trait",
"libc",
@@ -2411,7 +2411,7 @@ dependencies = [
[[package]]
name = "pi-natives"
version = "15.3.2"
version = "15.4.3"
dependencies = [
"anyhow",
"arboard",
@@ -2457,7 +2457,7 @@ dependencies = [
[[package]]
name = "pi-shell"
version = "15.3.2"
version = "15.4.3"
dependencies = [
"anyhow",
"brush-builtins",
+1 -1
View File
@@ -4,7 +4,7 @@ exclude = ["crates/brush-core-vendored", "crates/brush-builtins-vendored"]
resolver = "3"
[workspace.package]
version = "15.3.2"
version = "15.4.3"
edition = "2024"
license = "MIT"
authors = ["Can Boluk"]
+17 -17
View File
@@ -15,7 +15,7 @@
},
"packages/agent": {
"name": "@oh-my-pi/pi-agent-core",
"version": "15.3.2",
"version": "15.4.3",
"dependencies": {
"@oh-my-pi/pi-ai": "catalog:",
"@oh-my-pi/pi-natives": "catalog:",
@@ -30,7 +30,7 @@
},
"packages/ai": {
"name": "@oh-my-pi/pi-ai",
"version": "15.3.2",
"version": "15.4.3",
"bin": {
"pi-ai": "./src/cli.ts",
},
@@ -48,7 +48,7 @@
},
"packages/coding-agent": {
"name": "@oh-my-pi/pi-coding-agent",
"version": "15.3.2",
"version": "15.4.3",
"bin": {
"omp": "src/cli.ts",
},
@@ -83,7 +83,7 @@
},
"packages/natives": {
"name": "@oh-my-pi/pi-natives",
"version": "15.3.2",
"version": "15.4.3",
"devDependencies": {
"@napi-rs/cli": "catalog:",
"@types/bun": "catalog:",
@@ -91,7 +91,7 @@
},
"packages/stats": {
"name": "@oh-my-pi/omp-stats",
"version": "15.3.2",
"version": "15.4.3",
"bin": {
"omp-stats": "./src/index.ts",
},
@@ -116,7 +116,7 @@
},
"packages/swarm-extension": {
"name": "@oh-my-pi/swarm-extension",
"version": "15.3.2",
"version": "15.4.3",
"bin": {
"omp-swarm": "src/cli.ts",
},
@@ -132,7 +132,7 @@
},
"packages/tui": {
"name": "@oh-my-pi/pi-tui",
"version": "15.3.2",
"version": "15.4.3",
"dependencies": {
"@oh-my-pi/pi-natives": "catalog:",
"@oh-my-pi/pi-utils": "catalog:",
@@ -172,7 +172,7 @@
},
"packages/utils": {
"name": "@oh-my-pi/pi-utils",
"version": "15.3.2",
"version": "15.4.3",
"dependencies": {
"@oh-my-pi/pi-natives": "catalog:",
"beautiful-mermaid": "catalog:",
@@ -212,13 +212,13 @@
"@bufbuild/protoc-gen-es": "^2.12.0",
"@mozilla/readability": "^0.6.0",
"@napi-rs/cli": "3.6.2",
"@oh-my-pi/omp-stats": "15.3.2",
"@oh-my-pi/pi-agent-core": "15.3.2",
"@oh-my-pi/pi-ai": "15.3.2",
"@oh-my-pi/pi-coding-agent": "15.3.2",
"@oh-my-pi/pi-natives": "15.3.2",
"@oh-my-pi/pi-tui": "15.3.2",
"@oh-my-pi/pi-utils": "15.3.2",
"@oh-my-pi/omp-stats": "15.4.3",
"@oh-my-pi/pi-agent-core": "15.4.3",
"@oh-my-pi/pi-ai": "15.4.3",
"@oh-my-pi/pi-coding-agent": "15.4.3",
"@oh-my-pi/pi-natives": "15.4.3",
"@oh-my-pi/pi-tui": "15.4.3",
"@oh-my-pi/pi-utils": "15.4.3",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/context-async-hooks": "^2.0.0",
"@opentelemetry/sdk-trace-base": "^2.0.0",
@@ -831,7 +831,7 @@
"data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
"date-fns": ["date-fns@4.2.1", "", {}, "sha512-37RhSdxaG1suen6VDCza6rNrQfooyQh57HFVPwQGEq2QWliVLzPQZ8Oa017weOu+HZCnzI7N3Pf/wyoBKfEqrA=="],
"date-fns": ["date-fns@4.3.0", "", {}, "sha512-OYcL+3N/jyWbYdFGqoMAhytDgxP9pbYPUUiRCOgn4Fewaadk9l/Wam4Avciiyp2BgkpfQyBV9B+ehnVJych+eQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
@@ -1203,7 +1203,7 @@
"text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="],
"tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="],
"tinyexec": ["tinyexec@1.2.2", "", {}, "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g=="],
"token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="],
+54 -7
View File
@@ -2152,22 +2152,69 @@ fn setup_process_substitution(
Ok((candidate_fd_num, target_file))
}
// LOCAL DIVERGENCE (vs upstream reubeno/brush@main):
// Upstream writes the entire heredoc/here-string body into the pipe
// synchronously on the calling thread. That deadlocks any time the body
// exceeds the OS pipe buffer because the reader is not handed to the
// downstream command (and therefore not drained) until after this
// function returns. Concrete buffer sizes:
//
// * Linux: 64 KiB default, growable via `F_SETPIPE_SZ` up to
// `/proc/sys/fs/pipe-max-size` (1 MiB default).
// * macOS: 16-64 KiB, no `F_SETPIPE_SZ` equivalent.
// * Windows: ~4 KiB (`CreatePipe(nSize = 0)`), no portable knob to
// raise it.
//
// We keep the `F_SETPIPE_SZ` fast path for Linux (avoids a thread spawn
// for the common in-process case) but fall through to a detached writer
// thread on every other platform with OS threads, and on Linux when the
// kernel rejects the requested size (body > `pipe-max-size`). The thread
// owns the writer; it terminates naturally when the consumer drains the
// pipe or drops the reader (`BrokenPipe`), so no `JoinHandle` is retained.
// Targets without OS thread support keep upstream's synchronous write path
// so heredocs continue to work there instead of failing at thread spawn.
fn setup_open_file_with_contents(contents: &str) -> Result<OpenFile, error::Error> {
let (reader, mut writer) = std::io::pipe()?;
let bytes = contents.as_bytes();
// Linux fast path: grow the pipe so the entire body fits inline.
// Falls through to the generic writer when (a) `bytes.len()`
// overflows `i32`, or (b) the kernel rejects the requested size
// (body > /proc/sys/fs/pipe-max-size, default 1 MiB).
#[cfg(any(target_os = "linux", target_os = "android"))]
{
use std::os::fd::AsFd as _;
let len = i32::try_from(bytes.len())
.map_err(|_err| error::Error::from(error::ErrorKind::TooMuchData))?;
nix::fcntl::fcntl(reader.as_fd(), nix::fcntl::FcntlArg::F_SETPIPE_SZ(len))?;
if let Ok(len) = i32::try_from(bytes.len())
&& nix::fcntl::fcntl(reader.as_fd(), nix::fcntl::FcntlArg::F_SETPIPE_SZ(len)).is_ok()
{
writer.write_all(bytes)?;
drop(writer);
return Ok(reader.into());
}
}
#[cfg(target_family = "wasm")]
{
writer.write_all(bytes)?;
drop(writer);
return Ok(reader.into());
}
#[cfg(not(target_family = "wasm"))]
{
// Generic path: detached writer thread. Writing inline deadlocks
// once `bytes.len()` exceeds the OS pipe buffer (Windows ~4 KiB,
// macOS 16-64 KiB), neither of which has a `F_SETPIPE_SZ`
// equivalent.
let payload = bytes.to_vec();
std::thread::Builder::new()
.name("brush-heredoc-writer".into())
.spawn(move || {
// `BrokenPipe` is expected when the consumer drops the
// reader before the body is fully written; there is
// nothing useful to do with that error here.
let _ = writer.write_all(&payload);
})?;
}
writer.write_all(bytes)?;
drop(writer);
Ok(reader.into())
}
+6 -2
View File
@@ -922,6 +922,7 @@ mod tests {
use std::{
fs,
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
time::{Duration, SystemTime, UNIX_EPOCH},
};
@@ -934,11 +935,14 @@ mod tests {
impl TempDirGuard {
fn new() -> Self {
let unique = SystemTime::now()
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time is after UNIX_EPOCH")
.as_nanos();
let path = std::env::temp_dir().join(format!("pi-grep-test-{unique}"));
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
let path = std::env::temp_dir().join(format!("pi-grep-test-{pid}-{nanos}-{seq}"));
fs::create_dir_all(&path).expect("create temp test directory");
Self(path)
}
+1 -1
View File
@@ -67,5 +67,5 @@ use napi_derive::napi;
/// MUST stay in sync with `VERSION_SENTINEL_EXPORT` in
/// `packages/natives/native/index.js` (which derives the name from
/// `package.json#version`).
#[napi(js_name = "__piNativesV15_3_2")]
#[napi(js_name = "__piNativesV15_4_3")]
pub const fn pi_natives_version_sentinel() {}
+28
View File
@@ -1919,4 +1919,32 @@ mod tests {
}
assert_eq!(stdout, b"prod:8080");
}
/// Regression for a Windows/macOS deadlock in
/// `brush_core::interp::setup_open_file_with_contents`. The body is
/// 256 KiB — well past the default pipe buffer on every platform
/// (Windows ~4 KiB, macOS 16-64 KiB, Linux 64 KiB), so any inline
/// `write_all` on the calling thread blocks forever. The `:` builtin
/// never reads its stdin, so the only way `echo done` runs is if the
/// heredoc writer is decoupled from the main thread (or, on Linux,
/// the pipe buffer was grown via `F_SETPIPE_SZ`). The
/// `tokio::time::timeout` is the safety net that turns a regression
/// into a 10 s failure instead of hanging CI for the full
/// hard-timeout window.
#[tokio::test(flavor = "multi_thread")]
async fn large_heredoc_does_not_deadlock() {
let body = "X".repeat(256 * 1024);
let command = format!(": <<'EOF'\n{body}\nEOF\necho done");
let options = ShellExecuteOptions { command, ..Default::default() };
let result = time::timeout(
Duration::from_secs(10),
execute_shell(options, None, CancelToken::default()),
)
.await
.expect("execute_shell hung past 10 s — heredoc writer deadlocked")
.expect("execute_shell errored");
assert_eq!(result.exit_code, Some(0), "command did not run to completion");
}
}
+6 -4
View File
@@ -47,8 +47,9 @@ The broker is the only writer of OAuth refresh tokens. Clients (including the ga
```
omp auth-broker serve [--bind=host:port] # boot the broker
omp auth-broker token [--regenerate] [--json] # print or rotate the bearer token
omp auth-broker login <provider> [--via=user@host] [--dry-run]
omp auth-broker logout <provider>
omp auth-broker login [<provider>] [--via=user@host] [--dry-run]
omp auth-broker logout [<provider>]
omp auth-broker list [--json]
omp auth-broker import <file|dir> [--provider=<id>] [--include-disabled] [--dry-run] [--json]
omp auth-broker migrate --from-local [--dry-run] [--json]
omp auth-broker status [--json]
@@ -56,8 +57,9 @@ omp auth-broker status [--json]
- `serve` opens the local SQLite store at `getAgentDbPath()` and binds an HTTP listener (default `127.0.0.1:8765`). On startup a token is ensured at `<config-dir>/auth-broker.token` (mode `0600`, `0700` parent dir). The background refresher refreshes any OAuth credential whose `expires - Date.now() < refreshSkewMs` (default 5 min) every `refreshIntervalMs` (default 60 s).
- `token` prints the cached bearer or generates a new one. `--regenerate` rotates it.
- `login <provider>` runs the per-provider OAuth flow locally, or — with `--via=user@host` — `ssh -L <callback-port>:127.0.0.1:<callback-port> user@host omp auth-broker login <provider>` so the OAuth callback hits the local browser but the credential is written on the broker host. Built-in callback ports: `anthropic:54545`, `openai-codex:1455`, `google-gemini-cli:8085`, `google-antigravity:51121`, `gitlab-duo:8080`.
- `logout <provider>` deletes every credential row for `<provider>`.
- `login [<provider>]` runs the per-provider OAuth flow locally — when no provider is supplied, it falls back to an interactive numbered picker. With `--via=user@host` it shells out `ssh -L <callback-port>:127.0.0.1:<callback-port> user@host omp auth-broker login <provider>` so the OAuth callback hits the local browser but the credential is written on the broker host (`--via` requires `<provider>`). Built-in callback ports: `anthropic:54545`, `openai-codex:1455`, `google-gemini-cli:8085`, `google-antigravity:51121`, `gitlab-duo:8080`. The OAuth dance is driven in-process via `AuthStorage.login()` — there is no longer a `pi-ai` bin to spawn.
- `logout [<provider>]` deletes every credential row for `<provider>`. With no argument it shows an interactive numbered picker of currently-stored providers.
- `list` enumerates every registered OAuth provider id/name (the union of built-ins + `registerOAuthProvider` custom providers). `--json` emits a machine-readable array.
- `import <file|dir>` imports CLIProxyAPI-style JSON credentials into the local SQLite store. Maps `type` field → omp provider (`claude → anthropic`, `codex → openai-codex`, `gemini → google-gemini-cli`, `antigravity → google-antigravity`, `gemini-cli → google-gemini-cli`).
- `migrate --from-local` walks the local SQLite store + env-derived credentials and idempotently uploads them to the configured broker (`POST /v1/credential`).
- `status` health-pings the configured remote broker.
+2 -2
View File
@@ -131,7 +131,7 @@ When `CLAUDE_CODE_USE_FOUNDRY` is enabled, Anthropic requests switch to Foundry
| `AWS_DEFAULT_REGION` | Fallback if `AWS_REGION` unset |
| `AWS_PROFILE` | Enables named profile auth path |
| `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` | Enables IAM key auth path |
| `AWS_BEARER_TOKEN_BEDROCK` | Enables bearer token auth path |
| `AWS_BEARER_TOKEN_BEDROCK` | Highest-precedence bearer token auth path; skips AWS profile/credential-chain lookup when set |
| `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI` / `AWS_CONTAINER_CREDENTIALS_FULL_URI` | Enables ECS task credential path |
| `AWS_WEB_IDENTITY_TOKEN_FILE` + `AWS_ROLE_ARN` | Enables web identity auth path |
| `AWS_BEDROCK_SKIP_AUTH` | If `1`, injects dummy credentials (proxy/non-auth scenarios) |
@@ -298,7 +298,7 @@ Extra conditional behavior:
| `LM_STUDIO_BASE_URL` | Default implicit LM Studio discovery base URL override (`http://127.0.0.1:1234/v1` if unset) |
| `OLLAMA_BASE_URL` | Default implicit Ollama discovery base URL override (`http://127.0.0.1:11434` if unset) |
| `LLAMA_CPP_BASE_URL` | Default implicit Llama.cpp discovery base URL override (`http://127.0.0.1:8080` if unset) |
| `PI_EDIT_VARIANT` | Forces edit tool variant when valid (`patch`, `replace`, `hashline`, `atom`, `vim`, `apply_patch`) |
| `PI_EDIT_VARIANT` | Forces edit tool variant when valid (`patch`, `replace`, `hashline`, `apply_patch`) |
| `PI_FORCE_IMAGE_PROTOCOL` | Forces supported image protocol (`kitty`, `iterm2`/`iterm`, `sixel`, `none`) where used |
| `PI_ALLOW_SIXEL_PASSTHROUGH` | Allows SIXEL passthrough when `PI_FORCE_IMAGE_PROTOCOL=sixel` |
| `PI_NO_PTY` | If `1`, disables interactive PTY path for bash tool |
+2 -2
View File
@@ -10,7 +10,7 @@
- `crates/pi-natives/src/language/mod.rs` — language aliases and extension inference
- `packages/coding-agent/src/tools/path-utils.ts` — path/glob parsing and multi-path resolution
- `packages/coding-agent/src/tools/render-utils.ts` — parse-error dedupe and display caps
- `packages/coding-agent/src/tools/match-line-format.ts` — anchor-prefixed match rendering
- `packages/coding-agent/src/tools/match-line-format.ts` — hashline match rendering
- `packages/coding-agent/src/utils/file-display-mode.ts` — hashline vs line-number output mode
- `packages/natives/native/index.d.ts` — JS-visible native binding contract
@@ -36,7 +36,7 @@ Pattern grammar and language support exposed to the model:
- Single-shot tool result.
- Model-facing `content` is one text block:
- grouped by file for directory/multi-file searches,
- match lines rendered as `*LINE+HASH|text` in hashline mode or `*LINE|text` otherwise,
- match lines rendered under `¶PATH#HASH` as `*LINE:text` in hashline mode or `*LINE|text` otherwise,
- continuation lines for multi-line matches rendered with a leading space,
- optional `meta: NAME=value` lines when ast-grep captured metavariables.
- If no matches are found, text is `No matches found` or `No matches found. Parse issues mean the query may be mis-scoped; narrow paths before concluding absence.` plus formatted parse issues.
+81 -66
View File
@@ -13,7 +13,7 @@
- `packages/coding-agent/src/hashline/apply.ts` — validates anchors and applies edits
- `packages/coding-agent/src/hashline/anchors.ts` — stale-anchor mismatch formatting
- `packages/coding-agent/src/hashline/recovery.ts` — cache-based stale-anchor recovery
- `packages/coding-agent/src/hashline/hash.ts` — computes `LINEhh|` anchors shared with `read`/`search`
- `packages/coding-agent/src/hashline/hash.ts` — computes 4-hex file hashes and `LINE:TEXT` display lines shared with `read`/`search`
- `packages/coding-agent/src/edit/file-read-cache.ts` — per-session read snapshot cache
- `packages/coding-agent/src/tools/read.ts` — emits anchored lines and records read snapshots
- `packages/coding-agent/src/tools/search.ts` — records sparse snapshots from matches/context
@@ -26,23 +26,27 @@
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `input` | `string` | Yes | One or more edit sections. First non-blank line must be `¶PATH` unless the caller supplies the legacy fallback `path` outside the model schema and the body already looks like hashline ops (`packages/coding-agent/src/hashline/input.ts`). Optional `*** Begin Patch` / `*** End Patch` envelope is ignored if present. |
| `input` | `string` | Yes | One or more edit sections. Anchored sections must start with `¶PATH#HASH`; unbound `¶PATH` is allowed only for new-file / `BOF` / `EOF` boundary inserts. Optional `*** Begin Patch` / `*** End Patch` envelope is ignored if present. |
Patch language inside `input`:
- Section header: `¶PATH`
- Insert after: `ANCHOR↓`
- Insert before: `ANCHOR↑`
- Replace/delete range: `A-B→`
- Single-line replace/delete sugar: `A→` means `A-A→`
- `A-B→` with no payload deletes the range. To keep a blank line, include one explicit empty payload line.
- Inline payload: content after `↓`/`↑`/`→` on the same line is the first payload line; subsequent lines append to it
- Special anchors: `BOF`, `EOF`
- Anchor token: `<line><2-char-hash>`, for example `41th`
- Section header: `¶PATH#HASH` for anchored edits, `¶PATH` for BOF/EOF-only inserts
- Insert after: `LINE↓[payload]`
- Insert before: `LINE↑[payload]`
- Replace range: `A-B:[payload]`
- Single-line replace sugar: `A:[payload]` means `A-A:[payload]`
- Delete range: `A-B!`
- Single-line delete sugar: `A!` means `A-A!`
- **Payload semantics:** the first payload line is whatever follows the sigil on the op line itself; additional payload lines follow on subsequent lines and append after the inline first line. An empty inline (just the sigil followed by a newline) means the first payload line is empty. So bare `A↑` / `A↓` insert one blank line, and bare `A:` / `A-B:` replace the line/range with one blank line. But `A↓\nfoo` inserts blank-then-`foo`, not just `foo` — for a single-line insert, put `foo` inline as `A↓foo`.
- `!` deletes and forbids payload.
- Read lines like `84:content` are already valid single-line replacements.
- Special anchors: `BOF`, `EOF` (both support inline payload, e.g. `BOF↓export const done = true;`).
- Anchor token: bare line number, for example `41`
- File binding: 4-hex hash in the section header, for example `¶src/a.ts#1a2b`
Anchors come from `read`/`search` output. `read` formats lines as `LINEhh|TEXT` via `formatHashLine` / `formatHashLines` in `packages/coding-agent/src/hashline/hash.ts`; copy only the token left of `|` into op lines.
Anchors come from `read`/`search` output. `read` emits a `¶PATH#HASH` header and lines as `LINE:TEXT`; copy the header into the edit section and copy only the line number into op lines.
Other edit modes exist (`replace`, `patch`, `vim`, `apply_patch`) and are selected outside the tool payload by `resolveEditMode()` in `packages/coding-agent/src/utils/edit-mode.ts`. Their schemas are different; this document covers the default hashline mode.
Other edit modes exist (`replace`, `patch`, `apply_patch`) and are selected outside the tool payload by `resolveEditMode()` in `packages/coding-agent/src/utils/edit-mode.ts`. Their schemas are different; this document covers the default hashline mode.
## Outputs
- Single-shot tool result; hashline mode does not use a `resolve` preview/apply handshake.
@@ -67,20 +71,20 @@ Warnings:
- While the model is still typing arguments, the TUI can compute a diff preview with `packages/coding-agent/src/edit/streaming.ts`; that preview is not a deferred action and does not block execution.
## Flow
1. `EditTool.execute()` in `packages/coding-agent/src/edit/index.ts` resolves the active mode. Default is `hashline`; `customFormat` exposes `packages/coding-agent/src/hashline/grammar.lark` with `$HFMT$` / `$HOP_INSERT_BEFORE$` / `$HOP_INSERT_AFTER$` / `$HOP_REPLACE$` / `$HOP_CHARS$` / `$HFILE$` placeholders filled from `packages/coding-agent/src/hashline/hash.ts`.
2. `executeHashlineSingle()` in `packages/coding-agent/src/hashline/execute.ts` splits the raw `input` into `¶PATH` sections with `splitHashlineInputs()`.
1. `EditTool.execute()` in `packages/coding-agent/src/edit/index.ts` resolves the active mode. Default is `hashline`; `customFormat` exposes `packages/coding-agent/src/hashline/grammar.lark` with `$HFILE_HASH$` / `$HOP_INSERT_BEFORE$` / `$HOP_INSERT_AFTER$` / `$HOP_REPLACE$` / `$HOP_CHARS$` / `$HFILE$` placeholders filled from `packages/coding-agent/src/hashline/hash.ts`.
2. `executeHashlineSingle()` in `packages/coding-agent/src/hashline/execute.ts` splits the raw `input` into `¶PATH#HASH` / `¶PATH` sections with `splitHashlineInputs()`.
3. If multiple sections target the same path, `mergeSamePathSections()` concatenates them before execution so every op still refers to the original file snapshot.
4. Multi-section calls run a preflight pass (`preflightHashlineSection()`): parse ops, enforce plan-mode write rules, load the current file, reject anchor-scoped edits against missing files, reject auto-generated files, apply edits in memory, and fail if the result is a no-op. This prevents partial batches.
5. `parseHashlineWithWarnings()` in `packages/coding-agent/src/hashline/parser.ts` tokenizes the diff body:
- ignores blank lines and optional `*** Begin Patch`
- stops at `*** End Patch`
- stops at `*** Abort` and emits `ABORT_WARNING`
- turns `↓` / `↑` payload runs into one `insert` edit per payload line
- turns `A-B→` with payload into inserts before `A`, then deletes for `A-B`
- turns `A-B→` with no payload into one `delete` edit per line in the range; a blank-in-place edit requires one explicit empty payload line
6. `applyHashlineEdits()` in `packages/coding-agent/src/hashline/apply.ts` validates every referenced anchor before mutating anything. Each anchor hash is recomputed from current file content with `computeLineHash()`.
7. If any anchor hash differs, `applyHashlineEdits()` throws `HashlineMismatchError`. `execute.ts` catches only that class and calls `tryRecoverHashlineWithCache()`.
8. Recovery replays the edits against the most recent cached read/search snapshot for that path (`packages/coding-agent/src/edit/file-read-cache.ts`), then 3-way merges the result onto current disk content using `Diff.applyPatch(..., { fuzzFactor: 3 })` in `packages/coding-agent/src/hashline/recovery.ts`. On success the edit proceeds with a warning; on failure the original mismatch error is re-thrown.
- turns `↓` / `↑` payload runs (inline plus subsequent lines) into one `insert` edit per payload line
- turns `A-B:` with payload into inserts before `A`, then deletes for `A-B`
- turns `A-B!` into one `delete` edit per line in the range; payload is forbidden
6. `executeHashlineSingle()` computes the current file hash before applying anchored edits. If it differs from the section `#HASH`, recovery tries the read/search snapshot cache before any write.
7. `applyHashlineEdits()` validates only line bounds, then applies the already hash-bound line-number edits.
8. Recovery replays the edits against the cached snapshot for the section hash (`packages/coding-agent/src/edit/file-read-cache.ts`), then 3-way merges the result onto current disk content using `Diff.applyPatch(..., { fuzzFactor: 0 })` in `packages/coding-agent/src/hashline/recovery.ts`. On success the edit proceeds with a warning; on failure a `HashlineMismatchError` is surfaced.
9. Before splicing lines, `absorbReplacementBoundaryDuplicates()` normalizes some malformed-but-recoverable ranges:
- duplicate prefix/suffix lines adjacent to a replacement can be absorbed by widening the delete range
- pure inserts can auto-drop duplicated leading/trailing payload lines when `edit.hashlineAutoDropPureInsertDuplicates` is enabled
@@ -98,61 +102,71 @@ Warnings:
- `replace` — exact/fuzzy old/new text replacement (`packages/coding-agent/src/edit/modes/replace.ts`).
- `patch` — structured JSON diff-hunk mode (`packages/coding-agent/src/edit/modes/patch.ts`).
- `apply_patch` — freeform Codex-style `*** Begin Patch` envelope, internally expanded into patch-mode entries (`packages/coding-agent/src/edit/modes/apply-patch.ts`).
- `vim` — persistent modal editing buffer (`packages/coding-agent/src/tools/vim.ts`).
Hashline op examples:
Hashline op examples (single-line payloads are inline; multi-line payloads continue on subsequent lines):
```text
¶src/a.ts
4fb↓
const added = true;
¶src/a.ts#1a2b
4↓const added = true;
```
```text
¶src/a.ts
4fb↑
const addedBefore = true;
¶src/a.ts#1a2b
4↑const addedBefore = true;
```
```text
¶src/a.ts
4fb-6qx→
¶src/a.ts#1a2b
4-6:const replacement = true;
```
```text
¶src/a.ts
4fb-5dm→
const clean = (name || DEF).trim();
¶src/a.ts#1a2b
4-5:const clean = (name || DEF).trim();
return clean.length === 0 ? DEF : clean.toUpperCase();
```
```text
¶src/a.ts#1a2b
4:const clean = (name || DEF).trim();
```
BOF/EOF examples:
```text
¶src/a.ts
BOF↓
const HEADER = true;
BOF↓const HEADER = true;
```
```text
¶src/a.ts
EOF↓
export const done = true;
EOF↓export const done = true;
```
Delete / blank examples:
```text
¶src/a.ts
4fb→
¶src/a.ts#1a2b
4!
```
```text
¶src/a.ts
4fb→
¶src/a.ts#1a2b
4:
```
EOF↓
export const done = true;
```text
¶src/a.ts#1a2b
4-6!
```
Multi-file example:
```text
¶src/a.ts#1a2b
4:const enabled = true;
¶src/b.ts#3c4d
20!
```
## Side Effects
@@ -173,50 +187,51 @@ export const done = true;
## Limits & Caps
- Default mode is `hashline` (`DEFAULT_EDIT_MODE`) in `packages/coding-agent/src/utils/edit-mode.ts`.
- Anchor hashes are always 2 lowercase letters from a stable 647-entry bigram table (`HL_BIGRAMS_COUNT`) in `packages/coding-agent/src/hashline/hash.ts`.
- File hashes are 4 lowercase hex chars from `computeFileHash()` in `packages/coding-agent/src/hashline/hash.ts`.
- The visible mismatch report shows 2 lines of context on each side (`MISMATCH_CONTEXT`) in `packages/coding-agent/src/hashline/constants.ts`.
- Stale-anchor recovery uses `fuzzFactor: 3` (`HASHLINE_RECOVERY_FUZZ_FACTOR`) in `packages/coding-agent/src/hashline/recovery.ts`.
- Stale-anchor recovery uses `fuzzFactor: 0` (`HASHLINE_RECOVERY_FUZZ_FACTOR`) in `packages/coding-agent/src/hashline/recovery.ts`.
- The per-session read cache keeps at most 30 paths (`MAX_PATHS_PER_SESSION`) in `packages/coding-agent/src/edit/file-read-cache.ts`.
- Hashline streaming chunk defaults are 200 lines or 64 KiB per chunk (`packages/coding-agent/src/hashline/types.ts`, consumed by `packages/coding-agent/src/hashline/stream.ts`).
- `HL_OP_INSERT_BEFORE` is `↑`, `HL_OP_INSERT_AFTER` is `↓`, `HL_OP_REPLACE` is `→`, `HL_OP_CHARS` is `↑↓→`, `HL_FILE_PREFIX` is `¶`, and `HL_BODY_SEP` is `|` (`packages/coding-agent/src/hashline/hash.ts`).
- `HL_OP_INSERT_BEFORE` is `↑`, `HL_OP_INSERT_AFTER` is `↓`, `HL_OP_REPLACE` is `:`, `HL_OP_DELETE` is `!`, `HL_OP_CHARS` is `↑↓:!`, `HL_FILE_PREFIX` is `¶`, `HL_FILE_HASH_SEP` is `#`, and `HL_LINE_BODY_SEP` is `:` (`packages/coding-agent/src/hashline/hash.ts`).
## Errors
- Missing section header:
- `input must begin with "¶PATH" on the first non-blank line; got: ... Example: "¶src/foo.ts" then edit ops.`
- `input must begin with "¶PATH#HASH" on the first non-blank line for anchored edits; got: ...`
- Empty header:
- `Input header "¶" is empty; provide a file path.`
- Missing hash for anchored edit:
- `Missing hashline file hash for anchored edit to <path>; use ¶<path>#hash from your latest read.`
- Line-hash anchors in edit ops:
- `line N: edit ops use bare line numbers. Copy the ¶PATH#hash header, then use anchors like 42, 42-45, BOF, or EOF.`
- Bad anchor token:
- `line N: expected a full anchor such as "119sr"; got "...".`
- `line N: expected a line number such as "119"; got "...".`
- Bad range syntax:
- `line N: range must be ANCHOR or ANCHOR-ANCHOR (one dash, no spaces); got ...`
- `line N: range must be LINE or LINE-LINE (one dash, no spaces); got ...`
- `line N: range A-B ends before it starts.`
- `line N: range A-B uses two different hashes for the same line.`
- Missing payload for `↓` / `↑`:
- `line N: ↑ and ↓ operations require at least one verbatim payload line.`
- Payload forbidden for `!`:
- `line N: ! deletes only. Payload is forbidden after !; use : to replace.`
- Stray payload line:
- `line N: payload line has no preceding ↑, ↓, or → operation.`
- `line N: payload line has no preceding ↑, ↓, :, or ! operation.`
- Unknown op:
- `line N: unrecognized op. Use ANCHOR↑ (insert before), ANCHOR↓ (insert after), or A-B→ (replace/delete).`
- Delete vs blank:
- `A-B→` with no payload deletes. To blank in place, include one explicit empty payload line before the next op/header/EOF.
- `line N: unrecognized op. Use LINE↑ (insert before), LINE↓ (insert after), LINE: / A-B: (replace), or LINE! / A-B! (delete).`
- Missing file for anchor-scoped edits:
- `File not found: <path>`
- Out-of-range anchor:
- `Line N does not exist (file has M lines)`
- Stale anchors throw `HashlineMismatchError`. The error message contains re-read guidance and reprints nearby current file lines as `LINEhh|TEXT`; mismatched lines are marked `*`. `displayMessage` renders the same information in a code-frame style.
- Stale file hash throws `HashlineMismatchError`. The error contains both hashes, re-read guidance, and nearby current file lines as `*LINE:TEXT` / ` LINE:TEXT`.
- No-op edit:
- `Edits to <path> resulted in no changes being made.`
- Recovery failure is silent internally: if cache-based merge cannot prove a valid result, the original mismatch error is surfaced unchanged.
- Recovery failure is silent internally: if cache-based merge cannot prove a valid result, the mismatch error is surfaced unchanged.
## Notes
- `read` and `search` are the authoritative source of anchors. The edit parser does not want the trailing `|TEXT`; copy only the `LINEhh` token.
- `read` and `search` are the authoritative source of section hashes. Copy `¶PATH#HASH`; op lines use bare line numbers and do not want the trailing `:TEXT`.
- Multi-op patches are parsed against the original file snapshot. Do not renumber later anchors after earlier ops; `applyHashlineEdits()` buckets and applies them bottom-up.
- `A-B→` is not a primitive replace in the parser. With payload, it expands to inserts before `A` plus deletes for `A-B`; with no payload, it only deletes `A-B`. To blank in place, include one explicit empty payload line. Stale-anchor checking still happens on the original range lines.
- Interior lines of a multi-line range use hash `**` (`RANGE_INTERIOR_HASH`) and are not individually verified; only the first and last anchor hashes are checked.
- `computeLineHash()` trims trailing whitespace before hashing. Anchors survive line-ending changes and trailing-space-only changes, but not substantive line edits.
- For punctuation-only lines, the hash mixes in the line number; identical `}` lines on different lines intentionally get different anchors.
- `splitHashlineInputs()` normalizes absolute `¶PATH` headers back to a cwd-relative path when the file is inside the current working tree. Headers with any run of leading `¶` chars (e.g. `¶foo.ts`, `¶¶foo.ts`, `¶¶¶foo.ts`) are accepted; the canonical form is `¶PATH`.
- Optional `*** Begin Patch` / `*** End Patch` markers are accepted in hashline mode, but the file sections are still `¶PATH`-based, not Codex `*** Update File:` hunks.
- Failed hand-edits often come from sequentially shifting later anchors inside the same patch. Treat every op as using the line numbers from the original section header.
- `A-B:` is not a primitive replace in the parser. With payload, it expands to inserts before `A` plus deletes for `A-B`. `A-B!` is the direct delete form. Bare `A:` / `A-B:` (no payload) replaces with a single blank line; bare `↑` / `↓` insert a blank line.
- Inline payload tip: trailing whitespace on the op line is trimmed. To preserve trailing spaces in the inserted/replacement content, put that content on the next line instead of inline.
- `computeFileHash()` normalizes CR characters and trailing whitespace before hashing. The section survives line-ending and trailing-space-only changes, but not substantive file edits.
- `splitHashlineInputs()` normalizes absolute `¶PATH#HASH` headers back to a cwd-relative path when the file is inside the current working tree. Headers with any run of leading `¶` chars (e.g. `¶foo.ts`, `¶¶foo.ts`, `¶¶¶foo.ts`) are accepted; the canonical form is `¶PATH#HASH` for anchored edits.
- Optional `*** Begin Patch` / `*** End Patch` markers are accepted in hashline mode, but the file sections are still `¶PATH#HASH`-based, not Codex `*** Update File:` hunks.
- `*** Abort` terminates parsing early and returns `ABORT_WARNING`; ops parsed before the marker still apply.
- File-read cache invalidation is conflict-based, not write-through invalidation. If `read` later records content for a line that disagrees with the cached snapshot, the entire snapshot for that path is replaced with the newly observed lines (`packages/coding-agent/src/edit/file-read-cache.ts`).
- There is no resolve-style apply/discard phase for hashline edits. The only preview path is the transient TUI diff preview in `packages/coding-agent/src/edit/streaming.ts`.
+26 -18
View File
@@ -90,21 +90,22 @@ Side-channel artifacts:
- `js` is gated on `eval.js !== false`.
- A disabled or unavailable requested backend throws `ToolError`; there is no auto-fallback or sniffing.
3. The tool allocates an `OutputSink`, a `TailBuffer`, per-cell result objects, and a `sessionAbortController`. `session.trackEvalExecution?.(...)` can wrap the whole run for external cancellation tracking.
4. Cells execute sequentially. For each cell, `execute()`:
4. It resolves the executor session id from `session.getEvalSessionId?.()`, falling back to `defaultEvalSessionId(session)`. Subagents inherit the parent's id so both sides share the same JS VM and Python kernel for each backend.
5. Cells execute sequentially within one eval tool call. For each cell, `execute()`:
- clamps `(cell.timeout ?? 30) * 1000` ms through `clampTimeout("eval", ...)`
- builds a combined abort signal from the tool signal, the timeout, and the session abort controller
- marks the cell `running` and emits an update
- calls the backend’s `execute()` with `cwd`, `sessionId`, `sessionFile`, `kernelOwnerId`, `deadlineMs`, `reset` (defaults to `false`), artifact info, and chunk callback
5. JS cells dispatch through `packages/coding-agent/src/eval/js/index.ts` into `executeJs()`; Python cells dispatch through `packages/coding-agent/src/eval/py/index.ts` into `executePython()`.
6. Backend text chunks stream into the shared `OutputSink`; rich outputs are accumulated separately as JSON, images, markdown markers, and status events.
7. After each cell:
6. JS cells dispatch through `packages/coding-agent/src/eval/js/index.ts` into `executeJs()`; Python cells dispatch through `packages/coding-agent/src/eval/py/index.ts` into `executePython()`.
7. Backend text chunks stream into the shared `OutputSink`; rich outputs are accumulated separately as JSON, images, markdown markers, and status events.
8. After each cell:
- text output is trimmed and stored on that cell result
- multi-cell runs prefix text with `[i/n]` and the optional title
- cancellations return early with `isError: true` and a cell-specific abort message
- non-zero exit codes return early with `isError: true` and a message naming the failed cell
- later cells are skipped after the first error, but earlier cell state persists in the underlying runtime
8. On success, the tool joins all cell outputs, synthesizes `(no text output)` or `(no output)` when needed, and attaches truncation metadata from `summarizeFinal()`.
9. The renderer uses `details.cells`, `details.jsonOutputs`, and `details.statusEvents` to build notebook-style output. `mergeCallAndResult = true` and `inline = true`, so call and result render together in the transcript.
9. On success, the tool joins all cell outputs, synthesizes `(no text output)` or `(no output)` when needed, and attaches truncation metadata from `summarizeFinal()`.
10. The renderer uses `details.cells`, `details.jsonOutputs`, and `details.statusEvents` to build notebook-style output. `mergeCallAndResult = true` and `inline = true`, so call and result render together in the transcript.
## Modes / Variants
@@ -121,8 +122,8 @@ If the requested backend is disabled or unavailable, the tool throws `ToolError`
Implemented in `packages/coding-agent/src/eval/js/context-manager.ts` and `packages/coding-agent/src/eval/js/prelude.txt`.
- Persistent `vm.Context` instances keyed by `js:${sessionId}` in `vmContexts`
- `reset: true` calls `resetVmContext(sessionKey)` before the cell executes
- Persistent worker-backed VM sessions keyed by `js:${sessionId}`
- `reset: true` calls `resetVmContext(sessionKey)` before the cell executes; reset is destructive for all live runs on that JS session
- Top-level `await` and bare `return` are supported by wrapping code in an async IIFE when `wrapCode()` sees `await` or `return`
- Top-level static `import ... from ...` and dynamic `import(...)` calls are routed through `rewriteImports()`, which sends them via `__omp_import__` so the specifier resolves against the session cwd
- Module cache is busted for **local** imports between cells so edits to source files are picked up without restarting the runtime. `__omp_import__` deletes `require.cache[absPath]` before re-importing whenever the original specifier is a filesystem path: relative (`./x`, `../x`, `.`, `..`), POSIX-absolute (`/...`), home-prefixed (`~/...`), or Windows drive-letter (`C:\...` / `C:/...`). Bare specifiers (`react`, `lodash/x`) and URL/scheme specifiers (`node:fs`, `file://...`, `https://...`) are left in cache so package identity stays stable across cells. The cache-bust only fires when the resolved target is an absolute path — unresolved bare-package fallbacks (`resolveImportSpecifier()` returning the original specifier) skip it.
@@ -136,7 +137,7 @@ Implemented in `packages/coding-agent/src/eval/js/context-manager.ts` and `packa
- `{ type: "image", data, mimeType }` becomes an image output
- scalars become text
- The VM exposes a restricted `process` subset plus `Buffer`, `fetch`, `Blob`, `File`, `Headers`, `Request`, `Response`, `fs`, `require`, and browser-style globals
- Per-session VM runs are serialized with `runQueued()`
- Concurrent runs on the same VM are not queued end-to-end. Synchronous JS still runs on the single event loop; awaited regions can interleave with sibling runs.
### Python runtime
@@ -150,9 +151,9 @@ Implemented in `packages/coding-agent/src/eval/py/executor.ts`, `packages/coding
- create/connect kernel
- initialize cwd / env / `sys.path`
- execute `PYTHON_PRELUDE`
- Python cells run inside IPython/Jupyter, so top-level `await` works; the prompt warns not to use `asyncio.run(...)`
- The Python prelude defines synchronous helpers with the same surface as JS (except `tool.<name>` exists only in JS)
- `display(value)` wraps dict/list/tuple values in `IPython.display.JSON`; rich display MIME bundles are preserved
- Python cells run in the runner's persistent asyncio event loop, so top-level `await` works; the prompt warns not to use `asyncio.run(...)`
- The Python prelude defines helpers with the same surface as JS where practical, including `tool.<name>(args)` through a per-run loopback bridge
- Synchronous statement blocks run in the default executor with ContextVar state copied in; the GIL still serializes bytecode execution, but awaited regions can interleave with sibling cells
- Kernel `display_data` / `execute_result` messages map to:
- `application/x-omp-status` → status event
- `image/png` → image output
@@ -184,14 +185,14 @@ A single tool call can mix Python and JS cells. Persistence is per language runt
- Session state
- `session.assertEvalExecutionAllowed?.()` can block execution.
- `session.trackEvalExecution?.(...)` can register cancellable eval work.
- `session.getSessionFile?.()` and `session.getEvalKernelOwnerId?.()` influence kernel reuse and artifact lookup.
- JS VM contexts persist in `vmContexts` across eval calls until reset/disposal.
- Python retained kernels persist in `kernelSessions` until reset, eviction, idle cleanup, or owner cleanup.
- `session.getSessionFile?.()`, `session.getEvalSessionId?.()`, and `session.getEvalKernelOwnerId?.()` influence VM/kernel reuse and artifact lookup.
- JS VM contexts persist across eval calls until reset/disposal.
- Python retained kernels persist until reset, owner cleanup, or process exit.
- User-visible prompts / interactive UI
- none; stdin requests are rejected programmatically
- Background work / cancellation
- Python retained kernels have heartbeat and idle cleanup timers.
- Cancellation interrupts a running Python kernel and aborts JS promise waits.
- Cancellation hard-kills/resets the shared executor for that backend: JS terminates the worker, Python sends SIGINT and may escalate to subprocess shutdown.
## Limits & Caps
@@ -224,14 +225,21 @@ A single tool call can mix Python and JS cells. Persistence is per language runt
- Cancellation is returned, not thrown, once backend execution has started. The tool formats it as a cell failure and sets `details.isError = true`.
- If output truncates, the tool still succeeds; truncation is surfaced through `details.meta` and artifact-backed full output when available.
## Shared executor trade-offs
- Parent agents and subagents share eval state bidirectionally when a subagent inherits the parent's executor id. Mutations in either direction are visible to the other participant.
- Async regions of concurrent runs can interleave. Synchronous JS still blocks the VM event loop; synchronous Python still contends on the GIL.
- Cancelling one run is destructive to the shared backend executor. This is intentional: JS worker termination and Python SIGINT/subprocess shutdown are the only reliable way to interrupt arbitrary user code.
- `reset: true` is destructive for every live run on that backend session id. New starts on that backend are rejected while reset is in flight.
## Notes
- Backend selection is now strictly explicit per cell: `language` must be `"py"` or `"js"`. The previous `*** Cell` header parser, the `eval.lark` constrained grammar, and the sniffer-based fallback have all been removed.
- `EvalTool.customFormat` no longer exists. Tool calls flow through the standard JSON schema; there is no Lark-constrained sampling path.
- `tool.<name>()` exists only in JS. Python prelude helpers do not call back into the full tool registry.
- `tool.<name>()` exists in both JS and Python. Python calls route through a per-run loopback bridge keyed by the current cell id.
- JS helper paths reject protocol URIs (`://`) in `resolvePath()`; the JS prelude is filesystem-only unless the code calls `tool.read(...)` or another tool explicitly.
- Python helper `output(...)` depends on `PI_SESSION_FILE`; it fails outside a session-backed run.
- `display()` can produce text and structured outputs from the same value; the renderer prefers markdown over `text/plain` when both exist.
- JS static imports are rewritten only at top level. Nested imports stay invalid and surface normal JS syntax/runtime errors.
- `EvalTool` is `concurrency = "exclusive"`, so eval calls do not overlap within a session.
- `EvalTool` is `concurrency = "exclusive"` within one agent session, but parent and subagent sessions can run eval concurrently when they share an inherited executor id.
- The tool description shown to the model is templated by backend availability (`getEvalToolDescription()`); if Python is unavailable, the prompt omits Python-specific instructions.
+3 -3
View File
@@ -101,11 +101,11 @@ URL selectors are parsed separately in `packages/coding-agent/src/tools/fetch.ts
- Default open-ended limit is `min(session setting read.defaultLimit, DEFAULT_MAX_LINES)`.
- Explicit ranges expand by `RANGE_LEADING_CONTEXT_LINES = 1` / `RANGE_TRAILING_CONTEXT_LINES = 3` on the constrained sides only.
- Non-raw output uses `resolveFileDisplayMode()`:
- hashline anchors when edit mode is hashline, read is not raw, source is mutable, edit tool exists, and `readHashLines !== false`
- hashline numbered output when edit mode is hashline, read is not raw, source is mutable, edit tool exists, and `readHashLines !== false`
- otherwise optional line numbers when `readLineNumbers === true`
- raw mode suppresses both
- Prefix format in hashline mode is `lineNumber + 2-char line hash + "|"`, e.g. `41th|def alpha():`, from `formatHashLine()` in `packages/coding-agent/src/hashline/hash.ts`.
- Those anchors are what the `edit`/hashline path consumes later; immutable sources and `:raw` intentionally suppress them.
- Prefix format in hashline mode is a `¶PATH#HASH` header followed by `LINE:TEXT`, e.g. `¶src/foo.ts#1a2b` and `41:def alpha():`, from `computeFileHash()` / `formatNumberedLine()` in `packages/coding-agent/src/hashline/hash.ts`.
- The `edit`/hashline path consumes that header plus bare line numbers later; immutable sources and `:raw` intentionally suppress them.
### Directory listings
- `#readDirectory()` calls `buildDirectoryTree()` with:
+8 -7
View File
@@ -21,7 +21,7 @@
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `pattern` | `string` | Yes | Regex pattern. `search.ts` trims it and rejects empty input. The native matcher enables multiline only when the pattern text contains a literal newline or the two-character sequence `\\n`. The model prompt explicitly documents literal-brace escaping such as ``interface\\{\\}``, although the native layer also auto-escapes braces that cannot be valid repetition quantifiers. |
| `paths` | `string[]` | Yes | One or more file paths, directory paths, glob-like paths, or internal URLs. Empty strings are rejected after trimming/quote stripping. Internal URLs must resolve to a backing file and cannot contain glob characters. |
| `paths` | `string \| string[]` | Yes | One file path, directory path, glob-like path, internal URL, or an array of those. Empty strings are rejected after trimming/quote stripping. Internal URLs must resolve to a backing file and cannot contain glob characters. |
| `i` | `boolean` | No | Case-insensitive search. Defaults to `false`. Passed to native `ignoreCase`. |
| `gitignore` | `boolean` | No | Respect `.gitignore` during directory scans. Defaults to `true`. Passed to native `gitignore`. |
| `skip` | `number` | No | Global match offset. Defaults to `0`. `search.ts` floors finite numbers and rejects negative or non-finite values. |
@@ -29,8 +29,8 @@
## Outputs
The tool returns a single text block in `content[0].text` plus structured `details`.
- Match lines are formatted by `formatMatchLine()` as `*<anchor>|<line>` for matches and ` <anchor>|<line>` for context.
- Hashline mode: `*5th|content`, ` 9x}|content`.
- Match lines are formatted by `formatMatchLine()` as `*LINE:content` for matches and ` LINE:content` for context under a `¶PATH#HASH` header in hashline mode.
- Hashline mode: `¶src/login.ts#3c4d`, `*5:content`, ` 9:content`.
- Plain mode: `*5|content`, ` 9|content`.
- Directory results are grouped by file, with `# <path>` headings and blank lines between groups.
- `details` may include:
@@ -49,12 +49,13 @@ The tool returns a single text block in `content[0].text` plus structured `detai
- trims `pattern`, rejects empty patterns;
- normalizes `skip` to a non-negative integer;
- reads `search.contextBefore` and `search.contextAfter` from session settings (`1` and `3` by default);
- enables multiline only when `pattern` contains `\n` or an actual newline.
- enables multiline only when `pattern` contains `\n` or an actual newline;
- wraps a single string `paths` value into a one-element list before path resolution.
2. Each `paths` entry is normalized with `normalizePathLikeInput()`.
3. Internal URLs are resolved through `session.internalRouter`:
- glob metacharacters (`*`, `?`, `[`, `{`) are rejected for internal URLs;
- URLs without `resource.sourcePath` fail;
- immutable sources are tracked so output can suppress editable hashline anchors per file.
- immutable sources are tracked so output can suppress editable hashline numbered output per file.
4. For multi-path calls, `partitionExistingPaths()` skips only ENOENT entries. If every entry is missing, the tool errors.
5. Path resolution branches:
- one entry: `parseSearchPath()` splits `basePath` and optional glob;
@@ -116,7 +117,7 @@ The tool returns a single text block in `content[0].text` plus structured `detai
## Limits & Caps
- Visible page limit: `100` matches (`DEFAULT_MATCH_LIMIT` in `packages/coding-agent/src/tools/search.ts`).
- Native preselection limit: `500` matches (`internalLimit = Math.min(DEFAULT_MATCH_LIMIT * 5, 2000)` in `packages/coding-agent/src/tools/search.ts`).
- Line truncation: `1024` characters per emitted line (`DEFAULT_MAX_COLUMN` in `packages/coding-agent/src/session/streaming-output.ts`). Native grep marks truncated lines; JS reports `linesTruncated`.
- Line truncation: `512` characters per emitted line (`DEFAULT_MAX_COLUMN` in `packages/coding-agent/src/session/streaming-output.ts`). Native grep marks truncated lines; JS reports `linesTruncated`.
- Final text truncation: `truncateHead()` default byte cap `50 * 1024` bytes (`DEFAULT_MAX_BYTES` in `packages/coding-agent/src/session/streaming-output.ts`). `search.ts` overrides `maxLines` to `Number.MAX_SAFE_INTEGER`, so normal search output is byte-capped, not line-capped.
- Context defaults: `search.contextBefore = 1`, `search.contextAfter = 3` in `packages/coding-agent/src/config/settings-schema.ts`.
- Pagination: `skip` is a global match offset. In single-base searches it is pushed into native `offset`; in exact-file/multi-target aggregation it is applied in JS with `matches.slice(skip)`.
@@ -140,4 +141,4 @@ The tool returns a single text block in `content[0].text` plus structured `detai
- `hidden:true` is hard-coded in `search.ts`; there is no model-facing flag to exclude dotfiles.
- `gitignore:false` only affects native directory traversal. It does not disable the tool's own path normalization or explicit-file handling.
- When `paths` resolves to multiple exact files, `search.ts` does not apply the native `500` match cap and reports `totalMatches` internally as the post-skip length for that branch.
- The anchor suffix in hashline mode comes from `computeLineHash()` in `packages/coding-agent/src/hashline/hash.ts`; `search` itself only formats it.
- The section hash in hashline mode comes from `computeFileHash()` in `packages/coding-agent/src/hashline/hash.ts`; `search` emits bare line numbers beneath it.
+1 -1
View File
@@ -49,7 +49,7 @@ Single-shot result.
- Archive writes return empty `details`.
## Flow
1. `WriteTool.execute()` in `packages/coding-agent/src/tools/write.ts` strips `LINE+ID|` hashline prefixes from `content` when the session is in hashline display mode.
1. `WriteTool.execute()` in `packages/coding-agent/src/tools/write.ts` strips pasted `¶PATH#HASH` headers and `LINE:` hashline prefixes from `content` when the session is in hashline display mode.
2. It calls `#resolveArchiveWritePath()` first. That uses `parseArchivePathCandidates()` from `packages/coding-agent/src/tools/archive-reader.ts`, checks candidate archive files on disk, and falls back to the longest matching archive suffix even when the archive file does not exist yet.
3. Archive writes call `enforcePlanModeWrite(..., { op: exists ? "update" : "create" })`, then `#writeArchiveEntry()`.
- The parent directory of the archive file is created with `fs.mkdir(..., { recursive: true })`.
+7 -7
View File
@@ -20,13 +20,13 @@
"@bufbuild/protoc-gen-es": "^2.12.0",
"@mozilla/readability": "^0.6.0",
"@napi-rs/cli": "3.6.2",
"@oh-my-pi/omp-stats": "15.3.2",
"@oh-my-pi/pi-agent-core": "15.3.2",
"@oh-my-pi/pi-ai": "15.3.2",
"@oh-my-pi/pi-coding-agent": "15.3.2",
"@oh-my-pi/pi-natives": "15.3.2",
"@oh-my-pi/pi-tui": "15.3.2",
"@oh-my-pi/pi-utils": "15.3.2",
"@oh-my-pi/omp-stats": "15.4.3",
"@oh-my-pi/pi-agent-core": "15.4.3",
"@oh-my-pi/pi-ai": "15.4.3",
"@oh-my-pi/pi-coding-agent": "15.4.3",
"@oh-my-pi/pi-natives": "15.4.3",
"@oh-my-pi/pi-tui": "15.4.3",
"@oh-my-pi/pi-utils": "15.4.3",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/context-async-hooks": "^2.0.0",
"@opentelemetry/sdk-trace-base": "^2.0.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "@oh-my-pi/pi-agent-core",
"version": "15.3.2",
"version": "15.4.3",
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
"homepage": "https://omp.sh",
"author": "Can Boluk",
+1 -1
View File
@@ -38,7 +38,7 @@ const SCRIPT_CLASS =
const SCRIPT_RUN_RE = new RegExp(`[${SCRIPT_CLASS}]{2,}`, "u");
// Recovery registry. Each entry's parser must recognize the configured
// sentinel (per-tool, see eval/parse.ts and hashline/parser.ts) and surface
// sentinel (per-tool, see eval/parse.ts and hashline/executor.ts) and surface
// a warning to the model so it knows to re-issue any remaining work.
// `accepts` gates on input shape: tools whose contaminated input doesn't
// match the parser's expected DSL fall through to abort-and-retry.
+57
View File
@@ -1,6 +1,60 @@
# Changelog
## [Unreleased]
### Removed
- Removed the `pi-ai` CLI binary (`packages/ai/src/cli.ts`) and its `bin` entry. Use the in-process equivalent in the omp coding-agent CLI: `omp auth-broker login [provider]`, `omp auth-broker logout [provider]`, and `omp auth-broker list`. The library API (`AuthStorage.login()`, `getOAuthProviders()`, etc.) is unchanged.
### Fixed
- Fixed usage polling to disable credentials when OAuth refresh fails definitively (for example `invalid_grant`) and clear cached last-good usage data so stale reports no longer remain visible
## [15.4.3] - 2026-05-26
### Fixed
- Fixed Google Vertex model discovery to use the project-scoped OpenAI-compatible model list so Vertex Model Garden models such as GLM and Claude are available through ADC auth ([#1412](https://github.com/can1357/oh-my-pi/issues/1412)).
## [15.4.2] - 2026-05-26
### Fixed
- Fixed OpenCode Zen `big-pickle` follow-up requests replaying assistant tool-call turns without DeepSeek-required `reasoning_content`, which caused HTTP 400 errors in thinking mode.
## [15.4.1] - 2026-05-26
### Added
- Added `isOpenAICompletionsProgressChunk` export to identify real progress chunks vs. keepalives in OpenAI completions streams
- Added per-provider stream watchdog overrides via `getStreamIdleTimeoutMs(fallbackMs)` and `getStreamFirstEventTimeoutMs(idleTimeoutMs, fallbackMs)` to allow providers like Google Gemini CLI to extend first-event timeouts without affecting global defaults
- Added `promptCacheKey` to `StreamOptions` and passed it through stream option mapping so callers can specify an explicit prompt-cache key separate from `sessionId`
- Added `promptCacheKey` support to the native server option whitelist so `promptCacheKey` is accepted by `pi-native-server` streams
- Restored the per-provider stream watchdog (`iterateWithIdleTimeout`) on top of the abortable iterator. The lazy stream forwarder in `register-builtins` now wraps every provider's event stream with the first-event + steady-state idle watchdog (`PI_STREAM_FIRST_EVENT_TIMEOUT_MS`, `PI_STREAM_IDLE_TIMEOUT_MS`; aliases honored), and Anthropic / OpenAI Completions / OpenAI Responses / Azure OpenAI Responses / Codex SSE re-emit their per-provider progress predicates so empty keepalive frames cannot keep a stalled stream alive. Reverts the partial regression from #1392 that left Codex WebSocket subagent runs hanging silently for hours when the broker dropped frames between deltas. The Codex WebSocket transport additionally now resets `lastProgressAt` only on progress events (not keepalives), giving the 300s WS-internal idle ceiling the same liveness semantics as the SSE path.
### Changed
- Enabled OpenAI Codex WebSocket streams to apply `streamIdleTimeoutMs` and `streamFirstEventTimeoutMs` from `StreamOptions` per request instead of fixed internal defaults
- Changed stream idle watchdog implementation from `iterateUntilAbort` to `iterateWithIdleTimeout`, which now enforces maximum idle gaps between streamed events and distinguishes between first-event and steady-state timeouts
- Changed Anthropic, OpenAI Responses, OpenAI Completions, Azure OpenAI Responses, and OpenAI Codex Responses providers to use the new idle-timeout iterator with per-provider progress predicates so empty keepalive frames cannot keep a stalled stream alive
- Changed Codex WebSocket transport to reset `lastProgressAt` only on progress events (not keepalives), giving the 300s WS-internal idle ceiling the same liveness semantics as the SSE path
- Changed Google Gemini CLI stream forwarding defaults to use a 5-minute first-event floor via per-provider lazy-stream limits to avoid premature first-event timeouts on slow startup
- Changed OpenAI Responses and OpenAI Codex request handling to keep `sessionId` for provider routing and conversation headers while `promptCacheKey` controls the `prompt_cache_key` payload independently
- Changed `StreamOptions.streamIdleTimeoutMs` documentation to clarify it is now wired into every built-in provider and the lazy stream forwarder, and that `streamFirstEventTimeoutMs` is honored at both the SDK-request layer and the iterator-watchdog layer
- Changed OpenAI Responses and OpenAI Codex request handling so `sessionId` continues to drive provider routing and state while `promptCacheKey` controls the `prompt_cache_key` payload
- Changed Google Gemini CLI stream forwarding defaults to use a 5-minute first-event floor to avoid premature first-event timeouts on slow startup
- Changed auth-gateway request mapping to preserve incoming `prompt_cache_key` as both `promptCacheKey` and `sessionId` when routing OpenAI-compatible sessions
- Un-deprecated `StreamOptions.streamIdleTimeoutMs`; the option is wired into every built-in provider and the lazy stream forwarder again. `streamFirstEventTimeoutMs` is now honored at both the SDK-request layer (via `createSdkStreamRequestOptions`) and the iterator-watchdog layer, in cooperation.
### Removed
- Removed `installH2Fetch` and the `fetch` patch that forced HTTP/2 on HTTPS requests; callers now use the default Bun `fetch` transport
### Fixed
- Fixed first-item timeout handling so `iterateWithIdleTimeout` no longer keeps first-event timers active after the source throws or the consumer stops before semantic progress
- Fixed silent multi-hour hangs on Codex WebSocket subagent runs when the broker dropped frames between deltas by restoring per-provider stream watchdogs with progress-event filtering
- Fixed z.ai/GLM-via-OpenRouter subagent stalls where no-op keepalive chunks reset the idle watchdog indefinitely by filtering non-progress items before resetting the deadline
## [15.4.0] - 2026-05-26
### Breaking Changes
- Removed `findAnthropicAuth` from `anthropic-auth` and replaced store-driven auth discovery with `buildAnthropicAuthConfig`, requiring callers to provide an already-resolved API key before building Anthropic auth config
@@ -9,6 +63,7 @@
- Added `PI_CODEX_WEBSOCKET_FIRST_EVENT_TIMEOUT_MS` and `PI_CODEX_WEBSOCKET_IDLE_TIMEOUT_MS` options to tune Codex WebSocket timeout behavior before fallback
- Added `AuthStorage.getOAuthAccess` to return a refreshed OAuth access token with identity metadata (`accountId`, `email`, `projectId`, `enterpriseUrl`) for callers that need bearer-token headers together
- Added Codex WebSocket forwarding to the `onSseEvent` observer so the raw provider-stream debug viewer captures the inbound JSON frames and the outbound request frame from the WS transport using the same synthesized SSE-wire shape (`event:` + `data:` lines, prefixed with a `: ws ← <type>` (inbound) or `: ws → <type>` (outbound) comment).
### Changed
@@ -26,12 +81,14 @@
### Fixed
- Dropped truncated, thinking-only assistant turns with only `thinking`/`redacted_thinking` blocks and no `text` or `tool` content during message transformation, preventing Anthropic requests from sending consecutive assistant messages after a `max_tokens`/`error`/`aborted` interruption
- Fixed Amazon Bedrock bearer-token authentication to honor `AWS_BEARER_TOKEN_BEDROCK` before resolving AWS profiles or running `credential_process`, matching Bedrock API-key precedence. ([#1399](https://github.com/can1357/oh-my-pi/issues/1399))
- Updated `isRetryableError` to treat Bun HTTP/2 transport errors (`HTTP2StreamReset`, `HTTP2RefusedStream`) as retryable so transient stream-reset failures can be retried
- Fixed Codex WebSocket streaming to recover from stalled sessions by falling back to SSE when the first event or subsequent progress is delayed beyond the configured websocket timeout
- Fixed expired OAuth handling so provider-level paths no longer attempt direct token refresh calls for expired credentials and instead rely on `AuthStorage` for rotation
- Fixed provider streams aborting slow-but-valid first tokens or silent inter-event gaps with OMP-owned first-event/idle watchdog errors. Built-in lazy streams, OpenAI/Anthropic/Azure/Codex SSE, and Codex WebSocket streams now wait for provider output, provider/socket errors, caller aborts, or explicit request-layer timeouts instead of treating provider silence as failure ([#1392](https://github.com/can1357/oh-my-pi/issues/1392)).
- Fixed Claude Opus 4.7 on Amazon Bedrock streaming no reasoning output (and appearing to hang on long reasoning runs) because Anthropic silently switched the adaptive-thinking display default to `"omitted"`. The Bedrock provider now sends `thinking.display = "summarized"` by default on Opus 4.7+ adaptive models and on budget-based Claude models, mirroring the existing direct-Anthropic behavior. `BedrockOptions.thinkingDisplay` (`"summarized" | "omitted"`) is exposed for callers that want to opt out, and `hideThinkingSummary` now wires through to the Bedrock case ([#1373](https://github.com/can1357/oh-my-pi/issues/1373)).
- Fixed Cursor Composer resume/tool-continuation turns failing with `Cannot send empty user message to Cursor API`. Empty current user turns now use Cursor's `resumeAction` instead of constructing an invalid `userMessageAction` ([#1376](https://github.com/can1357/oh-my-pi/issues/1376)).
- Fixed `pi-ai login moonshot` failing with `invalid temperature: only 1 is allowed for this model` (HTTP 400) because the API-key validator probed `kimi-k2.5` with `temperature: 0`. Moonshot login now validates against `GET /v1/models`, matching the DeepSeek/Fireworks/NanoGPT/ZenMux pattern and authenticating the key without invoking model-specific parameter restrictions.
## [15.3.2] - 2026-05-25
### Added
+6 -5
View File
@@ -1057,13 +1057,14 @@ Official docs: [Application Default Credentials](https://cloud.google.com/docs/a
### CLI Login
The quickest way to authenticate:
Authenticate via the [`omp`](https://omp.sh) coding-agent CLI, which drives this library's OAuth/API-key flows in-process and persists into `agent.db`:
```bash
bunx @oh-my-pi/pi-ai login # interactive provider selection
bunx @oh-my-pi/pi-ai login anthropic # login to specific provider
bunx @oh-my-pi/pi-ai login vllm # store vLLM API key (or placeholder for local no-auth)
bunx @oh-my-pi/pi-ai list # list available providers
omp auth-broker login # interactive provider selection
omp auth-broker login anthropic # login to a specific provider
omp auth-broker login vllm # store vLLM API key (or placeholder for local no-auth)
omp auth-broker list # list supported providers
omp auth-broker logout # interactive — pick a stored credential to remove
```
Credentials are saved to `agent.db` in the agent directory. `/login qianfan` opens the Qianfan console and stores the pasted API key.
+1 -4
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "@oh-my-pi/pi-ai",
"version": "15.3.2",
"version": "15.4.3",
"description": "Unified LLM API with automatic model discovery and provider configuration",
"homepage": "https://omp.sh",
"author": "Can Boluk",
@@ -28,9 +28,6 @@
],
"main": "./src/index.ts",
"types": "./src/index.ts",
"bin": {
"pi-ai": "./src/cli.ts"
},
"scripts": {
"check": "biome check . && bun run check:types",
"check:types": "tsgo -p tsconfig.json --noEmit",
+2 -12
View File
@@ -10,7 +10,7 @@
* snapshot pull surfaces a clean delete on the client.
*/
import { logger } from "@oh-my-pi/pi-utils";
import type { AuthStorage } from "../auth-storage";
import { type AuthStorage, isDefinitiveOAuthFailure } from "../auth-storage";
import { DEFAULT_REFRESH_INTERVAL_MS, DEFAULT_REFRESH_SKEW_MS } from "./types";
export interface AuthBrokerRefresherOptions {
@@ -23,16 +23,6 @@ export interface AuthBrokerRefresherOptions {
now?: () => number;
}
const INVALID_GRANT_REGEX = /invalid_grant|invalid_token|revoked|unauthorized|expired.*refresh|refresh.*expired/i;
const TRANSIENT_REGEX = /timeout|network|fetch failed|ECONNREFUSED/i;
const HTTP_401_403_REGEX = /\b(401|403)\b/;
function isDefinitiveFailure(errorMsg: string): boolean {
if (INVALID_GRANT_REGEX.test(errorMsg)) return true;
if (HTTP_401_403_REGEX.test(errorMsg) && !TRANSIENT_REGEX.test(errorMsg)) return true;
return false;
}
export interface AuthBrokerRefresherSchedule {
enabled: boolean;
intervalMs: number;
@@ -113,7 +103,7 @@ export class AuthBrokerRefresher {
await this.#storage.refreshCredentialById(id);
} catch (error) {
const errorMsg = String(error);
if (isDefinitiveFailure(errorMsg)) {
if (isDefinitiveOAuthFailure(errorMsg)) {
logger.warn("auth-broker refresh failed definitively; disabling credential", {
id,
error: errorMsg,
+3 -1
View File
@@ -145,7 +145,9 @@ function buildStreamOptions(parsed: ParsedFormatRequest, api: Api, signal: Abort
// Client-supplied `prompt_cache_key` wins; otherwise derive a stable
// key from the model + system + tools so prefix caching engages on
// Codex-class backends across turns of the same logical conversation.
opts.sessionId = options.promptCacheKey ?? deriveSessionId(parsed.modelId, parsed.context);
const promptCacheKey = options.promptCacheKey ?? deriveSessionId(parsed.modelId, parsed.context);
opts.promptCacheKey = promptCacheKey;
opts.sessionId = promptCacheKey;
if (options.thinkingBudgets) {
opts.thinkingBudgets = { ...(opts.thinkingBudgets ?? {}), ...options.thinkingBudgets };
}
+1 -1
View File
@@ -67,7 +67,7 @@ export interface AuthGatewayParsedRequestOptions {
serviceTier?: ServiceTier;
/** Cache retention hint derived from inbound `cache_control` markers. */
cacheRetention?: CacheRetention;
/** OpenAI Responses `prompt_cache_key`; bridges to pi-ai `sessionId`. */
/** OpenAI Responses `prompt_cache_key`; also seeds provider routing when no separate session id exists. */
promptCacheKey?: string;
/** OpenAI Responses `previous_response_id` for response chaining. */
previousResponseId?: string;
+66 -4
View File
@@ -414,6 +414,29 @@ const OAUTH_REFRESH_SKEW_MS = 60_000;
*/
const MAX_PENDING_DISABLED_EVENTS = 32;
/**
* Classify an OAuth refresh error as a definitive credential failure (the
* refresh token is dead — re-login required) versus a transient blip
* (network/5xx — retry next sweep).
*
* Anchored at module scope so all three refresh sites — in-stream
* {@link AuthStorage.getApiKey}, the usage probe in
* {@link AuthStorage.fetchUsageReports}, and the auth-broker background
* refresher — disable rows on the same criteria. A drifting classifier
* between sites would let stale last-good usage reports surface indefinitely
* while streaming requests correctly tear the row down.
*/
const OAUTH_DEFINITIVE_FAILURE_REGEX =
/invalid_grant|invalid_token|revoked|unauthorized|expired.*refresh|refresh.*expired/i;
const OAUTH_TRANSIENT_FAILURE_REGEX = /timeout|network|fetch failed|ECONNREFUSED/i;
const OAUTH_HTTP_AUTH_REGEX = /\b(401|403)\b/;
export function isDefinitiveOAuthFailure(errorMsg: string): boolean {
if (OAUTH_DEFINITIVE_FAILURE_REGEX.test(errorMsg)) return true;
if (OAUTH_HTTP_AUTH_REGEX.test(errorMsg) && !OAUTH_TRANSIENT_FAILURE_REGEX.test(errorMsg)) return true;
return false;
}
type UsageCacheEntry<T> = {
value: T;
expiresAt: number;
@@ -1832,9 +1855,50 @@ export class AuthStorage {
credential: refreshedCredential,
};
} catch (error) {
const errorMsg = String(error);
// Definitive failure (invalid_grant / 401 not from a network blip) means
// the refresh token itself is dead — probing with the original credential
// will 401, the catch below will return null, and #fetchUsageCached's
// last-good fallback will surface yesterday's report indefinitely
// (including its already-elapsed `resetsAt`). CAS-disable the row and
// clear the cache so the credential drops out of the report instead of
// freezing in place until the user notices and re-logs in.
if (isDefinitiveOAuthFailure(errorMsg)) {
const credentialId = this.#findStoredCredentialIdForUsageCredential(
request.provider,
request.credential,
);
if (credentialId !== undefined) {
const entries = this.#getStoredCredentials(request.provider);
const index = entries.findIndex(entry => entry.id === credentialId);
if (index !== -1) {
const disabled = this.#tryDisableCredentialAtIfMatches(
request.provider,
index,
refreshableCredential,
`oauth refresh failed during usage probe: ${errorMsg}`,
);
if (disabled) {
this.#usageLogger?.warn(
"Usage credential refresh failed definitively; credential disabled",
{ provider: request.provider, credentialId, error: errorMsg },
);
// Neutralize last-good for this cache key: write a null
// entry with an immediately-elapsed expiry so a future
// getStale lookup (e.g. on re-login under the same
// account identity) can't replay the stale report.
this.#usageCache.set(this.#buildUsageReportCacheKey(request), {
value: null,
expiresAt: 0,
});
return null;
}
}
}
}
this.#usageLogger?.debug("Usage credential refresh failed, using original credential", {
provider: request.provider,
error: String(error),
error: errorMsg,
});
}
}
@@ -2877,9 +2941,7 @@ export class AuthStorage {
const errorMsg = String(error);
// Only remove credentials for definitive auth failures
// Keep credentials for transient errors (network, 5xx) and block temporarily
const isDefinitiveFailure =
/invalid_grant|invalid_token|revoked|unauthorized|expired.*refresh|refresh.*expired/i.test(errorMsg) ||
(/\b(401|403)\b/.test(errorMsg) && !/timeout|network|fetch failed|ECONNREFUSED/i.test(errorMsg));
const isDefinitiveFailure = isDefinitiveOAuthFailure(errorMsg);
logger.warn("OAuth token refresh failed", {
provider,
-262
View File
@@ -1,262 +0,0 @@
#!/usr/bin/env bun
import * as readline from "node:readline";
import { AuthStorage, SqliteAuthCredentialStore } from "./auth-storage";
import { getOAuthProviders } from "./utils/oauth";
import type { OAuthProvider } from "./utils/oauth/types";
const PROVIDERS = getOAuthProviders();
function prompt(rl: readline.Interface, question: string): Promise<string> {
const { promise, resolve, reject } = Promise.withResolvers<string>();
const input = process.stdin as NodeJS.ReadStream;
const supportsRawMode = input.isTTY && typeof input.setRawMode === "function";
const wasRaw = supportsRawMode ? input.isRaw : false;
let settled = false;
const cleanup = () => {
rl.off("SIGINT", onSigint);
if (supportsRawMode) {
input.off("keypress", onKeypress);
input.setRawMode?.(wasRaw);
}
};
const finish = (result: () => void) => {
if (settled) return;
settled = true;
cleanup();
result();
};
const cancel = () => {
finish(() => reject(new Error("Login cancelled")));
};
const onSigint = () => {
cancel();
};
const onKeypress = (_str: string, key: readline.Key) => {
if (key.name === "escape" || (key.ctrl && key.name === "c")) {
cancel();
rl.close();
}
};
if (supportsRawMode) {
readline.emitKeypressEvents(input, rl);
input.setRawMode(true);
input.on("keypress", onKeypress);
}
rl.once("SIGINT", onSigint);
rl.question(question, answer => {
finish(() => resolve(answer));
});
return promise;
}
async function login(provider: OAuthProvider): Promise<void> {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const promptFn = (msg: string) => prompt(rl, `${msg} `);
const store = await SqliteAuthCredentialStore.open();
const storage = new AuthStorage(store);
await storage.reload();
try {
await storage.login(provider, {
onAuth(info) {
const { url, instructions } = info;
console.log(`\nOpen this URL in your browser:\n${url}`);
if (instructions) console.log(instructions);
console.log();
},
onProgress(message) {
console.log(message);
},
onPrompt(p) {
return promptFn(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`);
},
});
console.log(`\nCredentials saved to ~/.omp/agent/agent.db`);
} finally {
store.close();
rl.close();
}
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
const command = args[0];
if (!command || command === "help" || command === "--help" || command === "-h") {
console.log(`Usage: bunx @oh-my-pi/pi-ai <command> [provider]
Commands:
login [provider] Login to a provider
logout [provider] Logout from a provider
status Show logged-in providers
list List available providers
Providers:
anthropic Anthropic (Claude Pro/Max)
github-copilot GitHub Copilot
google-gemini-cli Google Gemini CLI
google-antigravity Antigravity (Gemini 3, Claude, GPT-OSS)
openai-codex OpenAI Codex (ChatGPT Plus/Pro)
kimi-code Kimi Code
kilo Kilo Gateway
kagi Kagi
tavily Tavily
zai Z.AI (GLM Coding Plan)
deepseek DeepSeek
nanogpt NanoGPT
minimax-code MiniMax Coding Plan (International)
minimax-code-cn MiniMax Coding Plan (China)
cursor Cursor (Claude, GPT, etc.)
zenmux ZenMux
ollama-cloud Ollama Cloud
Examples:
bunx @oh-my-pi/pi-ai login # interactive provider selection
bunx @oh-my-pi/pi-ai login anthropic # login to specific provider
bunx @oh-my-pi/pi-ai logout anthropic # logout from specific provider
bunx @oh-my-pi/pi-ai status # show logged-in providers
bunx @oh-my-pi/pi-ai list # list providers
`);
return;
}
if (command === "status") {
const storage = await SqliteAuthCredentialStore.open();
try {
const providers = storage.listProviders();
if (providers.length === 0) {
console.log("No credentials stored.");
console.log(`Use 'bunx @oh-my-pi/pi-ai login' to authenticate.`);
} else {
console.log("Logged-in providers:\n");
for (const provider of providers) {
const oauth = storage.getOAuth(provider);
if (oauth) {
const expires = new Date(oauth.expires);
const expired = Date.now() >= oauth.expires;
const status = expired ? "(expired)" : `(expires ${expires.toLocaleString()})`;
console.log(` ${provider.padEnd(20)} ${status}`);
continue;
}
const apiKey = storage.getApiKey(provider);
if (apiKey) {
console.log(` ${provider.padEnd(20)} (api key)`);
}
}
}
} finally {
storage.close();
}
return;
}
if (command === "list") {
console.log("Available providers:\n");
for (const p of PROVIDERS) {
console.log(` ${p.id.padEnd(20)} ${p.name}`);
}
return;
}
if (command === "logout") {
let provider = args[1] as OAuthProvider | undefined;
const storage = await SqliteAuthCredentialStore.open();
try {
if (!provider) {
const providers = storage.listProviders();
if (providers.length === 0) {
console.log("No credentials stored.");
return;
}
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
console.log("Select a provider to logout:\n");
for (let i = 0; i < providers.length; i++) {
console.log(` ${i + 1}. ${providers[i]}`);
}
console.log();
const choice = await prompt(rl, `Enter number (1-${providers.length}): `);
rl.close();
const index = parseInt(choice, 10) - 1;
if (index < 0 || index >= providers.length) {
console.error("Invalid selection");
process.exit(1);
}
provider = providers[index] as OAuthProvider;
}
if (!provider) {
console.error("No provider selected");
process.exit(1);
}
const oauth = storage.getOAuth(provider);
const apiKey = storage.getApiKey(provider);
if (!oauth && !apiKey) {
console.error(`Not logged in to ${provider}`);
process.exit(1);
}
storage.deleteProvider(provider);
console.log(`Logged out from ${provider}`);
} finally {
storage.close();
}
return;
}
if (command === "login") {
let provider = args[1] as OAuthProvider | undefined;
if (!provider) {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
console.log("Select a provider:\n");
for (let i = 0; i < PROVIDERS.length; i++) {
console.log(` ${i + 1}. ${PROVIDERS[i].name}`);
}
console.log();
const choice = await prompt(rl, `Enter number (1-${PROVIDERS.length}): `);
rl.close();
const index = parseInt(choice, 10) - 1;
if (index < 0 || index >= PROVIDERS.length) {
console.error("Invalid selection");
process.exit(1);
}
provider = PROVIDERS[index].id as OAuthProvider;
}
if (!provider) {
console.error("No provider selected");
process.exit(1);
}
if (!PROVIDERS.some(p => p.id === provider)) {
console.error(`Unknown provider: ${provider}`);
console.error(`Use 'bunx @oh-my-pi/pi-ai list' to see available providers`);
process.exit(1);
}
console.log(`Logging in to ${provider}…`);
await login(provider);
return;
}
console.error(`Unknown command: ${command}`);
console.error(`Use 'bunx @oh-my-pi/pi-ai --help' for usage`);
process.exit(1);
}
main().catch(err => {
console.error("Error:", err.message);
process.exit(1);
});
-1
View File
@@ -40,7 +40,6 @@ export * from "./usage/zai";
export * from "./utils/anthropic-auth";
export * from "./utils/discovery";
export * from "./utils/event-stream";
export * from "./utils/h2-fetch";
export * from "./utils/oauth";
export type {
OAuthCredentials,
File diff suppressed because it is too large Load Diff
@@ -6,7 +6,7 @@
import type { ModelManagerOptions } from "../model-manager";
import type { Api, KnownProvider } from "../types";
import type { OAuthProvider } from "../utils/oauth/types";
import { googleModelManagerOptions } from "./google";
import { googleModelManagerOptions, googleVertexModelManagerOptions } from "./google";
import { ollamaCloudModelManagerOptions } from "./ollama";
import {
alibabaCodingPlanModelManagerOptions,
@@ -283,6 +283,9 @@ export const PROVIDER_DESCRIPTORS: readonly ProviderDescriptor[] = [
catalogDescriptor("zai", "glm-5.1", config => zaiModelManagerOptions(config), catalog("zAI", ["ZAI_API_KEY"])),
descriptor("github-copilot", "gpt-4o", config => githubCopilotModelManagerOptions(config)),
descriptor("google", "gemini-2.5-pro", config => googleModelManagerOptions(config)),
descriptor("google-vertex", "gemini-3-pro-preview", config => googleVertexModelManagerOptions(config), {
allowUnauthenticated: true,
}),
catalogDescriptor(
"cursor",
"claude-sonnet-4-6",
+43 -9
View File
@@ -1,6 +1,8 @@
import type { ModelManagerOptions } from "../model-manager";
import type { FetchImpl } from "../types";
import { fetchAntigravityDiscoveryModels } from "../utils/discovery/antigravity";
import { fetchGeminiModels } from "../utils/discovery/gemini";
import { fetchVertexOpenAIModels } from "../utils/discovery/vertex";
export interface GoogleModelManagerConfig {
apiKey?: string;
@@ -8,6 +10,10 @@ export interface GoogleModelManagerConfig {
export interface GoogleVertexModelManagerConfig {
apiKey?: string;
project?: string;
location?: string;
signal?: AbortSignal;
fetch?: FetchImpl;
}
export interface GoogleAntigravityModelManagerConfig {
@@ -32,15 +38,43 @@ export function googleModelManagerOptions(
};
}
export function googleVertexModelManagerOptions(
_config?: GoogleVertexModelManagerConfig,
): ModelManagerOptions<"google-vertex"> {
// Vertex AI uses Application Default Credentials (ADC) for authentication,
// which is handled at stream time rather than during model discovery.
// Dynamic model discovery is not yet implemented for this provider.
return {
providerId: "google-vertex",
};
export function googleVertexModelManagerOptions(config?: GoogleVertexModelManagerConfig): ModelManagerOptions {
const project = resolveVertexProject(config);
const hasApiKey = (config?.apiKey ?? Bun.env.GOOGLE_CLOUD_API_KEY ?? "").trim().length > 0;
const location = resolveVertexLocation(config);
if (hasApiKey) {
return { providerId: "google-vertex" };
}
if (project && location) {
return {
providerId: "google-vertex",
staticModels: [],
fetchDynamicModels: () =>
fetchVertexOpenAIModels({
project,
location,
signal: config?.signal,
fetch: config?.fetch,
}),
};
}
// With neither ADC project+location nor API key auth configured, drop the
// bundled static catalog so stale fallbacks (e.g. `gemini-1.5-*`) cannot leak
// into `/models` alongside an authoritative cached Vertex project catalog on
// the next refresh.
return { providerId: "google-vertex", staticModels: [] };
}
function resolveVertexProject(config?: GoogleVertexModelManagerConfig): string | undefined {
const project = config?.project ?? Bun.env.GOOGLE_CLOUD_PROJECT ?? Bun.env.GCP_PROJECT ?? Bun.env.GCLOUD_PROJECT;
const trimmed = project?.trim();
return trimmed ? trimmed : undefined;
}
function resolveVertexLocation(config?: GoogleVertexModelManagerConfig): string | undefined {
const location =
config?.location ?? Bun.env.GOOGLE_VERTEX_LOCATION ?? Bun.env.GOOGLE_CLOUD_LOCATION ?? Bun.env.VERTEX_LOCATION;
const trimmed = location?.trim();
return trimmed ? trimmed : undefined;
}
export function googleAntigravityModelManagerOptions(
+36 -22
View File
@@ -42,6 +42,8 @@ export type BedrockThinkingDisplay = "summarized" | "omitted";
export interface BedrockOptions extends StreamOptions {
region?: string;
profile?: string;
/** Amazon Bedrock API key sent as `Authorization: Bearer`, ahead of SigV4 credential resolution. */
bearerToken?: string;
toolChoice?: "auto" | "any" | "none" | { type: "tool"; name: string };
/* See https://docs.aws.amazon.com/bedrock/latest/userguide/inference-reasoning.html for supported models. */
reasoning?: Effort;
@@ -63,6 +65,12 @@ export interface BedrockOptions extends StreamOptions {
*/
thinkingDisplay?: BedrockThinkingDisplay;
}
const AUTHENTICATED_API_KEY_SENTINEL = "<authenticated>";
function resolveBearerToken(options: BedrockOptions): string | undefined {
const apiKey = options.apiKey === AUTHENTICATED_API_KEY_SENTINEL ? undefined : options.apiKey;
return options.bearerToken || apiKey || $env.AWS_BEARER_TOKEN_BEDROCK;
}
type Block = (TextContent | ThinkingContent | ToolCall) & { index?: number; partialJson?: string };
@@ -225,34 +233,40 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
body: commandInput,
};
let credentials: { accessKeyId: string; secretAccessKey: string; sessionToken?: string };
if ($flag("AWS_BEDROCK_SKIP_AUTH")) {
credentials = { accessKeyId: "dummy-access-key", secretAccessKey: "dummy-secret-key" };
} else {
credentials = await resolveAwsCredentials({
profile: options.profile,
region,
signal: options.signal,
});
}
const bodyText = JSON.stringify(commandInput);
const body = new TextEncoder().encode(bodyText);
const baseHeaders: Record<string, string> = {
"content-type": "application/json",
accept: "application/vnd.amazon.eventstream",
};
const signed = await signRequest({
method: "POST",
host,
path: urlPath,
body,
region,
service: "bedrock",
credentials,
headers: baseHeaders,
});
const requestHeaders: Record<string, string> = { ...baseHeaders, ...signed };
const bearerToken = resolveBearerToken(options);
let requestHeaders: Record<string, string>;
if (bearerToken) {
requestHeaders = { ...baseHeaders, Authorization: `Bearer ${bearerToken}` };
} else {
let credentials: { accessKeyId: string; secretAccessKey: string; sessionToken?: string };
if ($flag("AWS_BEDROCK_SKIP_AUTH")) {
credentials = { accessKeyId: "dummy-access-key", secretAccessKey: "dummy-secret-key" };
} else {
credentials = await resolveAwsCredentials({
profile: options.profile,
region,
signal: options.signal,
});
}
const signed = await signRequest({
method: "POST",
host,
path: urlPath,
body,
region,
service: "bedrock",
credentials,
headers: baseHeaders,
});
requestHeaders = { ...baseHeaders, ...signed };
}
const response = await fetchWithRetry(url, {
method: "POST",
+54 -15
View File
@@ -2,7 +2,10 @@ import * as nodeCrypto from "node:crypto";
import * as fs from "node:fs";
import { scheduler } from "node:timers/promises";
import * as tls from "node:tls";
import Anthropic, { type ClientOptions as AnthropicSdkClientOptions } from "@anthropic-ai/sdk";
import Anthropic, {
APIConnectionTimeoutError as AnthropicConnectionTimeoutError,
type ClientOptions as AnthropicSdkClientOptions,
} from "@anthropic-ai/sdk";
import type {
ContentBlockParam,
MessageCreateParamsStreaming,
@@ -53,17 +56,16 @@ import {
resolveCacheRetention,
} from "../utils";
import { createAbortSourceTracker } from "../utils/abort";
import { iterateUntilAbort } from "../utils/abortable-iterator";
import { AssistantMessageEventStream } from "../utils/event-stream";
import { isFoundryEnabled } from "../utils/foundry";
import { finalizeErrorMessage, type RawHttpRequestDump, rewriteCopilotError } from "../utils/http-inspector";
import { getStreamFirstEventTimeoutMs, getStreamIdleTimeoutMs, iterateWithIdleTimeout } from "../utils/idle-iterator";
import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse";
import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot";
import { notifyProviderResponse } from "../utils/provider-response";
import { isCopilotTransientModelError } from "../utils/retry";
import { COMBINATOR_KEYS, NO_STRICT, toolWireSchema } from "../utils/schema";
import { spillToDescription } from "../utils/schema/spill";
import { createSdkStreamRequestOptions } from "../utils/sdk-stream-timeout";
import { notifyRawSseEvent, wrapFetchForSseDebug } from "../utils/sse-debug";
import {
buildCopilotDynamicHeaders,
@@ -1089,35 +1091,71 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
| TextContent
| (ToolCall & { partialJson: string })
) & { index: number };
const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getStreamIdleTimeoutMs();
const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs);
const requestTimeoutMs =
firstEventTimeoutMs !== undefined && firstEventTimeoutMs > 0 ? firstEventTimeoutMs : undefined;
const blocks = output.content as Block[];
stream.push({ type: "start", partial: output });
// Retry loop for transient errors from the stream.
// Provider-level transport/rate-limit failures: only before any streamed content starts.
// Malformed envelopes/JSON: only before replay-unsafe text/tool events are visible on this stream.
let providerRetryAttempt = 0;
const firstEventTimeoutAbortError = new Error("Anthropic stream timed out while waiting for the first event");
const idleTimeoutAbortError = new Error("Anthropic stream stalled while waiting for the next event");
while (true) {
activeAbortTracker = createAbortSourceTracker(options?.signal);
const { requestSignal } = activeAbortTracker;
const requestOptions = createSdkStreamRequestOptions(requestSignal, options?.streamFirstEventTimeoutMs);
const requestOptions =
requestTimeoutMs === undefined
? { signal: requestSignal }
: { signal: requestSignal, timeout: requestTimeoutMs };
const anthropicRequest = client.messages.create({ ...params, stream: true }, requestOptions);
let streamedReplayUnsafeContent = false;
try {
const {
events: anthropicStream,
response,
requestId,
} = await getAnthropicStreamResponse(
anthropicRequest,
requestSignal,
options?.client ? event => options?.onSseEvent?.(event, model) : undefined,
);
let requestTimeout: NodeJS.Timeout | undefined;
if (requestTimeoutMs !== undefined) {
requestTimeout = setTimeout(
() => activeAbortTracker.abortLocally(firstEventTimeoutAbortError),
requestTimeoutMs,
);
}
let anthropicStream: AsyncIterable<RawMessageStreamEvent>;
let response: Response;
let requestId: string | null;
try {
({
events: anthropicStream,
response,
requestId,
} = await getAnthropicStreamResponse(
anthropicRequest,
requestSignal,
options?.client ? event => options?.onSseEvent?.(event, model) : undefined,
));
} catch (error) {
if (error instanceof AnthropicConnectionTimeoutError && !activeAbortTracker.wasCallerAbort()) {
throw firstEventTimeoutAbortError;
}
throw error;
} finally {
if (requestTimeout !== undefined) clearTimeout(requestTimeout);
}
await notifyProviderResponse(options, response, model, requestId);
let sawEvent = false;
let sawMessageStart = false;
let sawTerminalEnvelope = false;
for await (const event of iterateUntilAbort(anthropicStream, options?.signal)) {
for await (const event of iterateWithIdleTimeout(anthropicStream, {
idleTimeoutMs,
firstItemTimeoutMs: firstEventTimeoutMs,
errorMessage: idleTimeoutAbortError.message,
firstItemErrorMessage: firstEventTimeoutAbortError.message,
onIdle: () => activeAbortTracker.abortLocally(idleTimeoutAbortError),
onFirstItemTimeout: () => activeAbortTracker.abortLocally(firstEventTimeoutAbortError),
abortSignal: options?.signal,
})) {
sawEvent = true;
if (event.type === "message_start") {
@@ -1425,9 +1463,10 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
delete (block as { index?: number }).index;
delete (block as { partialJson?: string }).partialJson;
}
const firstEventTimeoutError = activeAbortTracker.getLocalAbortReason();
output.stopReason = activeAbortTracker.wasCallerAbort() ? "aborted" : "error";
output.errorStatus = extractHttpStatusFromError(error);
output.errorMessage = await finalizeErrorMessage(error, rawRequestDump);
output.errorMessage = firstEventTimeoutError?.message ?? (await finalizeErrorMessage(error, rawRequestDump));
output.errorMessage = rewriteCopilotError(output.errorMessage, error, model.provider);
output.duration = Date.now() - startTime;
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
@@ -1,9 +1,10 @@
import { $env, extractHttpStatusFromError } from "@oh-my-pi/pi-utils";
import { AzureOpenAI } from "openai";
import { AzureOpenAI, APIConnectionTimeoutError as OpenAIConnectionTimeoutError } from "openai";
import type {
Tool as OpenAITool,
ResponseCreateParamsStreaming,
ResponseInput,
ResponseStreamEvent,
} from "openai/resources/responses/responses";
import { getEnvApiKey } from "../stream";
import type {
@@ -18,11 +19,14 @@ import type {
} from "../types";
import { normalizeSystemPrompts } from "../utils";
import { createAbortSourceTracker } from "../utils/abort";
import { iterateUntilAbort } from "../utils/abortable-iterator";
import { AssistantMessageEventStream } from "../utils/event-stream";
import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
import {
getOpenAIStreamIdleTimeoutMs,
getStreamFirstEventTimeoutMs,
iterateWithIdleTimeout,
} from "../utils/idle-iterator";
import { sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema";
import { createSdkStreamRequestOptions, resolveSdkTimeoutMs } from "../utils/sdk-stream-timeout";
import { wrapFetchForSseDebug } from "../utils/sse-debug";
import { mapToOpenAIResponsesToolChoice } from "../utils/tool-choice";
import { normalizeOpenAIResponsesPromptCacheKey, supportsDeveloperRole } from "./openai-responses";
@@ -33,12 +37,15 @@ import {
convertResponsesAssistantMessage,
convertResponsesInputContent,
createInitialResponsesAssistantMessage,
isOpenAIResponsesProgressEvent,
normalizeResponsesToolCallIdForTransform,
processResponsesStream,
} from "./openai-responses-shared";
import { transformMessages } from "./transform-messages";
const DEFAULT_AZURE_API_VERSION = "v1";
const AZURE_OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE =
"Azure OpenAI responses stream timed out while waiting for the first event";
function parseDeploymentNameMap(value: string | undefined): Map<string, string> {
const map = new Map<string, string>();
@@ -104,7 +111,8 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses"
);
let rawRequestDump: RawHttpRequestDump | undefined;
const abortTracker = createAbortSourceTracker(options?.signal);
const { requestSignal } = abortTracker;
const firstEventTimeoutAbortError = new Error(AZURE_OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE);
const { requestAbortController, requestSignal } = abortTracker;
try {
// Create Azure OpenAI client
@@ -113,6 +121,10 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses"
const { baseUrl } = resolveAzureConfig(model, options);
const params = buildParams(model, context, options, deploymentName, baseUrl);
options?.onPayload?.(params);
const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs();
const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs);
const requestTimeoutMs =
firstEventTimeoutMs !== undefined && firstEventTimeoutMs > 0 ? firstEventTimeoutMs : undefined;
rawRequestDump = {
provider: model.provider,
api: output.api,
@@ -121,15 +133,52 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses"
url: `${baseUrl}/responses`,
body: params,
};
const requestOptions = createSdkStreamRequestOptions(requestSignal, options?.streamFirstEventTimeoutMs);
const openaiStream = await client.responses.create(params, requestOptions);
let requestTimeout: NodeJS.Timeout | undefined;
if (requestTimeoutMs !== undefined) {
requestTimeout = setTimeout(() => abortTracker.abortLocally(firstEventTimeoutAbortError), requestTimeoutMs);
}
let openaiStream: AsyncIterable<ResponseStreamEvent>;
try {
const requestOptions =
requestTimeoutMs === undefined
? { signal: requestSignal }
: { signal: requestSignal, timeout: requestTimeoutMs };
openaiStream = await client.responses.create(params, requestOptions);
} catch (error) {
if (error instanceof OpenAIConnectionTimeoutError && !abortTracker.wasCallerAbort()) {
throw firstEventTimeoutAbortError;
}
throw error;
} finally {
if (requestTimeout !== undefined) clearTimeout(requestTimeout);
}
stream.push({ type: "start", partial: output });
await processResponsesStream(iterateUntilAbort(openaiStream, options?.signal), output, stream, model, {
onFirstToken: () => {
if (!firstTokenTime) firstTokenTime = Date.now();
await processResponsesStream(
iterateWithIdleTimeout(openaiStream, {
idleTimeoutMs,
firstItemTimeoutMs: firstEventTimeoutMs,
firstItemErrorMessage: AZURE_OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE,
errorMessage: "Azure OpenAI responses stream stalled while waiting for the next event",
onIdle: () => requestAbortController.abort(),
onFirstItemTimeout: () => abortTracker.abortLocally(firstEventTimeoutAbortError),
abortSignal: options?.signal,
isProgressItem: isOpenAIResponsesProgressEvent,
}),
output,
stream,
model,
{
onFirstToken: () => {
if (!firstTokenTime) firstTokenTime = Date.now();
},
},
});
);
const firstEventTimeoutError = abortTracker.getLocalAbortReason();
if (firstEventTimeoutError) {
throw firstEventTimeoutError;
}
if (abortTracker.wasCallerAbort()) {
throw new Error("Request was aborted");
@@ -145,9 +194,10 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses"
stream.end();
} catch (error) {
for (const block of output.content) delete (block as { index?: number }).index;
const firstEventTimeoutError = abortTracker.getLocalAbortReason();
output.stopReason = abortTracker.wasCallerAbort() ? "aborted" : "error";
output.errorStatus = extractHttpStatusFromError(error);
output.errorMessage = await finalizeErrorMessage(error, rawRequestDump);
output.errorMessage = firstEventTimeoutError?.message ?? (await finalizeErrorMessage(error, rawRequestDump));
output.duration = Date.now() - startTime;
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
stream.push({ type: "error", reason: output.stopReason, error: output });
@@ -218,7 +268,6 @@ function createClient(model: Model<"azure-openai-responses">, apiKey: string, op
const baseFetch = options?.fetch ?? fetch;
const onSseEvent = options?.onSseEvent;
const sdkTimeoutMs = resolveSdkTimeoutMs(options?.streamFirstEventTimeoutMs);
return new AzureOpenAI({
apiKey,
apiVersion,
@@ -227,7 +276,6 @@ function createClient(model: Model<"azure-openai-responses">, apiKey: string, op
defaultHeaders: headers,
baseURL: baseUrl,
fetch: onSseEvent ? wrapFetchForSseDebug(baseFetch, event => onSseEvent(event, model)) : baseFetch,
...(sdkTimeoutMs !== undefined ? { timeout: sdkTimeoutMs } : {}),
});
}
@@ -244,7 +292,7 @@ function buildParams(
model: deploymentName,
input: messages,
stream: true,
prompt_cache_key: normalizeOpenAIResponsesPromptCacheKey(options?.sessionId),
prompt_cache_key: normalizeOpenAIResponsesPromptCacheKey(options?.promptCacheKey ?? options?.sessionId),
};
applyCommonResponsesSamplingParams(params, options, model.provider);
+7 -4
View File
@@ -67,10 +67,10 @@ function resolveApiKey(options?: GoogleVertexOptions): string | undefined {
}
function resolveProject(options?: GoogleVertexOptions): string {
const project = options?.project || $env.GOOGLE_CLOUD_PROJECT || $env.GCLOUD_PROJECT;
const project = options?.project || $env.GOOGLE_CLOUD_PROJECT || $env.GCP_PROJECT || $env.GCLOUD_PROJECT;
if (!project) {
throw new Error(
"Vertex AI requires a project ID. Set GOOGLE_CLOUD_PROJECT/GCLOUD_PROJECT or pass project in options.",
"Vertex AI requires a project ID. Set GOOGLE_CLOUD_PROJECT/GCP_PROJECT/GCLOUD_PROJECT or pass project in options.",
);
}
return project;
@@ -80,9 +80,12 @@ function resolveEndpointHost(location: string): string {
return location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`;
}
function resolveLocation(options?: GoogleVertexOptions): string {
const location = options?.location || $env.GOOGLE_CLOUD_LOCATION;
const location =
options?.location || $env.GOOGLE_VERTEX_LOCATION || $env.GOOGLE_CLOUD_LOCATION || $env.VERTEX_LOCATION;
if (!location) {
throw new Error("Vertex AI requires a location. Set GOOGLE_CLOUD_LOCATION or pass location in options.");
throw new Error(
"Vertex AI requires a location. Set GOOGLE_VERTEX_LOCATION/GOOGLE_CLOUD_LOCATION/VERTEX_LOCATION or pass location in options.",
);
}
return location;
}
-4
View File
@@ -216,10 +216,6 @@ export class MockModel implements Model<MockApi> {
this.toolCallCounter = 0;
}
}
/** @deprecated Use {@link MockModel}; the class IS the handle. */
export type MockModelHandle = MockModel;
/** Check whether `model` was produced by `createMockModel`. */
export function isMockModel(model: Model<Api>): model is MockModel {
return model instanceof MockModel;
@@ -29,6 +29,7 @@ import {
type FetchImpl,
type Model,
type ProviderSessionState,
type RawSseEvent,
resolveServiceTier,
type ServiceTier,
type StreamFunction,
@@ -45,11 +46,16 @@ import {
getOpenAIResponsesHistoryPayload,
normalizeSystemPrompts,
} from "../utils";
import { iterateUntilAbort } from "../utils/abortable-iterator";
import { AssistantMessageEventStream } from "../utils/event-stream";
import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
import {
getOpenAIStreamIdleTimeoutMs,
getStreamFirstEventTimeoutMs,
iterateWithIdleTimeout,
} from "../utils/idle-iterator";
import { parseStreamingJson } from "../utils/json-parse";
import { adaptSchemaForStrict, NO_STRICT, sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema";
import { notifyRawSseEvent } from "../utils/sse-debug";
import { compactGrammarDefinition } from "./grammar";
import { CODEX_BASE_URL, getCodexAccountId, OPENAI_HEADER_VALUES, OPENAI_HEADERS } from "./openai-codex/constants";
import {
@@ -66,6 +72,7 @@ import {
convertResponsesInputContent,
encodeResponsesToolCallId,
encodeTextSignatureV1,
isOpenAIResponsesProgressEvent,
mapOpenAIResponsesStopReason,
populateResponsesUsageFromResponse,
} from "./openai-responses-shared";
@@ -116,6 +123,14 @@ const X_REASONING_INCLUDED_HEADER = "x-reasoning-included";
const CODEX_WEBSOCKET_FATAL_PATTERNS = ["websocket error:", "websocket closed before open", "connection timeout"];
/** Max total time to spend retrying 429s with server-provided delays (5 minutes). */
const CODEX_RATE_LIMIT_BUDGET_MS = 5 * 60 * 1000;
const CODEX_ADDITIONAL_PROGRESS_EVENT_TYPES = new Set(["response.done", "response.incomplete"]);
function isCodexStreamProgressEvent(event: unknown): boolean {
if (isOpenAIResponsesProgressEvent(event)) return true;
if (!event || typeof event !== "object") return false;
const type = (event as { type?: unknown }).type;
return typeof type === "string" && CODEX_ADDITIONAL_PROGRESS_EVENT_TYPES.has(type);
}
type CodexTransport = "sse" | "websocket";
type CodexEventItem = ResponseReasoningItem | ResponseOutputMessage | ResponseFunctionToolCall | ResponseCustomToolCall;
@@ -157,6 +172,7 @@ interface CodexRequestContext {
baseUrl: string;
url: string;
requestHeaders: Record<string, string>;
transportSessionId?: string;
providerSessionState?: CodexProviderSessionState;
websocketState?: CodexWebSocketSessionState;
transformedBody: RequestBody;
@@ -165,6 +181,10 @@ interface CodexRequestContext {
interface CodexRequestSetup {
requestSignal: AbortSignal;
wrapCodexSseStream: (source: AsyncGenerator<Record<string, unknown>>) => AsyncGenerator<Record<string, unknown>>;
requestAbortController: AbortController;
websocketIdleTimeoutMs: number | undefined;
websocketFirstEventTimeoutMs: number | undefined;
}
interface CodexStreamRuntime {
@@ -356,6 +376,52 @@ function extractCodexWebSocketHandshakeHeaders(socket: Bun.WebSocket, openEvent?
);
}
// Synthesizes a `RawSseEvent` for a Codex WebSocket frame so the same debug
// pipeline used for HTTP SSE (`onSseEvent` → `RawSseDebugBuffer.recordEvent`)
// also captures WebSocket traffic. The `raw` array mirrors SSE wire format
// (one line per field) so the existing TUI viewer renders it identically:
// : ws ← <type>
// event: <type>
// data: <json>
// Outbound (client → server) uses `: ws → <type>`. The viewer pretty-prints
// `data:` JSON lines, so we keep the wire JSON single-line here and let the
// renderer expand it.
function notifyCodexWebSocketInbound(
observer: ((event: RawSseEvent) => void) | undefined,
parsed: Record<string, unknown>,
text: string,
): void {
const type = typeof parsed.type === "string" ? parsed.type : null;
const raw: string[] = [`: ws ← ${type ?? "(untyped)"}`];
if (type) raw.push(`event: ${type}`);
raw.push(`data: ${text}`);
notifyRawSseEvent(observer, { event: type, data: text, raw });
}
function notifyCodexWebSocketOutbound(
observer: ((event: RawSseEvent) => void) | undefined,
request: Record<string, unknown>,
payload: string,
): void {
const type = typeof request.type === "string" ? request.type : null;
const raw: string[] = [`: ws → ${type ?? "(untyped)"}`];
if (type) raw.push(`event: ${type}`);
raw.push(`data: ${payload}`);
notifyRawSseEvent(observer, { event: type, data: payload, raw });
}
function notifyCodexWebSocketMalformed(
observer: ((event: RawSseEvent) => void) | undefined,
data: unknown,
error: unknown,
): void {
const text = typeof data === "string" ? data : "";
const reason = error instanceof Error ? error.message : String(error);
const raw: string[] = [`: ws ← (parse-error: ${reason})`];
if (text) raw.push(`data: ${text}`);
notifyRawSseEvent(observer, { event: "parse_error", data: text, raw });
}
/** @internal Exported for tests. */
export function normalizeCodexToolChoice(
choice: ToolChoice | undefined,
@@ -472,7 +538,34 @@ function removeTransientBlockIndices(output: AssistantMessage): void {
}
function createRequestSetup(options: OpenAICodexResponsesOptions | undefined): CodexRequestSetup {
return { requestSignal: options?.signal ?? new AbortController().signal };
const requestAbortController = new AbortController();
const requestSignal = options?.signal
? AbortSignal.any([options.signal, requestAbortController.signal])
: requestAbortController.signal;
const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs();
const websocketIdleTimeoutMs = options?.streamIdleTimeoutMs ?? getCodexWebSocketIdleTimeoutMs();
const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs);
const websocketFirstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getCodexWebSocketFirstEventTimeoutMs();
const wrapCodexSseStream = (
source: AsyncGenerator<Record<string, unknown>>,
): AsyncGenerator<Record<string, unknown>> =>
iterateWithIdleTimeout(source, {
idleTimeoutMs,
firstItemTimeoutMs: firstEventTimeoutMs,
firstItemErrorMessage: "OpenAI Codex SSE stream timed out while waiting for the first event",
errorMessage: "OpenAI Codex SSE stream stalled while waiting for the next event",
onIdle: () => requestAbortController.abort(),
onFirstItemTimeout: () => requestAbortController.abort(),
abortSignal: options?.signal,
isProgressItem: isCodexStreamProgressEvent,
});
return {
requestAbortController,
requestSignal,
wrapCodexSseStream,
websocketIdleTimeoutMs,
websocketFirstEventTimeoutMs,
};
}
async function buildCodexRequestContext(
@@ -489,8 +582,9 @@ async function buildCodexRequestContext(
const accountId = getAccountId(apiKey);
const baseUrl = model.baseUrl || CODEX_BASE_URL;
const url = resolveCodexResponsesUrl(baseUrl);
const promptCacheKey = normalizeOpenAIResponsesPromptCacheKey(options?.sessionId);
const transformedBody = await buildTransformedCodexRequestBody(model, context, options);
const promptCacheKey = resolveCodexPromptCacheKey(options);
const transportSessionId = resolveCodexTransportSessionId(options);
const transformedBody = await buildTransformedCodexRequestBody(model, context, options, promptCacheKey);
options?.onPayload?.(transformedBody);
const requestHeaders = { ...(model.headers ?? {}), ...(options?.headers ?? {}) };
@@ -504,20 +598,20 @@ async function buildCodexRequestContext(
};
const providerSessionState = getCodexProviderSessionState(options?.providerSessionState);
const sessionKey = getCodexWebSocketSessionKey(promptCacheKey, model, accountId, baseUrl);
const publicSessionKey = getCodexPublicSessionKey(promptCacheKey, model, baseUrl);
const sessionKey = getCodexWebSocketSessionKey(transportSessionId, model, accountId, baseUrl);
const publicSessionKey = getCodexPublicSessionKey(transportSessionId, model, baseUrl);
if (sessionKey && publicSessionKey) {
providerSessionState?.webSocketPublicToPrivate.set(publicSessionKey, sessionKey);
}
const websocketState =
sessionKey && providerSessionState ? getCodexWebSocketSessionState(sessionKey, providerSessionState) : undefined;
return {
apiKey,
accountId,
baseUrl,
url,
requestHeaders,
transportSessionId,
providerSessionState,
websocketState,
transformedBody,
@@ -529,12 +623,13 @@ async function buildTransformedCodexRequestBody(
model: Model<"openai-codex-responses">,
context: Context,
options: OpenAICodexResponsesOptions | undefined,
promptCacheKey = resolveCodexPromptCacheKey(options),
): Promise<RequestBody> {
const params: RequestBody = {
model: model.id,
input: [...convertMessages(model, context)],
stream: true,
prompt_cache_key: normalizeOpenAIResponsesPromptCacheKey(options?.sessionId),
prompt_cache_key: promptCacheKey,
};
if (options?.maxTokens) {
@@ -613,7 +708,13 @@ async function openInitialCodexEventStream(
let websocketRetries = 0;
while (true) {
try {
return await openCodexWebSocketTransport(requestContext, requestSetup, websocketState, websocketRetries);
return await openCodexWebSocketTransport(
requestContext,
requestSetup,
websocketState,
websocketRetries,
options ? event => options.onSseEvent?.(event, model) : undefined,
);
} catch (error) {
const websocketError = error instanceof Error ? error : new Error(String(error));
const isFatal = isCodexWebSocketFatalError(websocketError);
@@ -644,6 +745,7 @@ async function openCodexWebSocketTransport(
requestSetup: CodexRequestSetup,
websocketState: CodexWebSocketSessionState,
retry: number,
onSseEvent?: (event: RawSseEvent) => void,
): Promise<{
eventStream: AsyncGenerator<Record<string, unknown>>;
requestBodyForState: RequestBody;
@@ -654,7 +756,7 @@ async function openCodexWebSocketTransport(
requestContext.requestHeaders,
requestContext.accountId,
requestContext.apiKey,
requestContext.transformedBody.prompt_cache_key,
requestContext.transportSessionId,
"websocket",
websocketState,
);
@@ -675,7 +777,12 @@ async function openCodexWebSocketTransport(
websocketHeaders,
websocketRequest,
websocketState,
{
idleTimeoutMs: requestSetup.websocketIdleTimeoutMs,
firstEventTimeoutMs: requestSetup.websocketFirstEventTimeoutMs,
},
requestSetup.requestSignal,
onSseEvent,
);
return { eventStream, requestBodyForState, transport: "websocket" };
}
@@ -692,20 +799,19 @@ async function openCodexSseTransport(
requestBodyForState: RequestBody;
transport: CodexTransport;
}> {
const eventStream = iterateUntilAbort(
const eventStream = requestSetup.wrapCodexSseStream(
await openCodexSseEventStream(
requestContext.url,
requestContext.requestHeaders,
requestContext.accountId,
requestContext.apiKey,
body.prompt_cache_key,
requestContext.transportSessionId,
body,
state,
requestSetup.requestSignal,
event => options?.onSseEvent?.(event, model),
options?.fetch,
),
requestSetup.requestSignal,
);
return { eventStream, requestBodyForState: structuredCloneJSON(body), transport: "sse" };
}
@@ -721,6 +827,7 @@ async function reopenCodexWebSocketRuntimeStream(
context.requestSetup,
state,
runtime.websocketStreamRetries,
context.options ? event => context.options?.onSseEvent?.(event, context.model) : undefined,
);
runtime.eventStream = next.eventStream;
runtime.requestBodyForState = next.requestBodyForState;
@@ -1602,6 +1709,18 @@ export async function prewarmOpenAICodexResponses(
state.prewarmed = true;
}
function resolveCodexPromptCacheKey(
options: Pick<OpenAICodexResponsesOptions, "promptCacheKey" | "sessionId"> | undefined,
): string | undefined {
return normalizeOpenAIResponsesPromptCacheKey(options?.promptCacheKey ?? options?.sessionId);
}
function resolveCodexTransportSessionId(
options: Pick<OpenAICodexResponsesOptions, "sessionId"> | undefined,
): string | undefined {
return normalizeOpenAIResponsesPromptCacheKey(options?.sessionId);
}
function getCodexWebSocketSessionKey(
sessionId: string | undefined,
model: Model<"openai-codex-responses">,
@@ -1848,29 +1967,29 @@ function headersToRecord(headers: Headers): Record<string, string> {
return result;
}
interface CodexWebSocketRequestTimeouts {
idleTimeoutMs?: number;
firstEventTimeoutMs?: number;
}
interface CodexWebSocketConnectionOptions {
idleTimeoutMs: number;
firstEventTimeoutMs: number;
onHandshakeHeaders?: (headers: Headers) => void;
}
class CodexWebSocketConnection {
#url: string;
#headers: Record<string, string>;
#idleTimeoutMs: number;
#firstEventTimeoutMs: number;
#onHandshakeHeaders?: (headers: Headers) => void;
#socket: Bun.WebSocket | null = null;
#queue: Array<Record<string, unknown> | Error | null> = [];
#waiters: Array<() => void> = [];
#connectPromise?: Promise<void>;
#activeRequest = false;
#streamObserver?: (event: RawSseEvent) => void;
constructor(url: string, headers: Record<string, string>, options: CodexWebSocketConnectionOptions) {
this.#url = url;
this.#headers = headers;
this.#idleTimeoutMs = options.idleTimeoutMs;
this.#firstEventTimeoutMs = options.firstEventTimeoutMs;
this.#onHandshakeHeaders = options.onHandshakeHeaders;
}
@@ -1983,8 +2102,10 @@ class CodexWebSocketConnection {
parsed.message = inner.message;
}
}
notifyCodexWebSocketInbound(this.#streamObserver, parsed, text);
this.#push(parsed);
} catch (error) {
notifyCodexWebSocketMalformed(this.#streamObserver, event.data, error);
this.#push(createCodexWebSocketTransportError(String(error)));
}
};
@@ -1999,7 +2120,9 @@ class CodexWebSocketConnection {
async *streamRequest(
request: Record<string, unknown>,
timeouts: CodexWebSocketRequestTimeouts,
signal?: AbortSignal,
onSseEvent?: (event: RawSseEvent) => void,
): AsyncGenerator<Record<string, unknown>> {
if (!this.#socket || this.#socket.readyState !== WebSocket.OPEN) {
throw createCodexWebSocketTransportError("websocket connection is unavailable");
@@ -2008,6 +2131,7 @@ class CodexWebSocketConnection {
throw createCodexWebSocketTransportError("websocket request already in progress");
}
this.#activeRequest = true;
this.#streamObserver = onSseEvent;
const onAbort = () => {
this.close("aborted");
this.#push(createCodexWebSocketTransportError("request was aborted"));
@@ -2021,21 +2145,28 @@ class CodexWebSocketConnection {
}
try {
this.#socket.send(JSON.stringify(request));
const requestPayload = JSON.stringify(request);
notifyCodexWebSocketOutbound(onSseEvent, request, requestPayload);
this.#socket.send(requestPayload);
let sawFirstEvent = false;
const { idleTimeoutMs, firstEventTimeoutMs } = timeouts;
let lastProgressAt = Date.now();
while (true) {
let timeoutMs: number;
let timeoutMs: number | undefined;
let timeoutReason: string;
if (sawFirstEvent) {
timeoutMs = this.#idleTimeoutMs - (Date.now() - lastProgressAt);
timeoutReason = "idle timeout waiting for websocket";
if (timeoutMs <= 0) {
throw createCodexWebSocketTransportError(timeoutReason);
if (idleTimeoutMs !== undefined && idleTimeoutMs > 0) {
timeoutMs = idleTimeoutMs - (Date.now() - lastProgressAt);
if (timeoutMs <= 0) {
throw createCodexWebSocketTransportError(timeoutReason);
}
}
} else {
timeoutMs = this.#firstEventTimeoutMs;
timeoutReason = "timeout waiting for first websocket event";
if (firstEventTimeoutMs !== undefined && firstEventTimeoutMs > 0) {
timeoutMs = firstEventTimeoutMs;
}
}
const next = await this.#nextMessage(timeoutMs, timeoutReason);
if (next instanceof Error) {
@@ -2045,7 +2176,9 @@ class CodexWebSocketConnection {
throw createCodexWebSocketTransportError("websocket closed before response completion");
}
sawFirstEvent = true;
lastProgressAt = Date.now();
if (isCodexStreamProgressEvent(next)) {
lastProgressAt = Date.now();
}
yield next;
const eventType = typeof next.type === "string" ? next.type : "";
if (
@@ -2060,6 +2193,7 @@ class CodexWebSocketConnection {
}
} finally {
this.#activeRequest = false;
this.#streamObserver = undefined;
if (signal) {
signal.removeEventListener("abort", onAbort);
}
@@ -2079,13 +2213,16 @@ class CodexWebSocketConnection {
if (waiter) waiter();
}
async #nextMessage(timeoutMs: number, timeoutReason: string): Promise<Record<string, unknown> | Error | null> {
async #nextMessage(
timeoutMs: number | undefined,
timeoutReason: string,
): Promise<Record<string, unknown> | Error | null> {
while (this.#queue.length === 0) {
const { promise, resolve } = Promise.withResolvers<void>();
this.#waiters.push(resolve);
let timedOut = false;
let timeout: NodeJS.Timeout | undefined;
if (timeoutMs > 0) {
if (timeoutMs !== undefined && timeoutMs > 0) {
timeout = setTimeout(() => {
timedOut = true;
const waiterIndex = this.#waiters.indexOf(resolve);
@@ -2124,8 +2261,6 @@ async function getOrCreateCodexWebSocketConnection(
resetCodexWebSocketAppendState(state);
logger.time("codexWs:newSocket");
state.connection = new CodexWebSocketConnection(url, headerRecord, {
idleTimeoutMs: getCodexWebSocketIdleTimeoutMs(),
firstEventTimeoutMs: getCodexWebSocketFirstEventTimeoutMs(),
onHandshakeHeaders: handshakeHeaders => {
updateCodexSessionMetadataFromHeaders(state, handshakeHeaders);
},
@@ -2192,17 +2327,19 @@ async function openCodexWebSocketEventStream(
headers: Headers,
request: Record<string, unknown>,
state: CodexWebSocketSessionState,
timeouts: CodexWebSocketRequestTimeouts,
signal?: AbortSignal,
onSseEvent?: (event: RawSseEvent) => void,
): Promise<AsyncGenerator<Record<string, unknown>>> {
const connection = await getOrCreateCodexWebSocketConnection(state, url, headers, signal);
return connection.streamRequest(request, signal);
return connection.streamRequest(request, timeouts, signal, onSseEvent);
}
function createCodexHeaders(
initHeaders: Record<string, string> | undefined,
accountId: string,
accessToken: string,
promptCacheKey?: string,
sessionId?: string,
transport: CodexTransport = "sse",
state?: CodexWebSocketSessionState,
): Headers {
@@ -2219,10 +2356,10 @@ function createCodexHeaders(
headers.set(OPENAI_HEADERS.BETA, betaHeader);
headers.set(OPENAI_HEADERS.ORIGINATOR, OPENAI_HEADER_VALUES.ORIGINATOR_CODEX);
headers.set("User-Agent", getCodexUserAgent());
if (promptCacheKey) {
headers.set(OPENAI_HEADERS.CONVERSATION_ID, promptCacheKey);
headers.set(OPENAI_HEADERS.SESSION_ID, promptCacheKey);
headers.set("x-client-request-id", promptCacheKey);
if (sessionId) {
headers.set(OPENAI_HEADERS.CONVERSATION_ID, sessionId);
headers.set(OPENAI_HEADERS.SESSION_ID, sessionId);
headers.set("x-client-request-id", sessionId);
} else {
headers.delete(OPENAI_HEADERS.CONVERSATION_ID);
headers.delete(OPENAI_HEADERS.SESSION_ID);
@@ -74,11 +74,16 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB
// applies when thinking mode is actually engaged.
const lowerId = model.id.toLowerCase();
const lowerName = (model.name ?? "").toLowerCase();
// OpenCode Zen's `big-pickle` is a DeepSeek reasoning alias; the upstream
// 400s come from DeepSeek and require exact reasoning_content replay.
const isOpenCodeDeepseekAlias =
provider === "opencode-zen" && (lowerId === "big-pickle" || lowerName === "big pickle");
const isDeepseekFamily =
provider === "deepseek" ||
baseUrl.includes("deepseek.com") ||
lowerId.includes("deepseek") ||
lowerName.includes("deepseek");
lowerName.includes("deepseek") ||
isOpenCodeDeepseekAlias;
const isDirectDeepseekApi = provider === "deepseek" || baseUrl.includes("api.deepseek.com");
const isDirectDeepseekReasoning = isDirectDeepseekApi && isDeepseekFamily && Boolean(model.reasoning);
const isNonStandard =
@@ -181,7 +186,13 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB
return {
supportsStore: !isNonStandard,
supportsDeveloperRole: !isNonStandard,
// `developer` is an OpenAI-Responses-era extension to the chat-completions schema. Almost
// every OpenAI-compatible host other than OpenAI itself (and Azure OpenAI, which mirrors
// the schema exactly) treats it as an unknown role: Moonshot returns a 400 "tokenization
// failed", Groq/Cerebras/etc. error or silently misroute. Default to `system` and require
// callers to opt in via `compat.supportsDeveloperRole: true` for hosts known to mirror
// OpenAI's reasoning-API surface.
supportsDeveloperRole: isOpenAIHost || isAzureHost,
supportsMultipleSystemMessages: supportsMultipleSystemMessagesDefault,
supportsReasoningEffort: !isGrok && !isZai,
reasoningEffortMap,
@@ -205,12 +216,14 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB
reasoningContentField: "reasoning_content",
// Backends that 400 follow-up requests when prior assistant tool-call turns lack `reasoning_content`:
// - Kimi: documented invariant on its native API.
// - Any reasoning-capable model reached through OpenRouter: DeepSeek V4 Pro and similar enforce
// this server-side whenever the request is in thinking mode. We can't translate Anthropic's
// redacted/encrypted reasoning into DeepSeek's plaintext form, so cross-provider continuations
// rely on a placeholder — see `convertMessages` for the placeholder injection.
// - OpenCode-Go and OpenCode-Zen handle reasoning content internally and reject
// `reasoning_content` in client-sent messages — exclude them even for Kimi models.
// - DeepSeek-family reasoning models, including aliased OpenCode Zen models
// like `big-pickle`, validate exact thinking-mode replay.
// - Any reasoning-capable model reached through OpenRouter can enforce this
// server-side whenever the request is in thinking mode. We can't translate
// Anthropic's redacted/encrypted reasoning into provider-native plaintext,
// so cross-provider continuations rely on a placeholder.
// OpenCode Kimi aliases handle reasoning content internally and reject
// client-sent `reasoning_content`, so exclude only that Kimi-on-OpenCode path.
requiresReasoningContentForToolCalls:
(isKimiModel && !isOpenCodeProvider) ||
(isDeepseekFamily && Boolean(model.reasoning)) ||
+103 -15
View File
@@ -1,5 +1,5 @@
import { $env, extractHttpStatusFromError } from "@oh-my-pi/pi-utils";
import OpenAI from "openai";
import OpenAI, { APIConnectionTimeoutError as OpenAIConnectionTimeoutError } from "openai";
import type {
ChatCompletionAssistantMessageParam,
ChatCompletionChunk,
@@ -37,7 +37,6 @@ import {
} from "../types";
import { normalizeSystemPrompts } from "../utils";
import { createAbortSourceTracker } from "../utils/abort";
import { iterateUntilAbort } from "../utils/abortable-iterator";
import { AssistantMessageEventStream } from "../utils/event-stream";
import { toFirepassWireModelId, toFireworksWireModelId } from "../utils/fireworks-model-id";
import {
@@ -46,13 +45,17 @@ import {
type RawHttpRequestDump,
rewriteCopilotError,
} from "../utils/http-inspector";
import {
getOpenAIStreamIdleTimeoutMs,
getStreamFirstEventTimeoutMs,
iterateWithIdleTimeout,
} from "../utils/idle-iterator";
import { parseStreamingJson } from "../utils/json-parse";
import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot";
import { getKimiCommonHeaders } from "../utils/oauth/kimi";
import { notifyProviderResponse } from "../utils/provider-response";
import { callWithCopilotModelRetry } from "../utils/retry";
import { adaptSchemaForStrict, NO_STRICT, toolWireSchema } from "../utils/schema";
import { resolveSdkTimeoutMs } from "../utils/sdk-stream-timeout";
import { wrapFetchForSseDebug } from "../utils/sse-debug";
import { type HealedToolCall, modelMayLeakKimiToolCalls, ToolCallHealer } from "../utils/tool-call-healing";
import { isForcedToolChoice, mapToOpenAICompletionsToolChoice } from "../utils/tool-choice";
@@ -164,6 +167,52 @@ function hasToolHistory(messages: Message[]): boolean {
}
return false;
}
/**
* Identify "real progress" stream chunks vs. keepalives, role-only preambles,
* and empty `{choices:[]}` no-ops emitted by some OpenAI-compatible endpoints.
* Without this filter, every keepalive resets `iterateWithIdleTimeout`'s
* deadline, so a provider that streams nothing but pings keeps the watchdog
* asleep indefinitely — observed against z.ai/GLM via OpenRouter where a
* subagent stalled for hours with no error surfaced.
*
* A chunk counts as progress when it carries terminal usage, a finish reason,
* or any model-produced delta (content / tool calls / reasoning / refusal).
* Role-only `delta: { role: "assistant" }` preambles do NOT count; we want the
* (longer) first-event timeout to keep governing until real output appears.
*/
export function isOpenAICompletionsProgressChunk(chunk: unknown): boolean {
if (!chunk || typeof chunk !== "object") return false;
const record = chunk as {
usage?: unknown;
choices?: ReadonlyArray<{
finish_reason?: unknown;
usage?: unknown;
delta?: {
content?: unknown;
tool_calls?: unknown;
reasoning?: unknown;
reasoning_content?: unknown;
reasoning_text?: unknown;
refusal?: unknown;
};
}>;
};
if (record.usage) return true;
const choice = Array.isArray(record.choices) ? record.choices[0] : undefined;
if (!choice) return false;
if (choice.finish_reason) return true;
if (choice.usage) return true;
const delta = choice.delta;
if (!delta) return false;
const content = delta.content;
if (typeof content === "string" ? content.length > 0 : Array.isArray(content) && content.length > 0) return true;
if (Array.isArray(delta.tool_calls) && delta.tool_calls.length > 0) return true;
if (typeof delta.reasoning === "string" && delta.reasoning.length > 0) return true;
if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) return true;
if (typeof delta.reasoning_text === "string" && delta.reasoning_text.length > 0) return true;
if (typeof delta.refusal === "string" && delta.refusal.length > 0) return true;
return false;
}
export interface OpenAICompletionsOptions extends StreamOptions {
toolChoice?: ToolChoice;
@@ -321,6 +370,8 @@ function getTrailingPartialDeepseekToken(text: string): string {
if (tail.length > 256) return "";
return tail;
}
const OPENAI_COMPLETIONS_FIRST_EVENT_TIMEOUT_MESSAGE =
"OpenAI completions stream timed out while waiting for the first event";
export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
model: Model<"openai-completions">,
@@ -337,10 +388,15 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
const output: AssistantMessage = createInitialResponsesAssistantMessage(model.api, model.provider, model.id);
let rawRequestDump: RawHttpRequestDump | undefined;
const abortTracker = createAbortSourceTracker(options?.signal);
const { requestSignal } = abortTracker;
const firstEventTimeoutAbortError = new Error(OPENAI_COMPLETIONS_FIRST_EVENT_TIMEOUT_MESSAGE);
const { requestAbortController, requestSignal } = abortTracker;
try {
const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs();
const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs);
const requestTimeoutMs =
firstEventTimeoutMs !== undefined && firstEventTimeoutMs > 0 ? firstEventTimeoutMs : undefined;
const {
client,
copilotPremiumRequests,
@@ -356,7 +412,6 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
options?.initiatorOverride,
options?.onSseEvent,
options?.fetch,
options?.streamFirstEventTimeoutMs,
);
const premiumRequestsTotal = copilotPremiumRequests;
getCapturedErrorResponse = captureErrorResponse;
@@ -389,11 +444,31 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
headers: requestHeaders,
body: params,
};
const { data, response, request_id } = await client.chat.completions
.create(params, { signal: requestSignal })
.withResponse();
await notifyProviderResponse(options, response, model, request_id);
return data;
const requestOptions =
requestTimeoutMs === undefined
? { signal: requestSignal }
: { signal: requestSignal, timeout: requestTimeoutMs };
let requestTimeout: NodeJS.Timeout | undefined;
if (requestTimeoutMs !== undefined) {
requestTimeout = setTimeout(
() => abortTracker.abortLocally(firstEventTimeoutAbortError),
requestTimeoutMs,
);
}
try {
const { data, response, request_id } = await client.chat.completions
.create(params, requestOptions)
.withResponse();
await notifyProviderResponse(options, response, model, request_id);
return data;
} catch (error) {
if (error instanceof OpenAIConnectionTimeoutError && !abortTracker.wasCallerAbort()) {
throw firstEventTimeoutAbortError;
}
throw error;
} finally {
if (requestTimeout !== undefined) clearTimeout(requestTimeout);
}
};
let openaiStream: AsyncIterable<ChatCompletionChunk>;
try {
@@ -601,7 +676,16 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
for (const call of calls) emitHealedToolCall(call);
};
for await (const chunk of iterateUntilAbort(openaiStream, options?.signal)) {
for await (const chunk of iterateWithIdleTimeout(openaiStream, {
idleTimeoutMs,
firstItemTimeoutMs: firstEventTimeoutMs,
firstItemErrorMessage: OPENAI_COMPLETIONS_FIRST_EVENT_TIMEOUT_MESSAGE,
errorMessage: "OpenAI completions stream stalled while waiting for the next event",
onIdle: () => requestAbortController.abort(),
onFirstItemTimeout: () => abortTracker.abortLocally(firstEventTimeoutAbortError),
abortSignal: options?.signal,
isProgressItem: isOpenAICompletionsProgressChunk,
})) {
if (!chunk || typeof chunk !== "object") continue;
// OpenAI documents ChatCompletionChunk.id as the unique chat completion identifier,
@@ -770,6 +854,10 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
finishCurrentBlock(currentBlock);
const firstEventTimeoutError = abortTracker.getLocalAbortReason();
if (firstEventTimeoutError) {
throw firstEventTimeoutError;
}
if (abortTracker.wasCallerAbort()) {
throw new Error("Request was aborted");
}
@@ -788,9 +876,12 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
stream.end();
} catch (error) {
for (const block of output.content) delete (block as any).index;
const firstEventTimeoutError = abortTracker.getLocalAbortReason();
output.stopReason = abortTracker.wasCallerAbort() ? "aborted" : "error";
output.errorStatus = extractHttpStatusFromError(error) ?? getCapturedErrorResponse?.()?.status;
output.errorMessage = await finalizeErrorMessage(error, rawRequestDump, getCapturedErrorResponse?.());
output.errorMessage =
firstEventTimeoutError?.message ??
(await finalizeErrorMessage(error, rawRequestDump, getCapturedErrorResponse?.()));
// Some providers via OpenRouter include extra details here.
const rawMetadata = (error as { error?: { metadata?: { raw?: string } } })?.error?.metadata?.raw;
if (rawMetadata) output.errorMessage += `\n${rawMetadata}`;
@@ -813,7 +904,6 @@ async function createClient(
initiatorOverride?: MessageAttribution,
onSseEvent?: OpenAICompletionsOptions["onSseEvent"],
fetchOverride?: FetchImpl,
streamFirstEventTimeoutOverride?: number,
): Promise<{
client: OpenAI;
copilotPremiumRequests: number | undefined;
@@ -912,7 +1002,6 @@ async function createClient(
baseFetch.preconnect ? { preconnect: baseFetch.preconnect } : {},
);
const debugFetch = onSseEvent ? wrapFetchForSseDebug(wrappedFetch, event => onSseEvent(event, model)) : wrappedFetch;
const sdkTimeoutMs = resolveSdkTimeoutMs(streamFirstEventTimeoutOverride);
return {
client: new OpenAI({
apiKey,
@@ -922,7 +1011,6 @@ async function createClient(
defaultHeaders: headers,
defaultQuery: azureDefaultQuery,
fetch: debugFetch,
...(sdkTimeoutMs !== undefined ? { timeout: sdkTimeoutMs } : {}),
}),
copilotPremiumRequests,
baseUrl,
@@ -32,6 +32,31 @@ import { normalizeResponsesToolCallId } from "../utils";
import type { AssistantMessageEventStream } from "../utils/event-stream";
import { parseStreamingJson } from "../utils/json-parse";
import { joinTextWithImagePlaceholder, NON_VISION_IMAGE_PLACEHOLDER, partitionVisionContent } from "./vision-guard";
export const OPENAI_RESPONSES_PROGRESS_EVENT_TYPES: ReadonlySet<string> = new Set([
"response.created",
"response.output_item.added",
"response.reasoning_summary_part.added",
"response.reasoning_summary_text.delta",
"response.reasoning_summary_part.done",
"response.reasoning_text.delta",
"response.content_part.added",
"response.output_text.delta",
"response.refusal.delta",
"response.function_call_arguments.delta",
"response.function_call_arguments.done",
"response.custom_tool_call_input.delta",
"response.custom_tool_call_input.done",
"response.output_item.done",
"response.completed",
"response.failed",
"error",
]);
export function isOpenAIResponsesProgressEvent(event: unknown): boolean {
if (!event || typeof event !== "object") return false;
const type = (event as { type?: unknown }).type;
return typeof type === "string" && OPENAI_RESPONSES_PROGRESS_EVENT_TYPES.has(type);
}
export function encodeTextSignatureV1(id: string, phase?: TextSignatureV1["phase"]): string {
const payload: TextSignatureV1 = { v: 1, id };
+84 -30
View File
@@ -1,5 +1,5 @@
import { $env, extractHttpStatusFromError, structuredCloneJSON } from "@oh-my-pi/pi-utils";
import OpenAI from "openai";
import OpenAI, { APIConnectionTimeoutError as OpenAIConnectionTimeoutError } from "openai";
import type {
Tool as OpenAITool,
ResponseCreateParamsStreaming,
@@ -30,14 +30,17 @@ import {
sanitizeOpenAIResponsesHistoryItemsForReplay,
} from "../utils";
import { createAbortSourceTracker } from "../utils/abort";
import { iterateUntilAbort } from "../utils/abortable-iterator";
import { AssistantMessageEventStream } from "../utils/event-stream";
import { finalizeErrorMessage, type RawHttpRequestDump, rewriteCopilotError } from "../utils/http-inspector";
import {
getOpenAIStreamIdleTimeoutMs,
getStreamFirstEventTimeoutMs,
iterateWithIdleTimeout,
} from "../utils/idle-iterator";
import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot";
import { notifyProviderResponse } from "../utils/provider-response";
import { callWithCopilotModelRetry } from "../utils/retry";
import { adaptSchemaForStrict, NO_STRICT, sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema";
import { createSdkStreamRequestOptions, resolveSdkTimeoutMs } from "../utils/sdk-stream-timeout";
import { wrapFetchForSseDebug } from "../utils/sse-debug";
import { mapToOpenAIResponsesToolChoice, type OpenAIResponsesToolChoice } from "../utils/tool-choice";
import {
@@ -55,6 +58,7 @@ import {
convertResponsesAssistantMessage,
convertResponsesInputContent,
createInitialResponsesAssistantMessage,
isOpenAIResponsesProgressEvent,
normalizeResponsesToolCallIdForTransform,
processResponsesStream,
repairOrphanResponsesToolOutputs,
@@ -96,6 +100,8 @@ export interface OpenAIResponsesOptions extends StreamOptions {
}
const OPENAI_RESPONSES_PROVIDER_SESSION_STATE_PREFIX = "openai-responses:";
const OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE =
"OpenAI responses stream timed out while waiting for the first event";
interface OpenAIResponsesProviderSessionState extends ProviderSessionState {
nativeHistoryReplayWarmed: boolean;
@@ -165,11 +171,14 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
);
let rawRequestDump: RawHttpRequestDump | undefined;
const abortTracker = createAbortSourceTracker(options?.signal);
const { requestSignal } = abortTracker;
const firstEventTimeoutAbortError = new Error(OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE);
const { requestAbortController, requestSignal } = abortTracker;
try {
// Keep request headers and prompt-cache routing on the same session-derived value.
const cacheSessionId = getOpenAIResponsesCacheSessionId(options);
// Keep request routing on `sessionId` while allowing callers to pin a
// stable prompt-cache key independently. Side-channel calls use this to
// avoid perturbing provider conversation state without cold-starting the cache.
const routingSessionId = getOpenAIResponsesRoutingSessionId(options);
const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
const { client, copilotPremiumRequests, baseUrl } = createClient(
model,
@@ -177,14 +186,17 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
apiKey,
options?.headers,
options?.initiatorOverride,
cacheSessionId,
routingSessionId,
options?.onSseEvent,
options?.fetch,
options?.streamFirstEventTimeoutMs,
);
const premiumRequestsTotal = copilotPremiumRequests;
const providerSessionState = getOpenAIResponsesProviderSessionState(model, options?.providerSessionState);
const { params } = buildParams(model, context, options, providerSessionState, baseUrl);
const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs();
const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs);
const requestTimeoutMs =
firstEventTimeoutMs !== undefined && firstEventTimeoutMs > 0 ? firstEventTimeoutMs : undefined;
options?.onPayload?.(params);
rawRequestDump = {
provider: model.provider,
@@ -194,14 +206,33 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
url: `${baseUrl ?? "https://api.openai.com/v1"}/responses`,
body: params,
};
const requestOptions = createSdkStreamRequestOptions(requestSignal, options?.streamFirstEventTimeoutMs);
const openaiStream = await callWithCopilotModelRetry(
async () => {
const { data, response, request_id } = await client.responses
.create(params, requestOptions)
.withResponse();
await notifyProviderResponse(options, response, model, request_id);
return data;
const requestOptions =
requestTimeoutMs === undefined
? { signal: requestSignal }
: { signal: requestSignal, timeout: requestTimeoutMs };
let requestTimeout: NodeJS.Timeout | undefined;
if (requestTimeoutMs !== undefined) {
requestTimeout = setTimeout(
() => abortTracker.abortLocally(firstEventTimeoutAbortError),
requestTimeoutMs,
);
}
try {
const { data, response, request_id } = await client.responses
.create(params, requestOptions)
.withResponse();
await notifyProviderResponse(options, response, model, request_id);
return data;
} catch (error) {
if (error instanceof OpenAIConnectionTimeoutError && !abortTracker.wasCallerAbort()) {
throw firstEventTimeoutAbortError;
}
throw error;
} finally {
if (requestTimeout !== undefined) clearTimeout(requestTimeout);
}
},
{ provider: model.provider, signal: requestSignal },
);
@@ -209,16 +240,35 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
stream.push({ type: "start", partial: output });
const nativeOutputItems: Array<Record<string, unknown>> = [];
await processResponsesStream(iterateUntilAbort(openaiStream, options?.signal), output, stream, model, {
onFirstToken: () => {
if (!firstTokenTime) firstTokenTime = Date.now();
await processResponsesStream(
iterateWithIdleTimeout(openaiStream, {
idleTimeoutMs,
firstItemTimeoutMs: firstEventTimeoutMs,
firstItemErrorMessage: OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE,
errorMessage: "OpenAI responses stream stalled while waiting for the next event",
onFirstItemTimeout: () => abortTracker.abortLocally(firstEventTimeoutAbortError),
onIdle: () => requestAbortController.abort(),
abortSignal: options?.signal,
isProgressItem: isOpenAIResponsesProgressEvent,
}),
output,
stream,
model,
{
onFirstToken: () => {
if (!firstTokenTime) firstTokenTime = Date.now();
},
onOutputItemDone: item => {
nativeOutputItems.push(structuredCloneJSON<unknown>(item) as unknown as Record<string, unknown>);
},
},
onOutputItemDone: item => {
nativeOutputItems.push(structuredCloneJSON<unknown>(item) as unknown as Record<string, unknown>);
},
});
);
if (premiumRequestsTotal !== undefined) output.usage.premiumRequests = premiumRequestsTotal;
const firstEventTimeoutError = abortTracker.getLocalAbortReason();
if (firstEventTimeoutError) {
throw firstEventTimeoutError;
}
if (abortTracker.wasCallerAbort()) {
throw new Error("Request was aborted");
}
@@ -236,9 +286,10 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
stream.end();
} catch (error) {
for (const block of output.content) delete (block as { index?: number }).index;
const firstEventTimeoutError = abortTracker.getLocalAbortReason();
output.stopReason = abortTracker.wasCallerAbort() ? "aborted" : "error";
output.errorStatus = extractHttpStatusFromError(error);
output.errorMessage = await finalizeErrorMessage(error, rawRequestDump);
output.errorMessage = firstEventTimeoutError?.message ?? (await finalizeErrorMessage(error, rawRequestDump));
output.errorMessage = rewriteCopilotError(output.errorMessage, error, model.provider);
output.duration = Date.now() - startTime;
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
@@ -259,7 +310,6 @@ function createClient(
sessionId?: string,
onSseEvent?: OpenAIResponsesOptions["onSseEvent"],
fetchOverride?: FetchImpl,
streamFirstEventTimeoutOverride?: number,
): {
client: OpenAI;
copilotPremiumRequests: number | undefined;
@@ -298,7 +348,6 @@ function createClient(
headers["x-client-request-id"] ??= sessionId;
}
const baseFetch = fetchOverride ?? fetch;
const sdkTimeoutMs = resolveSdkTimeoutMs(streamFirstEventTimeoutOverride);
return {
client: new OpenAI({
apiKey,
@@ -307,19 +356,24 @@ function createClient(
maxRetries: 5,
defaultHeaders: headers,
fetch: onSseEvent ? wrapFetchForSseDebug(baseFetch, event => onSseEvent(event, model)) : baseFetch,
...(sdkTimeoutMs !== undefined ? { timeout: sdkTimeoutMs } : {}),
}),
copilotPremiumRequests,
baseUrl,
};
}
function getOpenAIResponsesCacheSessionId(
function getOpenAIResponsesPromptCacheKey(
options: Pick<OpenAIResponsesOptions, "cacheRetention" | "promptCacheKey" | "sessionId"> | undefined,
): string | undefined {
if (resolveCacheRetention(options?.cacheRetention) === "none") return undefined;
return normalizeOpenAIResponsesPromptCacheKey(options?.promptCacheKey ?? options?.sessionId);
}
function getOpenAIResponsesRoutingSessionId(
options: Pick<OpenAIResponsesOptions, "cacheRetention" | "sessionId"> | undefined,
): string | undefined {
return resolveCacheRetention(options?.cacheRetention) === "none"
? undefined
: normalizeOpenAIResponsesPromptCacheKey(options?.sessionId);
if (resolveCacheRetention(options?.cacheRetention) === "none") return undefined;
return normalizeOpenAIResponsesPromptCacheKey(options?.sessionId);
}
function buildParams(
@@ -359,7 +413,7 @@ function buildParams(
}
const cacheRetention = resolveCacheRetention(options?.cacheRetention);
const promptCacheKey = getOpenAIResponsesCacheSessionId(options);
const promptCacheKey = getOpenAIResponsesPromptCacheKey(options);
const params: OpenAIResponsesSamplingParams = {
model: model.id,
input: messages,
@@ -58,6 +58,7 @@ const ALLOWED_OPTION_KEYS: ReadonlySet<keyof SimpleStreamOptions> = new Set([
"maxRetryDelayMs",
"metadata",
"sessionId",
"promptCacheKey",
"streamFirstEventTimeoutMs",
"streamIdleTimeoutMs",
"reasoning",
+81 -8
View File
@@ -20,8 +20,8 @@ import type {
OptionsForApi,
} from "../types";
import { type AbortSourceTracker, createAbortSourceTracker } from "../utils/abort";
import { iterateUntilAbort } from "../utils/abortable-iterator";
import { AssistantMessageEventStream as EventStreamImpl } from "../utils/event-stream";
import { getStreamFirstEventTimeoutMs, getStreamIdleTimeoutMs, iterateWithIdleTimeout } from "../utils/idle-iterator";
import type { BedrockOptions } from "./amazon-bedrock";
import type { AnthropicOptions } from "./anthropic";
import type { AzureOpenAIResponsesOptions } from "./azure-openai-responses";
@@ -157,22 +157,82 @@ export function setBedrockProviderModule(module: BedrockProviderModule): void {
// Stream forwarding / error helpers
// ---------------------------------------------------------------------------
const LAZY_STREAM_IDLE_TIMEOUT_ERROR = "Provider stream stalled while waiting for the next event";
const LAZY_STREAM_FIRST_EVENT_TIMEOUT_ERROR = "Provider stream timed out while waiting for the first event";
function hasFinalResult(
source: AsyncIterable<AssistantMessageEvent>,
): source is AsyncIterable<AssistantMessageEvent> & { result(): Promise<AssistantMessage> } {
return typeof (source as { result?: unknown }).result === "function";
}
/**
* Per-provider default overrides for the lazy stream watchdogs. These widen the
* floor used when neither caller option nor env var pins a value. The env vars
* (`PI_STREAM_FIRST_EVENT_TIMEOUT_MS`, `PI_STREAM_IDLE_TIMEOUT_MS`) still take
* precedence; `StreamOptions.streamFirstEventTimeoutMs` / `streamIdleTimeoutMs`
* still trump everything.
*/
interface LazyStreamLimits {
defaultFirstEventTimeoutMs?: number;
defaultIdleTimeoutMs?: number;
/**
* The provider implementation already wraps its upstream transport with
* stream timeouts. Keep the lazy loader from racing it with generic errors.
*/
providerHandlesStreamTimeouts?: boolean;
}
/**
* Cloud Code Assist (google-gemini-cli / google-antigravity) routinely takes
* longer than the global 100s default to emit its first SSE event when serving
* the heavier Gemini 3.x Pro tiers at high thinking levels. Bump the first-event
* floor to five minutes so callers stop seeing spurious "stream timed out while
* waiting for the first event" aborts on legitimate cold reasoning starts.
* The steady-state idle watchdog stays on the global default since the upstream
* emits thinking tokens frequently once it gets going.
*/
const GOOGLE_GEMINI_CLI_LAZY_STREAM_LIMITS: LazyStreamLimits = {
defaultFirstEventTimeoutMs: 300_000,
};
const PROVIDER_HANDLED_STREAM_TIMEOUTS: LazyStreamLimits = {
providerHandlesStreamTimeouts: true,
};
function forwardStream<TApi extends Api>(
target: EventStreamImpl,
source: AsyncIterable<AssistantMessageEvent>,
model: Model<TApi>,
options: OptionsForApi<TApi>,
abortTracker: AbortSourceTracker,
limits?: LazyStreamLimits,
): void {
(async () => {
try {
for await (const event of iterateUntilAbort(source, options.signal)) {
const providerHandlesStreamTimeouts = limits?.providerHandlesStreamTimeouts === true;
const idleTimeoutMs = providerHandlesStreamTimeouts
? undefined
: (options.streamIdleTimeoutMs ?? getStreamIdleTimeoutMs(limits?.defaultIdleTimeoutMs));
const watchedSource = iterateWithIdleTimeout(source, {
idleTimeoutMs,
firstItemTimeoutMs: providerHandlesStreamTimeouts
? 0
: (options.streamFirstEventTimeoutMs ??
getStreamFirstEventTimeoutMs(idleTimeoutMs, limits?.defaultFirstEventTimeoutMs)),
errorMessage: LAZY_STREAM_IDLE_TIMEOUT_ERROR,
firstItemErrorMessage: LAZY_STREAM_FIRST_EVENT_TIMEOUT_ERROR,
onIdle: () => abortTracker.abortLocally(new Error(LAZY_STREAM_IDLE_TIMEOUT_ERROR)),
onFirstItemTimeout: () => abortTracker.abortLocally(new Error(LAZY_STREAM_FIRST_EVENT_TIMEOUT_ERROR)),
abortSignal: options.signal,
// The synthetic `start` event is yielded immediately by every provider before
// the upstream model has emitted any tokens. Treating it as the first "real"
// item would flip the watchdog from `firstItemTimeoutMs` to the much shorter
// `idleTimeoutMs` while we're still legitimately waiting on the model's
// first response (slow first-token from reasoning models, cold proxies, etc.).
isProgressItem: event => (event as AssistantMessageEvent).type !== "start",
});
for await (const event of watchedSource) {
target.push(event);
}
if (hasFinalResult(source)) {
@@ -221,6 +281,7 @@ function createLazyLoadErrorMessage<TApi extends Api>(
function createLazyStream<TApi extends Api>(
loadModule: () => Promise<LazyProviderModule<TApi>>,
limits?: LazyStreamLimits,
): (model: Model<TApi>, context: Context, options: OptionsForApi<TApi>) => EventStreamImpl {
return (model, context, options) => {
const outer = new EventStreamImpl();
@@ -231,7 +292,7 @@ function createLazyStream<TApi extends Api>(
const abortTracker = createAbortSourceTracker(streamOptions.signal);
const providerOptions = { ...streamOptions, signal: abortTracker.requestSignal } as OptionsForApi<TApi>;
const inner = module.stream(model, context, providerOptions);
forwardStream(outer, inner, model, streamOptions, abortTracker);
forwardStream(outer, inner, model, streamOptions, abortTracker, limits);
})
.catch(error => {
const message = createLazyLoadErrorMessage(model, error);
@@ -346,14 +407,26 @@ function loadBedrockProviderModule(): Promise<LazyProviderModule<"bedrock-conver
// providers, the lazy loading will take effect on the main code path.
// ---------------------------------------------------------------------------
export const streamAnthropic = createLazyStream(loadAnthropicProviderModule);
export const streamAzureOpenAIResponses = createLazyStream(loadAzureOpenAIResponsesProviderModule);
export const streamAnthropic = createLazyStream(loadAnthropicProviderModule, PROVIDER_HANDLED_STREAM_TIMEOUTS);
export const streamAzureOpenAIResponses = createLazyStream(
loadAzureOpenAIResponsesProviderModule,
PROVIDER_HANDLED_STREAM_TIMEOUTS,
);
export const streamGoogle = createLazyStream(loadGoogleProviderModule);
export const streamGoogleGeminiCli = createLazyStream(loadGoogleGeminiCliProviderModule);
export const streamGoogleGeminiCli = createLazyStream(
loadGoogleGeminiCliProviderModule,
GOOGLE_GEMINI_CLI_LAZY_STREAM_LIMITS,
);
export const streamGoogleVertex = createLazyStream(loadGoogleVertexProviderModule);
export const streamOpenAICodexResponses = createLazyStream(loadOpenAICodexResponsesProviderModule);
export const streamOpenAICompletions = createLazyStream(loadOpenAICompletionsProviderModule);
export const streamOpenAIResponses = createLazyStream(loadOpenAIResponsesProviderModule);
export const streamOpenAICompletions = createLazyStream(
loadOpenAICompletionsProviderModule,
PROVIDER_HANDLED_STREAM_TIMEOUTS,
);
export const streamOpenAIResponses = createLazyStream(
loadOpenAIResponsesProviderModule,
PROVIDER_HANDLED_STREAM_TIMEOUTS,
);
export const streamCursor = createLazyStream(loadCursorProviderModule);
export const streamOllama = createLazyStream(loadOllamaProviderModule);
+30 -5
View File
@@ -14,6 +14,7 @@ import type { AnthropicOptions } from "./providers/anthropic";
import type { CursorOptions } from "./providers/cursor";
import { isGitLabDuoModel, streamGitLabDuo } from "./providers/gitlab-duo";
import type { GoogleOptions } from "./providers/google";
import { getVertexAccessToken } from "./providers/google-auth";
import type { GoogleGeminiCliOptions } from "./providers/google-gemini-cli";
import type { GoogleVertexOptions } from "./providers/google-vertex";
import { isKimiModel, streamKimi } from "./providers/kimi";
@@ -47,6 +48,7 @@ import type {
AssistantMessage,
AssistantMessageEvent,
Context,
FetchImpl,
Model,
OptionsForApi,
SimpleStreamOptions,
@@ -72,6 +74,24 @@ function hasVertexAdcCredentials(): boolean {
}
return cachedVertexAdcCredentialsExists;
}
function isGoogleVertexOpenAIModel(model: Model<Api>): boolean {
return (
model.provider === "google-vertex" &&
model.api === "openai-completions" &&
model.baseUrl.includes("/endpoints/openapi")
);
}
function createVertexOpenAIFetch(options: StreamOptions | undefined): FetchImpl {
const baseFetch = options?.fetch ?? fetch;
const vertexFetch = async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
const token = await getVertexAccessToken({ signal: options?.signal, fetch: baseFetch });
const headers = new Headers(init?.headers);
headers.set("Authorization", `Bearer ${token}`);
return baseFetch(input, { ...init, headers });
};
return Object.assign(vertexFetch, baseFetch.preconnect ? { preconnect: baseFetch.preconnect } : {});
}
type KeyResolver = string | (() => string | undefined);
@@ -119,16 +139,16 @@ const serviceProviderMap: Record<string, KeyResolver> = {
return $env.GOOGLE_CLOUD_API_KEY;
}
const hasCredentials = hasVertexAdcCredentials();
const hasProject = !!($env.GOOGLE_CLOUD_PROJECT || $env.GCLOUD_PROJECT);
const hasLocation = !!$env.GOOGLE_CLOUD_LOCATION;
const hasProject = !!($env.GOOGLE_CLOUD_PROJECT || $env.GCP_PROJECT || $env.GCLOUD_PROJECT);
const hasLocation = !!($env.GOOGLE_VERTEX_LOCATION || $env.GOOGLE_CLOUD_LOCATION || $env.VERTEX_LOCATION);
if (hasCredentials && hasProject && hasLocation) {
return "<authenticated>";
}
},
// Amazon Bedrock supports multiple credential sources:
// 1. AWS_PROFILE - named profile from ~/.aws/credentials
// 1. AWS_BEARER_TOKEN_BEDROCK - Bedrock API keys (bearer token)
// 2. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY - standard IAM keys
// 3. AWS_BEARER_TOKEN_BEDROCK - Bedrock API keys (bearer token)
// 3. AWS_PROFILE - named profile from ~/.aws/credentials
// 4. AWS_CONTAINER_CREDENTIALS_* - ECS/Task IAM role credentials
// 5. AWS_WEB_IDENTITY_TOKEN_FILE + AWS_ROLE_ARN - IRSA (EKS) web identity
"amazon-bedrock": () => {
@@ -222,7 +242,9 @@ export function stream<TApi extends Api>(
if (!apiKey) {
throw new Error(`No API key for provider: ${model.provider}`);
}
const providerOptions = { ...options, apiKey };
const providerOptions = isGoogleVertexOpenAIModel(model)
? { ...options, apiKey: "vertex-adc", fetch: createVertexOpenAIFetch(options as StreamOptions | undefined) }
: { ...options, apiKey };
const api: Api = model.api;
switch (api) {
@@ -564,6 +586,9 @@ function mapOptionsForApi<TApi extends Api>(
maxRetryDelayMs: options?.maxRetryDelayMs,
metadata: options?.metadata,
sessionId: options?.sessionId,
promptCacheKey: options?.promptCacheKey,
streamFirstEventTimeoutMs: options?.streamFirstEventTimeoutMs,
streamIdleTimeoutMs: options?.streamIdleTimeoutMs,
providerSessionState: options?.providerSessionState,
onPayload: options?.onPayload,
onResponse: options?.onResponse,
+30 -17
View File
@@ -316,11 +316,17 @@ export interface StreamOptions {
*/
metadata?: Record<string, unknown>;
/**
* Optional session identifier for providers that support session-based caching.
* Providers can use this to enable prompt caching, request routing, or other
* session-aware features. Ignored by providers that don't support it.
* Optional session identifier for providers that support session-based
* routing, request affinity, or transport reuse. Providers may also use this
* as the prompt-cache key when `promptCacheKey` is not set.
*/
sessionId?: string;
/**
* Optional prompt-cache identity. When set, OpenAI Responses-compatible
* providers use this for `prompt_cache_key` while keeping `sessionId` for
* provider routing / conversation headers.
*/
promptCacheKey?: string;
/**
* Provider-scoped mutable state store for this agent session.
* Providers can use this to persist transport/session state between turns.
@@ -336,30 +342,37 @@ export interface StreamOptions {
*/
onResponse?: (response: ProviderResponseMetadata, model?: Model<Api>) => void | Promise<void>;
/**
* Optional callback for raw Server-Sent Events as they arrive from HTTP streaming providers.
* Optional callback for raw Server-Sent Events as they arrive from HTTP streaming providers,
* plus synthesized SSE-shaped frames for the Codex WebSocket transport (one synthetic frame
* per JSON request/response message). WebSocket frames are tagged with a leading
* `: ws → <type>` (outbound) or `: ws ← <type>` (inbound) comment line in `RawSseEvent.raw`.
*
* Diagnostic only: provider implementations must ignore callback failures and must not
* let observers alter stream contents.
*/
onSseEvent?: (event: RawSseEvent, model?: Model<Api>) => void;
/**
* Optional SDK/request timeout hint in milliseconds applied to the underlying HTTP
* request when the provider's transport exposes a per-request timeout.
* Optional override for the first-event watchdog in milliseconds. Built-in
* providers apply this budget twice when they can: once to the underlying
* SDK/request while waiting for the HTTP stream object to exist, then again
* in the iterator while waiting for the first semantic stream event. Set to
* `0` to disable both layers for this request. After the first semantic
* event arrives, `streamIdleTimeoutMs` governs inter-event stalls. Falls
* back to `PI_STREAM_FIRST_EVENT_TIMEOUT_MS` and then to a 100s default.
*
* Honored by: `openai-completions`, `openai-responses`, `azure-openai-responses`,
* `anthropic-messages`.
* Ignored by: `openai-codex-responses` (uses its own websocket/SSE transport),
* `google`, `google-gemini-cli`, `google-vertex`, `bedrock-converse`, `cursor-agent`.
*
* Provider stream silence is never treated as failure on its own — once the request
* has started, callers must abort to interrupt a silent stream.
* Iterator-level honored by: every built-in provider (via the lazy-stream
* forwarder in `register-builtins`). SDK-request honored by:
* `openai-completions`, `openai-responses`, `azure-openai-responses`,
* `anthropic-messages`.
*/
streamFirstEventTimeoutMs?: number;
/**
* @deprecated Stream idle watchdogs were removed; provider streams now wait for
* provider output, provider/socket errors, caller aborts, or request-layer timeouts.
* This field is accepted for backwards compatibility but no longer wired anywhere.
* Will be removed in the next major release.
* Optional override for the maximum idle gap between streamed events in
* milliseconds. Once the first event arrives, this guards against silent
* mid-stream stalls (broker dies, half-open socket, model produces no real
* progress for too long). Set to `0` to disable. Falls back to
* `PI_STREAM_IDLE_TIMEOUT_MS` (alias: `PI_OPENAI_STREAM_IDLE_TIMEOUT_MS`)
* and then to a 120s default.
*/
streamIdleTimeoutMs?: number;
/**
+1
View File
@@ -2,3 +2,4 @@ export * from "./antigravity";
export * from "./codex";
export * from "./gemini";
export * from "./openai-compatible";
export * from "./vertex";
+210
View File
@@ -0,0 +1,210 @@
import { UNK_CONTEXT_WINDOW, UNK_MAX_TOKENS } from "@oh-my-pi/pi-ai";
import * as z from "zod/v4";
import { getVertexAccessToken } from "../../providers/google-auth";
import type { FetchImpl, Model } from "../../types";
const API_VERSION = "v1";
const DEFAULT_PAGE_SIZE = 100;
const DEFAULT_MAX_PAGES = 25;
const vertexOpenAIModelSchema = z.object({
id: z.string().optional().catch(undefined),
name: z.string().optional().catch(undefined),
displayName: z.string().optional().catch(undefined),
});
const vertexOpenAIModelsResponseSchema = z.object({
data: z
.array(z.unknown())
.optional()
.transform(items => {
if (!items) return [];
const parsedItems: VertexOpenAIModelItem[] = [];
for (const item of items) {
const parsed = vertexOpenAIModelSchema.safeParse(item);
if (parsed.success) parsedItems.push(parsed.data);
}
return parsedItems;
}),
nextPageToken: z.string().optional().catch(undefined),
});
type VertexOpenAIModelItem = z.infer<typeof vertexOpenAIModelSchema>;
/** Configuration for Vertex AI OpenAI-compatible model discovery. */
export interface VertexDiscoveryOptions {
/** Google Cloud project ID hosting the Vertex AI endpoint. */
project: string;
/** Vertex AI location, for example `global` or `us-central1`. */
location: string;
/** Optional requested page size for model listing. */
pageSize?: number;
/** Maximum number of pages to request before stopping pagination. */
maxPages?: number;
/** Optional abort signal for HTTP requests. */
signal?: AbortSignal;
/** Optional fetch implementation override for tests. */
fetch?: FetchImpl;
}
/**
* Fetches models exposed by Vertex AI's OpenAI-compatible endpoint.
*
* Returns `null` on auth, transport, or protocol failures so callers can fall
* back to cache/static models without surfacing discovery noise at startup.
*/
export async function fetchVertexOpenAIModels(
options: VertexDiscoveryOptions,
): Promise<Model<"openai-completions">[] | null> {
const project = options.project.trim();
const location = options.location.trim();
if (!project || !location) return null;
const fetchImpl = options.fetch ?? fetch;
const baseUrl = buildVertexOpenAIBaseUrl(project, location);
const pageSize = normalizePositiveInt(options.pageSize, DEFAULT_PAGE_SIZE);
const maxPages = normalizePositiveInt(options.maxPages, DEFAULT_MAX_PAGES);
let accessToken: string;
try {
accessToken = await getVertexAccessToken({ signal: options.signal, fetch: fetchImpl });
} catch {
return null;
}
const modelsById = new Map<string, Model<"openai-completions">>();
const seenTokens = new Set<string>();
let nextPageToken: string | undefined;
for (let page = 0; page < maxPages; page += 1) {
const requestUrl = buildModelsUrl(baseUrl, pageSize, nextPageToken);
let response: Response;
try {
response = await fetchImpl(requestUrl, {
method: "GET",
headers: { Authorization: `Bearer ${accessToken}` },
signal: options.signal,
});
} catch {
return null;
}
if (!response.ok) return null;
let payload: unknown;
try {
payload = await response.json();
} catch {
return null;
}
const parsed = vertexOpenAIModelsResponseSchema.safeParse(payload);
if (!parsed.success) return null;
for (const item of parsed.data.data) {
const model = normalizeModel(item, baseUrl);
if (model) modelsById.set(model.id, model);
}
const token = normalizePageToken(parsed.data.nextPageToken);
if (!token || seenTokens.has(token)) break;
seenTokens.add(token);
nextPageToken = token;
}
return Array.from(modelsById.values()).sort((left, right) => left.id.localeCompare(right.id));
}
/** Returns the stable Vertex AI OpenAI-compatible endpoint base URL. */
export function buildVertexOpenAIBaseUrl(project: string, location: string): string {
const host = location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`;
return `https://${host}/${API_VERSION}/projects/${project}/locations/${location}/endpoints/openapi`;
}
function buildModelsUrl(baseUrl: string, pageSize: number, pageToken?: string): URL {
const url = new URL(`${baseUrl}/models`);
url.searchParams.set("pageSize", String(pageSize));
if (pageToken) url.searchParams.set("pageToken", pageToken);
return url;
}
function normalizePositiveInt(value: number | undefined, fallback: number): number {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return fallback;
const normalized = Math.floor(value);
return normalized > 0 ? normalized : fallback;
}
function normalizePageToken(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const token = value.trim();
return token.length > 0 ? token : undefined;
}
function normalizeModel(item: VertexOpenAIModelItem, baseUrl: string): Model<"openai-completions"> | null {
const id = normalizeModelId(item.id ?? item.name);
if (!id) return null;
return {
id,
name: normalizeModelName(item.displayName, id),
api: "openai-completions",
provider: "google-vertex",
baseUrl,
reasoning: inferReasoning(id),
input: inferInput(id),
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: UNK_CONTEXT_WINDOW,
maxTokens: UNK_MAX_TOKENS,
};
}
function normalizeModelId(value: string | undefined): string | null {
if (!value) return null;
const trimmed = value.trim();
if (!trimmed) return null;
const marker = "/models/";
const markerIndex = trimmed.lastIndexOf(marker);
if (markerIndex >= 0) {
const modelId = trimmed.slice(markerIndex + marker.length);
const publisher = extractPublisher(trimmed.slice(0, markerIndex));
return publisher ? `${publisher}/${modelId}` : modelId;
}
return trimmed;
}
function extractPublisher(prefix: string): string | undefined {
const marker = "/publishers/";
const markerIndex = prefix.lastIndexOf(marker);
if (markerIndex < 0) return undefined;
const publisher = prefix.slice(markerIndex + marker.length).trim();
return publisher.length > 0 ? publisher : undefined;
}
function normalizeModelName(displayName: string | undefined, id: string): string {
const trimmed = displayName?.trim();
return trimmed ? trimmed : id;
}
function inferReasoning(id: string): boolean {
const normalized = id.toLowerCase();
return (
normalized.includes("thinking") ||
normalized.includes("reasoning") ||
normalized.includes("glm-4.5") ||
normalized.includes("glm-4.6") ||
normalized.includes("glm-4.7") ||
normalized.includes("glm-5") ||
normalized.includes("gemini-2.5") ||
normalized.includes("gemini-3")
);
}
function inferInput(id: string): ("text" | "image")[] {
const normalized = id.toLowerCase();
if (
normalized.includes("gemini") ||
normalized.includes("vision") ||
normalized.includes("image") ||
normalized.includes("vl")
) {
return ["text", "image"];
}
return ["text"];
}
-60
View File
@@ -1,60 +0,0 @@
/**
* Patch `globalThis.fetch` to advertise HTTP/2 in TLS ALPN, with transparent
* HTTP/1.1 fallback when the server doesn't negotiate `h2`.
*
* Bun's HTTP/2 client is gated on `BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT`,
* read by the native runtime before any JS executes; assigning to
* `process.env` from inside JS is a no-op. Per-request `protocol: "http2"`
* activates h2 over TLS ALPN and rejects with `error.code === "HTTP2Unsupported"`
* if the server picks anything else, so we catch and retry without the hint.
*
* Some HTTPS endpoints (e.g. corporate API gateways behind reverse proxies)
* advertise h2 via ALPN but then refuse or reset the connection at the HTTP/2
* framing layer. Bun surfaces these as `ConnectionRefused`, `ConnectionReset`,
* or `ConnectionClosed` rather than `HTTP2Unsupported`, so we treat those
* codes as h2-fallback triggers as well.
*
* Bun negotiates h2 via ALPN over TLS only (no h2c), so plain `http://` URLs
* skip the attempt entirely — avoids the throw/retry round-trip for localhost.
*
* Idempotent.
*/
const installed: unique symbol = Symbol.for("oh-my-pi.h2fetch.installed");
interface PatchedFetch {
[installed]?: true;
}
export function installH2Fetch(): void {
const original = globalThis.fetch as typeof fetch & PatchedFetch;
if (original[installed]) return;
/** Error codes that indicate h2 negotiation/transport failure (not an application error). */
const h2FallbackCodes: ReadonlySet<string> = new Set([
"HTTP2Unsupported", // Server selected h1 in ALPN
"ConnectionRefused", // Server refused the h2 connection
"ConnectionReset", // Server reset during h2 handshake
"ConnectionClosed", // Server closed before h2 response
]);
const wrapper = async function h2fetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {
if (!isHttps(input)) return original(input, init);
try {
return await original(input, { ...init, protocol: "http2" });
} catch (err) {
if (!h2FallbackCodes.has((err as { code?: string }).code ?? "")) throw err;
return original(input, init);
}
} as typeof fetch & PatchedFetch;
// Preserve `fetch.preconnect` and any other statics SDK code might poke at.
Object.assign(wrapper, original);
wrapper[installed] = true;
globalThis.fetch = wrapper;
}
function isHttps(input: string | URL | Request): boolean {
if (typeof input === "string") return input.startsWith("https:");
if (input instanceof URL) return input.protocol === "https:";
return input.url.startsWith("https:");
}
+239
View File
@@ -0,0 +1,239 @@
import { $env } from "@oh-my-pi/pi-utils";
const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 120_000;
const DEFAULT_STREAM_FIRST_EVENT_TIMEOUT_MS = 100_000;
function normalizeIdleTimeoutMs(value: string | undefined, fallback: number): number | undefined {
if (value === undefined) return fallback;
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
if (parsed <= 0) return undefined;
return Math.trunc(parsed);
}
/**
* Returns the idle timeout used for provider streaming transports.
*
* `PI_OPENAI_STREAM_IDLE_TIMEOUT_MS` is accepted as a backward-compatible alias.
* Set `PI_STREAM_IDLE_TIMEOUT_MS=0` to disable the watchdog.
*
* Providers that legitimately stream much slower than the global default can pass
* `fallbackMs` to widen the floor used when neither env var nor caller option is set.
* Caller options still take precedence; env overrides still trump the fallback.
*/
export function getStreamIdleTimeoutMs(fallbackMs: number = DEFAULT_STREAM_IDLE_TIMEOUT_MS): number | undefined {
return normalizeIdleTimeoutMs($env.PI_STREAM_IDLE_TIMEOUT_MS ?? $env.PI_OPENAI_STREAM_IDLE_TIMEOUT_MS, fallbackMs);
}
/**
* Returns the idle timeout used for OpenAI-family streaming transports.
*
* Set `PI_OPENAI_STREAM_IDLE_TIMEOUT_MS=0` to disable the watchdog.
*/
export function getOpenAIStreamIdleTimeoutMs(): number | undefined {
return normalizeIdleTimeoutMs(
$env.PI_OPENAI_STREAM_IDLE_TIMEOUT_MS ?? $env.PI_STREAM_IDLE_TIMEOUT_MS,
DEFAULT_STREAM_IDLE_TIMEOUT_MS,
);
}
/**
* Returns the timeout used while waiting for the first stream event.
* The first token can legitimately take longer than later inter-event gaps,
* so the default never undershoots the steady-state idle timeout.
*
* Set `PI_STREAM_FIRST_EVENT_TIMEOUT_MS=0` to disable the watchdog.
*
* Providers whose first response can legitimately take longer (heavy reasoning,
* slow cold-start proxies) can pass `fallbackMs` to widen the floor used when
* neither env var nor caller option is set. Caller options still take precedence;
* env overrides still trump the fallback.
*/
export function getStreamFirstEventTimeoutMs(
idleTimeoutMs?: number,
fallbackMs: number = DEFAULT_STREAM_FIRST_EVENT_TIMEOUT_MS,
): number | undefined {
const fallback = idleTimeoutMs === undefined ? fallbackMs : Math.max(fallbackMs, idleTimeoutMs);
return normalizeIdleTimeoutMs($env.PI_STREAM_FIRST_EVENT_TIMEOUT_MS, fallback);
}
export interface IdleTimeoutIteratorOptions {
idleTimeoutMs?: number;
firstItemTimeoutMs?: number;
errorMessage: string;
firstItemErrorMessage?: string;
onIdle?: () => void;
onFirstItemTimeout?: () => void;
/**
* Optional semantic-progress predicate. Non-progress items are still yielded,
* but they do not reset the idle deadline. This prevents provider
* keepalive/no-op events from keeping a stalled tool call alive forever.
*/
isProgressItem?: (item: unknown) => boolean;
/**
* Cancel iteration as soon as this signal aborts. Required for caller-driven
* cancellation (ESC) when the underlying transport does not surface signal
* aborts to the iterator (HTTP/2 proxies, native sockets, mocked fetch).
* Without this, the consumer sleeps on iterator.next() until the idle/first
* -event watchdog fires — observable as the issue #912 "Working… forever"
* symptom on the github-copilot provider.
*/
abortSignal?: AbortSignal;
}
/**
* Yields items from an async iterable while enforcing a maximum idle gap between items.
*
* The first item may use a shorter timeout so stuck requests can be aborted and retried
* before any user-visible content has streamed.
*/
export async function* iterateWithIdleTimeout<T>(
iterable: AsyncIterable<T>,
options: IdleTimeoutIteratorOptions,
): AsyncGenerator<T> {
const firstItemTimeoutMs = options.firstItemTimeoutMs ?? options.idleTimeoutMs;
const firstItemDeadlineMs =
firstItemTimeoutMs !== undefined && firstItemTimeoutMs > 0 ? Date.now() + firstItemTimeoutMs : undefined;
const abortSignal = options.abortSignal;
const iterator = iterable[Symbol.asyncIterator]();
const closeIterator = (): void => {
const returnPromise = iterator.return?.();
if (returnPromise) {
void returnPromise.catch(() => {});
}
};
if (abortSignal?.aborted) {
closeIterator();
throw abortReason(abortSignal);
}
const withRacy = <T>(promise: Promise<T>) =>
promise.then(
result => ({ kind: "next" as const, result }),
error => ({ kind: "error" as const, error }),
);
let awaitingFirstItem = true;
const markFirstItemReceived = () => {
awaitingFirstItem = false;
};
const isProgressItem = (item: T): boolean => {
if (!options.isProgressItem) return true;
try {
return options.isProgressItem(item);
} catch {
return true;
}
};
let lastProgressAt = Date.now();
const noTimeoutEnforced =
(firstItemTimeoutMs === undefined || firstItemTimeoutMs <= 0) &&
(options.idleTimeoutMs === undefined || options.idleTimeoutMs <= 0);
while (true) {
let activeTimeoutMs: number | undefined;
if (awaitingFirstItem) {
if (firstItemDeadlineMs !== undefined) {
activeTimeoutMs = firstItemDeadlineMs - Date.now();
if (activeTimeoutMs <= 0) {
options.onFirstItemTimeout?.();
closeIterator();
throw new Error(options.firstItemErrorMessage ?? options.errorMessage);
}
}
} else if (options.idleTimeoutMs !== undefined && options.idleTimeoutMs > 0) {
activeTimeoutMs = options.idleTimeoutMs - (Date.now() - lastProgressAt);
if (activeTimeoutMs <= 0) {
options.onIdle?.();
closeIterator();
throw new Error(options.errorMessage);
}
}
const nextResultPromise = withRacy(iterator.next());
const racers: Array<
Promise<
| { kind: "next"; result: IteratorResult<T> }
| { kind: "error"; error: unknown }
| { kind: "timeout" }
| { kind: "abort" }
>
> = [nextResultPromise];
let timer: NodeJS.Timeout | undefined;
let resolveTimeout: ((value: { kind: "timeout" }) => void) | undefined;
const enforceTimeout = !noTimeoutEnforced && activeTimeoutMs !== undefined && activeTimeoutMs > 0;
if (enforceTimeout) {
const { promise, resolve } = Promise.withResolvers<{ kind: "timeout" }>();
resolveTimeout = resolve;
timer = setTimeout(() => resolve({ kind: "timeout" }), activeTimeoutMs);
racers.push(promise);
}
let abortListener: (() => void) | undefined;
let resolveAbort: ((value: { kind: "abort" }) => void) | undefined;
if (abortSignal) {
const { promise, resolve } = Promise.withResolvers<{ kind: "abort" }>();
resolveAbort = resolve;
abortListener = () => resolve({ kind: "abort" });
abortSignal.addEventListener("abort", abortListener, { once: true });
racers.push(promise);
}
try {
const outcome = await Promise.race(racers);
if (outcome.kind === "abort") {
closeIterator();
throw abortReason(abortSignal!);
}
if (outcome.kind === "timeout") {
if (!awaitingFirstItem) {
options.onIdle?.();
} else {
options.onFirstItemTimeout?.();
}
closeIterator();
throw new Error(
!awaitingFirstItem ? options.errorMessage : (options.firstItemErrorMessage ?? options.errorMessage),
);
}
if (outcome.kind === "error") {
throw outcome.error;
}
if (outcome.result.done) {
markFirstItemReceived();
return;
}
const item = outcome.result.value;
// Non-progress items (e.g. provider keepalives, synthetic `start` events that
// arrive before the model has produced any tokens) MUST NOT flip us out of
// `awaitingFirstItem`. Otherwise the next iteration switches from the (longer)
// first-item watchdog to the (shorter) idle watchdog while we're still waiting
// on the model's first real output.
if (isProgressItem(item)) {
markFirstItemReceived();
lastProgressAt = Date.now();
}
yield item;
} finally {
if (timer !== undefined) clearTimeout(timer);
// Resolve dangling promises so the racers don't leak (Promise.race is one-shot).
resolveTimeout?.({ kind: "timeout" });
if (abortListener && abortSignal) {
abortSignal.removeEventListener("abort", abortListener);
}
resolveAbort?.({ kind: "abort" });
}
}
}
function abortReason(signal: AbortSignal): Error {
const reason = signal.reason;
if (reason instanceof Error) return reason;
if (typeof reason === "string") return new Error(reason);
return new Error("Request was aborted");
}
+11 -4
View File
@@ -1,4 +1,12 @@
/** Moonshot login flow (API key paste against https://api.moonshot.ai/v1). */
/**
* Moonshot login flow (API key paste against https://api.moonshot.ai/v1).
*
* Validation hits `GET /v1/models` rather than a chat completion. Moonshot's
* thinking models (e.g. kimi-k2.5/k2.6) reject the `temperature: 0` probe used
* by the chat-completions validator with `invalid temperature: only 1 is
* allowed for this model`, so a hello-world chat call cannot authenticate the
* key reliably across the Moonshot catalog.
*/
import { createApiKeyLogin } from "./api-key-login";
export const loginMoonshot = createApiKeyLogin({
@@ -8,9 +16,8 @@ export const loginMoonshot = createApiKeyLogin({
promptMessage: "Paste your Moonshot API key",
placeholder: "sk-...",
validation: {
kind: "chat-completions",
kind: "models-endpoint",
provider: "moonshot",
baseUrl: "https://api.moonshot.ai/v1",
model: "kimi-k2.5",
modelsUrl: "https://api.moonshot.ai/v1/models",
},
});
+1
View File
@@ -34,6 +34,7 @@ export const UNSUPPORTED_SCHEMA_FIELDS: Record<string, true> = {
maximum: true,
exclusiveMinimum: true,
exclusiveMaximum: true,
multipleOf: true,
pattern: true,
format: true,
};
@@ -4,6 +4,8 @@ import { streamAnthropic } from "../src/providers/anthropic";
import type { Context, Model } from "../src/types";
import { waitForDelayOrAbort } from "./helpers";
const originalFetch = global.fetch;
const model: Model<"anthropic-messages"> = {
id: "claude-sonnet-4-5",
name: "Claude Sonnet 4.5",
@@ -90,13 +92,11 @@ function createSuccessfulAnthropicEvents(text: string): MockAnthropicEvent[] {
function createAnthropicMockStream({
signal,
connectDelayMs = 0,
firstEventDelayMs = 0,
events,
hangAfterEvents = false,
}: {
signal: AbortSignal | undefined;
connectDelayMs?: number;
firstEventDelayMs?: number;
events?: MockAnthropicEvent[];
hangAfterEvents?: boolean;
}): MockAnthropicRequest {
@@ -111,9 +111,6 @@ function createAnthropicMockStream({
await waitForAbortAndThrowAbortError(signal);
return;
}
if (firstEventDelayMs > 0) {
await waitForDelayOrAbort(firstEventDelayMs, signal);
}
for (const event of events) {
yield event;
}
@@ -138,18 +135,20 @@ function createAnthropicMockStream({
}
afterEach(() => {
// No shared globals to restore; keep hook so the suite stays explicit.
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe("anthropic stream silence", () => {
it("waits for delayed first stream events without retrying", async () => {
describe("anthropic first-event timeout retries", () => {
it("retries when the provider never sends the first stream event", async () => {
let attempt = 0;
const create = ((_body: unknown, requestOptions?: { signal?: AbortSignal }) => {
const requestTimeouts: Array<number | undefined> = [];
const create = ((_body: unknown, requestOptions?: { signal?: AbortSignal; timeout?: number }) => {
attempt += 1;
requestTimeouts.push(requestOptions?.timeout);
return createAnthropicMockStream({
signal: requestOptions?.signal,
firstEventDelayMs: 10,
events: createSuccessfulAnthropicEvents("slow first event"),
events: attempt === 1 ? undefined : createSuccessfulAnthropicEvents("retry recovered"),
}) as never;
}) as unknown as Anthropic["messages"]["create"];
const client = { messages: { create } } as Anthropic;
@@ -161,15 +160,18 @@ describe("anthropic stream silence", () => {
providerRetryWait,
}).result();
expect(attempt).toBe(1);
expect(providerRetryWait).not.toHaveBeenCalled();
expect(attempt).toBe(2);
expect(providerRetryWait).toHaveBeenCalledWith(2000, undefined);
expect(requestTimeouts).toEqual([1, 1]);
expect(result.stopReason).toBe("stop");
expect(result.content).toEqual([{ type: "text", text: "slow first event" }]);
expect(result.content).toEqual([{ type: "text", text: "retry recovered" }]);
expect(result.responseId).toBe("msg_retry_success");
});
it("does not arm the Anthropic first-event watchdog before the stream connects", async () => {
const create = ((_body: unknown, requestOptions?: { signal?: AbortSignal }) => {
let seenRequestTimeout: number | undefined;
const create = ((_body: unknown, requestOptions?: { signal?: AbortSignal; timeout?: number }) => {
seenRequestTimeout = requestOptions?.timeout;
return createAnthropicMockStream({
signal: requestOptions?.signal,
connectDelayMs: 2,
@@ -180,13 +182,41 @@ describe("anthropic stream silence", () => {
const result = await streamAnthropic(model, context, {
client,
streamFirstEventTimeoutMs: 1,
streamFirstEventTimeoutMs: 20,
}).result();
expect(result.stopReason).toBe("stop");
expect(seenRequestTimeout).toBe(20);
expect(result.content).toEqual([{ type: "text", text: "delayed connect" }]);
});
it("times out before the Anthropic stream connects and forwards the budget to the SDK request", async () => {
let attempt = 0;
const requestTimeouts: Array<number | undefined> = [];
const create = ((_body: unknown, requestOptions?: { signal?: AbortSignal; timeout?: number }) => {
attempt += 1;
requestTimeouts.push(requestOptions?.timeout);
return createAnthropicMockStream({
signal: requestOptions?.signal,
connectDelayMs: 20,
events: createSuccessfulAnthropicEvents("too late"),
}) as never;
}) as unknown as Anthropic["messages"]["create"];
const client = { messages: { create } } as Anthropic;
const providerRetryWait = vi.fn(async () => {});
const result = await streamAnthropic(model, context, {
client,
streamFirstEventTimeoutMs: 1,
providerRetryWait,
}).result();
expect(attempt).toBe(4);
expect(providerRetryWait).toHaveBeenCalledTimes(3);
expect(requestTimeouts).toEqual([1, 1, 1, 1]);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toBe("Anthropic stream timed out while waiting for the first event");
});
it("keeps caller aborts as aborted instead of retrying them as first-event timeouts", async () => {
let attempt = 0;
const create = ((_body: unknown, requestOptions?: { signal?: AbortSignal }) => {
@@ -209,7 +239,7 @@ describe("anthropic stream silence", () => {
expect(result.errorMessage).not.toBe("Anthropic stream timed out while waiting for the first event");
expect((result.errorMessage ?? "").toLowerCase()).toContain("abort");
});
it("waits through silent gaps between tool-call events until caller aborts", async () => {
it("fails hung Anthropic streams between tool-call events instead of waiting forever", async () => {
let attempt = 0;
const create = ((_body: unknown, requestOptions?: { signal?: AbortSignal }) => {
attempt += 1;
@@ -243,19 +273,16 @@ describe("anthropic stream silence", () => {
}) as never;
}) as unknown as Anthropic["messages"]["create"];
const client = { messages: { create } } as Anthropic;
const controller = new AbortController();
setTimeout(() => controller.abort(), 10);
const result = await streamAnthropic(model, context, {
client,
signal: controller.signal,
streamFirstEventTimeoutMs: 5000,
streamIdleTimeoutMs: 1,
}).result();
expect(attempt).toBe(1);
expect(result.stopReason).toBe("aborted");
expect(result.errorMessage).not.toBe("Anthropic stream stalled while waiting for the next event");
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toBe("Anthropic stream stalled while waiting for the next event");
expect(result.content).toEqual([
{
type: "toolCall",
@@ -272,3 +272,167 @@ describe("AuthStorage usage cache: jitter", () => {
}
});
});
describe("AuthStorage usage cache: terminal refresh failure", () => {
// Regression: a revoked refresh token used to fail the in-line OAuth refresh
// inside the usage probe, get silently swallowed, then trigger the upstream
// 401 → null → last-good fallback chain. The credential was therefore never
// removed from the candidate set and the /usage TUI kept rendering yesterday's
// report — including its now-elapsed `resetsAt`, which the renderer printed
// as e.g. `(-612090ms)`. The fix CAS-disables the row on a definitive refresh
// failure and clears the cache, so the credential drops out cleanly.
it("disables credential and suppresses last-good when OAuth refresh fails with invalid_grant", async () => {
// Row whose access token has just expired — within the 60s refresh skew so
// the usage probe is forced to refresh before issuing the upstream call.
const row = oauthRow(1, "a@example.com");
(row.credential as { expires: number }).expires = Date.now() - 1000;
const rows = [row];
// `makeStore` returns `false` from `tryDisableAuthCredentialIfMatches`,
// which would short-circuit our disable. Use a local store that actually
// performs the soft-delete so we can observe the AuthStorage-side effects.
const cache = new Map<string, CacheEntry>();
let disableCalls = 0;
const store: ObservableStore = {
cache,
close() {},
listAuthCredentials: () => rows.filter(r => !r.disabledCause),
updateAuthCredential() {},
deleteAuthCredential(id: number, cause: string) {
const target = rows.find(r => r.id === id);
if (target) target.disabledCause = cause;
},
tryDisableAuthCredentialIfMatches(id: number, _data: string, cause: string) {
disableCalls += 1;
const target = rows.find(r => r.id === id);
if (!target) return false;
target.disabledCause = cause;
return true;
},
replaceAuthCredentialsForProvider: () => rows,
upsertAuthCredentialForProvider: () => rows,
deleteAuthCredentialsForProvider() {},
getCache(key: string, options?: { includeExpired?: boolean }) {
const entry = cache.get(key);
if (!entry) return null;
if (!options?.includeExpired && entry.expiresAtSec * 1000 <= Date.now()) return null;
return entry.value;
},
setCache(key: string, value: string, expiresAtSec: number) {
cache.set(key, { value, expiresAtSec });
},
cleanExpiredCache() {},
};
// Pre-populate the cache with a "last good" report whose inner expiresAt
// is in the past (so `get()` misses) but the entry is still reachable via
// `getStale()`. Mirrors what the prior poll would have written.
const lastGood = makeReport("a@example.com");
const cacheKey = "usage_cache:report:anthropic:default:oauth|account:account-1|email:a@example.com";
cache.set(cacheKey, {
value: JSON.stringify({ value: lastGood, expiresAt: 1 }),
expiresAtSec: Math.floor((Date.now() + 24 * 60 * 60_000) / 1000),
});
const storage = new AuthStorage(store, {
usageProviderResolver: provider => (provider === "anthropic" ? claudeUsage.claudeUsageProvider : undefined),
refreshOAuthCredential: async () => {
throw new Error("OAuth refresh failed: 400 invalid_grant: refresh token revoked");
},
});
await storage.reload();
const fetchSpy = vi.spyOn(claudeUsage.claudeUsageProvider, "fetchUsage");
try {
const reports = anthropicReports(await storage.fetchUsageReports());
// No last-good fallback: the row was disabled before lastGood could leak.
expect(reports).toHaveLength(0);
// CAS disable was attempted exactly once on the failing row.
expect(disableCalls).toBe(1);
expect(rows[0].disabledCause).toContain("invalid_grant");
// Upstream probe is short-circuited — no point asking the provider
// with a credential we've just torn down.
expect(fetchSpy).not.toHaveBeenCalled();
// Cache entry was neutralized: a future `getStale` lookup (e.g. on
// re-login under the same account identity) returns null, not the
// stale report with its already-elapsed `resetsAt`.
const rawAfter = cache.get(cacheKey);
expect(rawAfter).toBeDefined();
const parsedAfter = JSON.parse(rawAfter!.value);
expect(parsedAfter.value).toBeNull();
// And a second poll surfaces nothing — the credential is gone from
// `listAuthCredentials`, so `#collectUsageRequests` doesn't even
// look it up.
const secondPoll = anthropicReports(await storage.fetchUsageReports());
expect(secondPoll).toHaveLength(0);
} finally {
storage.close();
vi.restoreAllMocks();
}
});
it("preserves last-good fallback for transient (non-definitive) refresh failures", async () => {
// Mirror image: a 502 from the token endpoint is transient — we keep the
// row, fall back to the prior good report, and try again next poll.
const row = oauthRow(2, "b@example.com");
(row.credential as { expires: number }).expires = Date.now() - 1000;
const rows = [row];
const cache = new Map<string, CacheEntry>();
const store: ObservableStore = {
cache,
close() {},
listAuthCredentials: () => rows.filter(r => !r.disabledCause),
updateAuthCredential() {},
deleteAuthCredential() {},
tryDisableAuthCredentialIfMatches() {
return true;
},
replaceAuthCredentialsForProvider: () => rows,
upsertAuthCredentialForProvider: () => rows,
deleteAuthCredentialsForProvider() {},
getCache(key: string, options?: { includeExpired?: boolean }) {
const entry = cache.get(key);
if (!entry) return null;
if (!options?.includeExpired && entry.expiresAtSec * 1000 <= Date.now()) return null;
return entry.value;
},
setCache(key: string, value: string, expiresAtSec: number) {
cache.set(key, { value, expiresAtSec });
},
cleanExpiredCache() {},
};
const lastGood = makeReport("b@example.com");
const cacheKey = "usage_cache:report:anthropic:default:oauth|account:account-2|email:b@example.com";
cache.set(cacheKey, {
value: JSON.stringify({ value: lastGood, expiresAt: 1 }),
expiresAtSec: Math.floor((Date.now() + 24 * 60 * 60_000) / 1000),
});
const storage = new AuthStorage(store, {
usageProviderResolver: provider => (provider === "anthropic" ? claudeUsage.claudeUsageProvider : undefined),
refreshOAuthCredential: async () => {
throw new Error("fetch failed: connect ECONNREFUSED 1.2.3.4:443");
},
});
await storage.reload();
// The provider probe runs with the stale credential and fails — we don't
// need a real upstream response, just a deterministic null so the lastGood
// path is the one being tested.
vi.spyOn(claudeUsage.claudeUsageProvider, "fetchUsage").mockResolvedValue(null);
try {
const reports = anthropicReports(await storage.fetchUsageReports());
expect(reports).toHaveLength(1);
expect(reports[0]?.metadata?.email).toBe("b@example.com");
expect(rows[0].disabledCause).toBeNull();
} finally {
storage.close();
vi.restoreAllMocks();
}
});
});
@@ -369,6 +369,27 @@ describe("DeepSeek reasoning_content tool-call replay", () => {
expect(rc).toBe("");
});
it("sets reasoning_content to empty string for OpenCode Zen big-pickle tool-call turns", () => {
const model = getBundledModel("opencode-zen", "big-pickle") as Model<"openai-completions">;
const compat = detectCompat(model);
expect(compat.requiresReasoningContentForToolCalls).toBe(true);
expect(compat.allowsSyntheticReasoningContentForToolCalls).toBe(false);
const msg = assistantToolCall(model, [
{
type: "toolCall",
id: "call_big_pickle",
name: "bash",
arguments: { command: "git status --short" },
} as ToolCall,
]);
const messages = convertMessages(model, { messages: [msg] }, compat);
const assistant = messages.find(m => m.role === "assistant");
expect(assistant).toBeDefined();
expect(Reflect.get(assistant as object, "reasoning_content")).toBe("");
expect((assistant as { content: unknown }).content).toBe("");
});
it("sets content to empty string (not null) when reasoning_content is present", () => {
const model = deepseekModel({
provider: "nvidia",
@@ -0,0 +1,131 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { resolveProviderModels } from "../src/model-manager";
import { googleVertexModelManagerOptions } from "../src/provider-models/google";
import { __resetVertexTokenCache } from "../src/providers/google-auth";
const OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
const METADATA_TOKEN_URL = "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token";
describe("google-vertex model discovery", () => {
let tempDir = "";
let dbPath = "";
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-ai-vertex-models-"));
dbPath = path.join(tempDir, "models.db");
});
afterEach(async () => {
__resetVertexTokenCache();
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true });
tempDir = "";
dbPath = "";
}
});
it("uses the Vertex OpenAI-compatible model list as the authoritative project catalog", async () => {
const urls: string[] = [];
const options = googleVertexModelManagerOptions({
project: "vertex-project",
location: "global",
fetch: async input => {
const url = input instanceof Request ? input.url : input.toString();
urls.push(url);
if (url === METADATA_TOKEN_URL || url === OAUTH_TOKEN_URL) {
return new Response(JSON.stringify({ access_token: "vertex-token", expires_in: 3600 }));
}
if (
url.startsWith(
"https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi/models",
)
) {
return new Response(
JSON.stringify({
data: [
{ id: "zai-org/glm-4.7-maas", displayName: "GLM-4.7" },
{
name: "projects/vertex-project/locations/global/publishers/anthropic/models/claude-sonnet-4-5",
displayName: "Claude Sonnet 4.5",
},
],
}),
);
}
return new Response("not found", { status: 404 });
},
});
const result = await resolveProviderModels({ ...options, cacheDbPath: dbPath }, "online");
expect(result.stale).toBe(false);
expect(result.models.map(model => model.id)).toEqual(["anthropic/claude-sonnet-4-5", "zai-org/glm-4.7-maas"]);
expect(result.models.every(model => model.provider === "google-vertex")).toBe(true);
expect(result.models.every(model => model.api === "openai-completions")).toBe(true);
expect(
result.models.every(
model =>
model.baseUrl ===
"https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi",
),
).toBe(true);
expect(result.models.some(model => model.id === "gemini-1.5-pro")).toBe(false);
expect(urls).toContain(
"https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi/models?pageSize=100",
);
});
it("keeps the API-key Vertex Gemini catalog when project and location are also configured", async () => {
const options = googleVertexModelManagerOptions({
apiKey: "vertex-api-key",
project: "vertex-project",
location: "global",
});
const result = await resolveProviderModels({ ...options, cacheDbPath: dbPath }, "offline");
expect(result.models.some(model => model.id === "gemini-2.5-pro")).toBe(true);
expect(result.models.every(model => model.provider === "google-vertex")).toBe(true);
expect(result.models.every(model => model.api === "google-vertex")).toBe(true);
});
it("omits the bundled Vertex Gemini static fallback when neither ADC project nor API key are configured", async () => {
const previousProject = Bun.env.GOOGLE_CLOUD_PROJECT;
const previousGcpProject = Bun.env.GCP_PROJECT;
const previousGcloudProject = Bun.env.GCLOUD_PROJECT;
const previousVertexLocation = Bun.env.GOOGLE_VERTEX_LOCATION;
const previousCloudLocation = Bun.env.GOOGLE_CLOUD_LOCATION;
const previousLocation = Bun.env.VERTEX_LOCATION;
const previousApiKey = Bun.env.GOOGLE_CLOUD_API_KEY;
delete Bun.env.GOOGLE_CLOUD_PROJECT;
delete Bun.env.GCP_PROJECT;
delete Bun.env.GCLOUD_PROJECT;
delete Bun.env.GOOGLE_VERTEX_LOCATION;
delete Bun.env.GOOGLE_CLOUD_LOCATION;
delete Bun.env.VERTEX_LOCATION;
delete Bun.env.GOOGLE_CLOUD_API_KEY;
try {
const options = googleVertexModelManagerOptions();
const result = await resolveProviderModels({ ...options, cacheDbPath: dbPath }, "offline");
expect(result.models).toEqual([]);
} finally {
if (previousProject === undefined) delete Bun.env.GOOGLE_CLOUD_PROJECT;
else Bun.env.GOOGLE_CLOUD_PROJECT = previousProject;
if (previousGcpProject === undefined) delete Bun.env.GCP_PROJECT;
else Bun.env.GCP_PROJECT = previousGcpProject;
if (previousGcloudProject === undefined) delete Bun.env.GCLOUD_PROJECT;
else Bun.env.GCLOUD_PROJECT = previousGcloudProject;
if (previousVertexLocation === undefined) delete Bun.env.GOOGLE_VERTEX_LOCATION;
else Bun.env.GOOGLE_VERTEX_LOCATION = previousVertexLocation;
if (previousCloudLocation === undefined) delete Bun.env.GOOGLE_CLOUD_LOCATION;
else Bun.env.GOOGLE_CLOUD_LOCATION = previousCloudLocation;
if (previousLocation === undefined) delete Bun.env.VERTEX_LOCATION;
else Bun.env.VERTEX_LOCATION = previousLocation;
if (previousApiKey === undefined) delete Bun.env.GOOGLE_CLOUD_API_KEY;
else Bun.env.GOOGLE_CLOUD_API_KEY = previousApiKey;
}
});
});
+170
View File
@@ -0,0 +1,170 @@
import { describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { hookFetch } from "@oh-my-pi/pi-utils";
import { streamBedrock } from "../src/providers/amazon-bedrock";
import { clearAwsCredentialCache } from "../src/providers/aws-credentials";
import type { Context, Model } from "../src/types";
const model: Model<"bedrock-converse-stream"> = {
id: "zai.glm-5",
name: "GLM-5",
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
baseUrl: "https://bedrock-runtime.us-west-2.amazonaws.com",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 131_072,
maxTokens: 16_384,
};
const context: Context = {
systemPrompt: [],
messages: [{ role: "user", content: "say hi", timestamp: Date.now() }],
};
const awsEnvKeys = [
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"AWS_PROFILE",
"AWS_REGION",
"AWS_DEFAULT_REGION",
"AWS_CONFIG_FILE",
"AWS_SHARED_CREDENTIALS_FILE",
"AWS_EC2_METADATA_DISABLED",
"AWS_BEARER_TOKEN_BEDROCK",
"AWS_BEDROCK_SKIP_AUTH",
] as const;
function snapshotAwsEnv(): () => void {
const previous = new Map<string, string | undefined>();
for (const key of awsEnvKeys) previous.set(key, process.env[key]);
return () => {
for (const key of awsEnvKeys) {
const value = previous.get(key);
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
clearAwsCredentialCache();
};
}
describe("issue #1399: Bedrock bearer token precedence", () => {
it("uses AWS_BEARER_TOKEN_BEDROCK without invoking profile credential_process", async () => {
const restoreAwsEnv = snapshotAwsEnv();
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-bedrock-auth-"));
try {
const configPath = path.join(tempDir, "config");
await Bun.write(
configPath,
[
"[default]",
"region = us-west-2",
"credential_process = /bin/sh -c 'echo should-not-run >&2; exit 17'",
"",
].join("\n"),
);
delete process.env.AWS_ACCESS_KEY_ID;
delete process.env.AWS_SECRET_ACCESS_KEY;
delete process.env.AWS_SESSION_TOKEN;
delete process.env.AWS_PROFILE;
delete process.env.AWS_DEFAULT_REGION;
delete process.env.AWS_BEDROCK_SKIP_AUTH;
process.env.AWS_REGION = "us-west-2";
process.env.AWS_CONFIG_FILE = configPath;
process.env.AWS_SHARED_CREDENTIALS_FILE = path.join(tempDir, "credentials");
process.env.AWS_EC2_METADATA_DISABLED = "true";
process.env.AWS_BEARER_TOKEN_BEDROCK = "bedrock-api-key";
clearAwsCredentialCache();
let requestHeaders: Headers | undefined;
using _hook = hookFetch((_input, init) => {
requestHeaders = new Headers(init?.headers);
return new Response('{"message":"unauthorized"}', { status: 401 });
});
const result = await streamBedrock(model, context, {}).result();
expect(requestHeaders?.get("authorization")).toBe("Bearer bedrock-api-key");
expect(requestHeaders?.has("x-amz-date")).toBe(false);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("Bedrock HTTP 401");
expect(result.errorMessage).not.toContain("credential_process");
} finally {
restoreAwsEnv();
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("ignores agent sentinel apiKey when AWS_BEARER_TOKEN_BEDROCK is available", async () => {
const restoreAwsEnv = snapshotAwsEnv();
try {
delete process.env.AWS_ACCESS_KEY_ID;
delete process.env.AWS_SECRET_ACCESS_KEY;
delete process.env.AWS_SESSION_TOKEN;
delete process.env.AWS_PROFILE;
delete process.env.AWS_CONFIG_FILE;
delete process.env.AWS_DEFAULT_REGION;
delete process.env.AWS_BEDROCK_SKIP_AUTH;
process.env.AWS_REGION = "us-west-2";
process.env.AWS_SHARED_CREDENTIALS_FILE = path.join(os.tmpdir(), "missing-aws-credentials");
process.env.AWS_EC2_METADATA_DISABLED = "true";
process.env.AWS_BEARER_TOKEN_BEDROCK = "bedrock-api-key";
clearAwsCredentialCache();
let requestHeaders: Headers | undefined;
using _hook = hookFetch((_input, init) => {
requestHeaders = new Headers(init?.headers);
return new Response('{"message":"unauthorized"}', { status: 401 });
});
const result = await streamBedrock(model, context, { apiKey: "<authenticated>" }).result();
expect(requestHeaders?.get("authorization")).toBe("Bearer bedrock-api-key");
expect(requestHeaders?.get("authorization")).not.toBe("Bearer <authenticated>");
expect(requestHeaders?.has("x-amz-date")).toBe(false);
expect(result.stopReason).toBe("error");
} finally {
restoreAwsEnv();
}
});
it("ignores agent sentinel apiKey when signing with AWS credentials", async () => {
const restoreAwsEnv = snapshotAwsEnv();
try {
process.env.AWS_ACCESS_KEY_ID = "AKIDEXAMPLE";
process.env.AWS_SECRET_ACCESS_KEY = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY";
delete process.env.AWS_SESSION_TOKEN;
delete process.env.AWS_PROFILE;
delete process.env.AWS_CONFIG_FILE;
delete process.env.AWS_SHARED_CREDENTIALS_FILE;
delete process.env.AWS_DEFAULT_REGION;
delete process.env.AWS_BEARER_TOKEN_BEDROCK;
delete process.env.AWS_BEDROCK_SKIP_AUTH;
process.env.AWS_REGION = "us-west-2";
process.env.AWS_EC2_METADATA_DISABLED = "true";
clearAwsCredentialCache();
let requestHeaders: Headers | undefined;
using _hook = hookFetch((_input, init) => {
requestHeaders = new Headers(init?.headers);
return new Response('{"message":"unauthorized"}', { status: 401 });
});
const result = await streamBedrock(model, context, { apiKey: "<authenticated>" }).result();
const authorization = requestHeaders?.get("authorization");
expect(authorization).toStartWith("AWS4-HMAC-SHA256 ");
expect(authorization).toContain("Credential=AKIDEXAMPLE/");
expect(authorization).not.toBe("Bearer <authenticated>");
expect(requestHeaders?.has("x-amz-date")).toBe(true);
expect(result.stopReason).toBe("error");
} finally {
restoreAwsEnv();
}
});
});
+2 -2
View File
@@ -5,7 +5,7 @@ import {
isMockModel,
MOCK_API,
type MockHandler,
type MockModelHandle,
type MockModel,
registerMockApi,
streamMock,
} from "../src/providers/mock";
@@ -298,7 +298,7 @@ describe("mock provider — async-iterable response sources", () => {
test("generator can react to call state via the shared handle", async () => {
// Each yield reads `mock.calls` to react to what just happened.
let handle!: MockModelHandle;
let handle!: MockModel;
const gen = async function* () {
yield { content: ["turn 0"] };
// At this point mock.calls.length === 1.
+122 -8
View File
@@ -286,7 +286,6 @@ describe("openai-codex streaming", () => {
const result = await streamOpenAICodexResponses(model, context, {
apiKey: token,
signal: controller.signal,
streamIdleTimeoutMs: 5,
}).result();
expect(result.stopReason).toBe("aborted");
@@ -359,6 +358,63 @@ describe("openai-codex streaming", () => {
expect(result.stopReason).toBe("stop");
});
it("forwards websocket frames through onSseEvent for the raw-SSE debug viewer", async () => {
const tempDir = TempDir.createSync("@pi-codex-stream-");
setAgentDir(tempDir.path());
const token = createCodexTestToken();
class ObservedWebSocket extends MockWebSocket {
constructor(url: string, options?: { headers?: WsHeaders }) {
super(url, options);
this.scheduleOpen();
}
send(): void {
this.emitCodexResponse({ messageId: "msg_obs", responseId: "resp_obs", text: "Observed" });
}
}
global.WebSocket = ObservedWebSocket as unknown as typeof WebSocket;
const observed: Array<{ event: string | null; data: string; raw: string[] }> = [];
const result = await streamOpenAICodexResponses(
createCodexTestModel("https://chatgpt.com/backend-api"),
createCodexTestContext(),
{
apiKey: token,
sessionId: "ws-observer-session",
providerSessionState: new Map<string, ProviderSessionState>(),
onSseEvent: event => {
observed.push({ event: event.event, data: event.data, raw: [...event.raw] });
},
},
).result();
expect(result.stopReason).toBe("stop");
// First record is the outbound request frame (the JSON we sent).
const [outbound, ...inbound] = observed;
expect(outbound).toBeDefined();
expect(outbound.raw[0]).toMatch(/^: ws → /);
expect(outbound.data.length).toBeGreaterThan(0);
expect(() => JSON.parse(outbound.data)).not.toThrow();
// Inbound frames mirror the Codex response sequence emitted by `emitCodexResponse`.
expect(inbound.map(e => e.event)).toEqual([
"response.output_item.added",
"response.content_part.added",
"response.output_text.delta",
"response.output_item.done",
"response.done",
]);
for (const event of inbound) {
expect(event.raw[0]).toBe(`: ws ← ${event.event}`);
// Synthesized SSE wire shape: `event:` line then `data:` line.
expect(event.raw[1]).toBe(`event: ${event.event}`);
expect(event.raw[2]).toBe(`data: ${event.data}`);
expect(JSON.parse(event.data)).toMatchObject({ type: event.event });
}
});
it("omits request-body headers and replaces stale beta headers for websocket handshakes", async () => {
const tempDir = TempDir.createSync("@pi-codex-stream-");
setAgentDir(tempDir.path());
@@ -907,6 +963,48 @@ describe("openai-codex streaming", () => {
const streamResult = streamOpenAICodexResponses(model, context, { apiKey: token, sessionId });
await streamResult.result();
});
it("keeps prompt_cache_key separate from Codex conversation headers", async () => {
const tempDir = TempDir.createSync("@pi-codex-stream-");
setAgentDir(tempDir.path());
const token = createCodexTestToken();
const model = createCodexTestModel("https://chatgpt.com/backend-api");
const sessionId = "side-channel-session";
const promptCacheKey = "main-session-cache";
let capturedHeaders: Headers | undefined;
let capturedBody: Record<string, unknown> | undefined;
global.fetch = vi.fn(async (input: string | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
if (url === "https://api.github.com/repos/openai/codex/releases/latest") {
return new Response(JSON.stringify({ tag_name: "rust-v0.0.0" }), { status: 200 });
}
if (url.startsWith("https://raw.githubusercontent.com/openai/codex/")) {
return new Response("PROMPT", { status: 200, headers: { etag: '"etag"' } });
}
if (url === "https://chatgpt.com/backend-api/codex/responses") {
capturedHeaders = init?.headers instanceof Headers ? init.headers : new Headers(init?.headers);
capturedBody =
typeof init?.body === "string" ? (JSON.parse(init.body) as Record<string, unknown>) : undefined;
return new Response(createCompletedCodexSse("Hello"), {
status: 200,
headers: { "content-type": "text/event-stream" },
});
}
return new Response("not found", { status: 404 });
}) as unknown as typeof fetch;
await streamOpenAICodexResponses(model, createCodexTestContext(), {
apiKey: token,
sessionId,
promptCacheKey,
}).result();
expect(capturedHeaders?.get("conversation_id")).toBe(sessionId);
expect(capturedHeaders?.get("session_id")).toBe(sessionId);
expect(capturedHeaders?.get("x-client-request-id")).toBe(sessionId);
expect(capturedBody?.prompt_cache_key).toBe(promptCacheKey);
});
it("rejects gpt-5.3-codex minimal reasoning effort instead of clamping", async () => {
const tempDir = TempDir.createSync("@pi-codex-stream-");
@@ -1788,13 +1886,12 @@ describe("openai-codex streaming", () => {
expect(fetchMock).not.toHaveBeenCalled();
});
it("waits for caller abort when websocket status events do not make semantic progress", async () => {
it("surfaces a websocket idle-timeout error when status events never make semantic progress", async () => {
const tempDir = TempDir.createSync("@pi-codex-stream-");
setAgentDir(tempDir.path());
const token = createCodexTestToken();
const sse = createCompletedCodexSse("Hello fallback");
const fetchMock = vi.fn(async () => {
return new Response(sse, { headers: { "content-type": "text/event-stream" } });
throw new Error("SSE fallback should not run once the websocket stream becomes replay-unsafe");
});
global.fetch = fetchMock as unknown as typeof fetch;
@@ -1818,6 +1915,16 @@ describe("openai-codex streaming", () => {
arguments: "",
},
});
this.sendJson({
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_ws_stalled",
call_id: "call_ws_stalled",
name: "todo_write",
arguments: "{}",
},
});
interval = setInterval(() => {
this.sendJson({
type: "response.in_progress",
@@ -1835,18 +1942,25 @@ describe("openai-codex streaming", () => {
const model = createCodexTestModel("https://chatgpt.com/backend-api");
const providerSessionState = new Map<string, ProviderSessionState>();
const controller = new AbortController();
setTimeout(() => controller.abort(), 30);
const result = await streamOpenAICodexResponses(model, createCodexTestContext(), {
apiKey: token,
sessionId: "ws-no-progress-session",
providerSessionState,
signal: controller.signal,
streamIdleTimeoutMs: 5,
}).result();
expect(sendCount).toBe(1);
expect(result.stopReason).toBe("aborted");
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("idle timeout waiting for websocket");
expect(result.content).toEqual([
expect.objectContaining({
type: "toolCall",
id: "call_ws_stalled|fc_ws_stalled",
name: "todo_write",
arguments: {},
partialJson: "",
}),
]);
expect(fetchMock).not.toHaveBeenCalled();
});
@@ -183,6 +183,57 @@ describe("openai-completions compatibility", () => {
]);
});
it("defaults supportsDeveloperRole to off for non-OpenAI/Azure hosts", () => {
// Regression: Moonshot's Kimi chat template rejects the `developer` role
// with `400 Invalid request: tokenization failed` because `developer` is
// an OpenAI extension and most other hosts don't carry it through their
// tokenizer. The default for any non-OpenAI/Azure host MUST be `system`,
// so reasoning models on those hosts cannot accidentally emit `developer`.
const cases: Array<{ provider: string; baseUrl: string; expected: boolean }> = [
{ provider: "openai", baseUrl: "https://api.openai.com/v1", expected: true },
{ provider: "azure", baseUrl: "https://example.openai.azure.com/openai", expected: true },
{ provider: "moonshot", baseUrl: "https://api.moonshot.ai/v1", expected: false },
{ provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", expected: false },
{ provider: "groq", baseUrl: "https://api.groq.com/openai/v1", expected: false },
{ provider: "github-copilot", baseUrl: "https://api.githubcopilot.com", expected: false },
];
for (const { provider, baseUrl, expected } of cases) {
const model: Model<"openai-completions"> = {
...getBundledModel("openai", "gpt-4o-mini"),
api: "openai-completions",
provider: provider as Model["provider"],
baseUrl,
reasoning: true,
};
expect(detectCompat(model).supportsDeveloperRole).toBe(expected);
}
});
it("emits system role for reasoning models on Moonshot (kimi tokenization rejects developer)", () => {
const model: Model<"openai-completions"> = {
...getBundledModel("openai", "gpt-4o-mini"),
api: "openai-completions",
provider: "moonshot",
baseUrl: "https://api.moonshot.ai/v1",
id: "kimi-k2.5",
reasoning: true,
};
const messages = convertMessages(
model,
{
systemPrompt: ["you are a helpful assistant"],
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
},
detectCompat(model),
);
expect(messages.slice(0, 2)).toEqual([
{ role: "system", content: "you are a helpful assistant" },
{ role: "user", content: "hi" },
]);
});
it("coalesces ordered system prompts when the host disables multi-system support", () => {
const model: Model<"openai-completions"> = {
...getBundledModel("openai", "gpt-4o-mini"),
@@ -0,0 +1,262 @@
import { afterEach, describe, expect, it } from "bun:test";
import { getBundledModel } from "../src/models";
import { isOpenAICompletionsProgressChunk, streamOpenAICompletions } from "../src/providers/openai-completions";
import type { Context, Model } from "../src/types";
const originalFetch = global.fetch;
const openAICompletionsModel = {
...(getBundledModel("openai", "gpt-4o-mini") as Model<"openai-completions">),
api: "openai-completions",
} satisfies Model<"openai-completions">;
function baseContext(): Context {
return {
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
};
}
function getRequestSignal(input: string | URL | Request, init: RequestInit | undefined): AbortSignal | undefined {
if (init?.signal) {
return init.signal;
}
if (input instanceof Request) {
return input.signal;
}
return undefined;
}
function createKeepaliveOnlyCompletionsResponse(modelId: string, signal: AbortSignal | undefined): Response {
const encoder = new TextEncoder();
let interval: NodeJS.Timeout | undefined;
let abortListener: (() => void) | undefined;
const encode = (event: unknown): Uint8Array => encoder.encode(`data: ${JSON.stringify(event)}\n\n`);
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(
encode({
id: "chatcmpl-stalled",
object: "chat.completion.chunk",
created: 0,
model: modelId,
choices: [{ index: 0, delta: { content: "Hello" } }],
}),
);
interval = setInterval(() => {
controller.enqueue(
encode({
id: "chatcmpl-stalled",
object: "chat.completion.chunk",
created: 0,
model: modelId,
choices: [{ index: 0, delta: { role: "assistant" } }],
}),
);
}, 2);
abortListener = () => {
if (interval) clearInterval(interval);
if (abortListener) signal?.removeEventListener("abort", abortListener);
const reason = signal?.reason;
controller.error(reason instanceof Error ? reason : new Error("request aborted"));
};
if (signal?.aborted) {
queueMicrotask(() => abortListener?.());
} else {
signal?.addEventListener("abort", abortListener, { once: true });
}
},
cancel() {
if (interval) clearInterval(interval);
if (abortListener) signal?.removeEventListener("abort", abortListener);
},
});
return new Response(stream, {
status: 200,
headers: { "content-type": "text/event-stream" },
});
}
afterEach(() => {
global.fetch = originalFetch;
});
/**
* Contract: `isOpenAICompletionsProgressChunk` decides whether a streamed chunk
* resets the idle-watchdog deadline in `iterateWithIdleTimeout`. A false
* positive (counting a no-op chunk as progress) silently disables the
* watchdog and is the root cause of the z.ai/GLM-via-OpenRouter hang where
* a subagent stalled for hours with no error surfaced. A false negative is
* cheap (delays the watchdog by at most the first-event window).
*/
describe("isOpenAICompletionsProgressChunk", () => {
describe("non-progress chunks (MUST NOT reset the watchdog)", () => {
it("rejects null/non-object", () => {
expect(isOpenAICompletionsProgressChunk(null)).toBe(false);
expect(isOpenAICompletionsProgressChunk(undefined)).toBe(false);
expect(isOpenAICompletionsProgressChunk("hi")).toBe(false);
expect(isOpenAICompletionsProgressChunk(42)).toBe(false);
});
it("rejects empty {} keepalives", () => {
expect(isOpenAICompletionsProgressChunk({})).toBe(false);
});
it("rejects {choices: []} keepalives", () => {
expect(isOpenAICompletionsProgressChunk({ choices: [] })).toBe(false);
});
it("rejects role-only preambles", () => {
expect(
isOpenAICompletionsProgressChunk({
choices: [{ delta: { role: "assistant" } }],
}),
).toBe(false);
});
it("rejects empty-string content deltas", () => {
expect(
isOpenAICompletionsProgressChunk({
choices: [{ delta: { content: "" } }],
}),
).toBe(false);
});
it("rejects empty-array content deltas", () => {
expect(
isOpenAICompletionsProgressChunk({
choices: [{ delta: { content: [] } }],
}),
).toBe(false);
});
it("rejects empty tool_calls arrays", () => {
expect(
isOpenAICompletionsProgressChunk({
choices: [{ delta: { tool_calls: [] } }],
}),
).toBe(false);
});
it("rejects empty reasoning fields", () => {
expect(
isOpenAICompletionsProgressChunk({
choices: [{ delta: { reasoning: "" } }],
}),
).toBe(false);
expect(
isOpenAICompletionsProgressChunk({
choices: [{ delta: { reasoning_content: "" } }],
}),
).toBe(false);
expect(
isOpenAICompletionsProgressChunk({
choices: [{ delta: { reasoning_text: "" } }],
}),
).toBe(false);
});
});
describe("progress chunks (MUST reset the watchdog)", () => {
it("accepts a top-level usage chunk (terminal token report)", () => {
expect(
isOpenAICompletionsProgressChunk({
usage: { prompt_tokens: 12, completion_tokens: 4 },
}),
).toBe(true);
});
it("accepts choice-level usage", () => {
expect(
isOpenAICompletionsProgressChunk({
choices: [{ usage: { prompt_tokens: 12 } }],
}),
).toBe(true);
});
it("accepts finish_reason", () => {
expect(
isOpenAICompletionsProgressChunk({
choices: [{ finish_reason: "stop" }],
}),
).toBe(true);
expect(
isOpenAICompletionsProgressChunk({
choices: [{ finish_reason: "tool_calls" }],
}),
).toBe(true);
});
it("accepts text content deltas", () => {
expect(
isOpenAICompletionsProgressChunk({
choices: [{ delta: { content: "Hello" } }],
}),
).toBe(true);
});
it("accepts array-shape content parts (Mistral-style)", () => {
expect(
isOpenAICompletionsProgressChunk({
choices: [{ delta: { content: [{ type: "text", text: "Hi" }] } }],
}),
).toBe(true);
});
it("accepts tool call deltas", () => {
expect(
isOpenAICompletionsProgressChunk({
choices: [
{
delta: {
tool_calls: [{ id: "call_1", function: { name: "search", arguments: "{}" } }],
},
},
],
}),
).toBe(true);
});
it("accepts reasoning deltas in all three field names", () => {
expect(
isOpenAICompletionsProgressChunk({
choices: [{ delta: { reasoning: "thinking..." } }],
}),
).toBe(true);
expect(
isOpenAICompletionsProgressChunk({
choices: [{ delta: { reasoning_content: "thinking..." } }],
}),
).toBe(true);
expect(
isOpenAICompletionsProgressChunk({
choices: [{ delta: { reasoning_text: "thinking..." } }],
}),
).toBe(true);
});
it("accepts refusal deltas", () => {
expect(
isOpenAICompletionsProgressChunk({
choices: [{ delta: { refusal: "I can't help with that." } }],
}),
).toBe(true);
});
});
});
describe("provider integration", () => {
it("times out a completions stream whose keepalives never make progress", async () => {
global.fetch = ((input: string | URL | Request, init?: RequestInit) =>
Promise.resolve(
createKeepaliveOnlyCompletionsResponse(openAICompletionsModel.id, getRequestSignal(input, init)),
)) as typeof fetch;
const result = await streamOpenAICompletions(openAICompletionsModel, baseContext(), {
apiKey: "test-key",
streamFirstEventTimeoutMs: 1_000,
streamIdleTimeoutMs: 20,
}).result();
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toBe("OpenAI completions stream stalled while waiting for the next event");
expect(result.content).toEqual([{ type: "text", text: "Hello" }]);
});
});
@@ -3,6 +3,7 @@ import { getBundledModel } from "../src/models";
import { streamAzureOpenAIResponses } from "../src/providers/azure-openai-responses";
import { streamOpenAICompletions } from "../src/providers/openai-completions";
import { streamOpenAIResponses } from "../src/providers/openai-responses";
import { streamSimple } from "../src/stream";
import type { Context, Model, TextContent } from "../src/types";
import { waitForDelayOrAbort } from "./helpers";
@@ -42,6 +43,15 @@ function getRequestSignal(input: string | URL | Request, init: RequestInit | und
return undefined;
}
function getRequestHeader(input: string | URL | Request, init: RequestInit | undefined, name: string): string | null {
if (init?.headers) {
return new Headers(init.headers).get(name);
}
if (input instanceof Request) {
return input.headers.get(name);
}
return null;
}
function createHangingSseResponse(signal: AbortSignal | undefined): Response {
let abortListener: (() => void) | undefined;
const stream = new ReadableStream<Uint8Array>({
@@ -143,28 +153,19 @@ function createNoProgressOpenAIResponsesStream(signal: AbortSignal | undefined):
});
}
function createDelayedFetch(delayMs: number, responseFactory: () => Response): typeof fetch {
function createDelayedFetch(
delayMs: number,
responseFactory: () => Response,
onRequest?: (input: string | URL | Request, init: RequestInit | undefined) => void,
): typeof fetch {
async function mockFetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {
onRequest?.(input, init);
await waitForDelayOrAbort(delayMs, getRequestSignal(input, init));
return responseFactory();
}
return Object.assign(mockFetch, { preconnect: originalFetch.preconnect });
}
function createAbortObservingDelayedFetch(delayMs: number, onAbort: () => void): typeof fetch {
async function mockFetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {
const signal = getRequestSignal(input, init);
try {
await waitForDelayOrAbort(delayMs, signal);
} catch (error) {
if (signal?.aborted) onAbort();
throw error;
}
return createOpenAIResponsesSuccessResponse();
}
return Object.assign(mockFetch, { preconnect: originalFetch.preconnect });
}
function createOpenAIResponsesSuccessResponse(): Response {
return createSseResponse([
@@ -227,22 +228,33 @@ function createOpenAICompletionsSuccessResponse(modelId: string): Response {
]);
}
async function expectProviderSilenceWaitsForCallerAbort(
run: (
signal: AbortSignal,
streamFirstEventTimeoutMs: number,
) => Promise<{ stopReason: string; errorMessage?: string }>,
unexpectedMessage: string,
async function expectFirstEventTimeout(
run: (streamFirstEventTimeoutMs: number) => Promise<{ stopReason: string; errorMessage?: string }>,
expectedMessage: string,
): Promise<void> {
global.fetch = createHangingFetch();
const controller = new AbortController();
setTimeout(() => controller.abort(), 30);
const result = await run(controller.signal, 5);
const result = await run(20);
expect(result.stopReason).toBe("aborted");
expect(result.errorMessage).not.toBe(unexpectedMessage);
expect((result.errorMessage ?? "").toLowerCase()).toContain("abort");
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toBe(expectedMessage);
}
async function expectRequestSetupTimeout(
run: (streamFirstEventTimeoutMs: number) => Promise<{ stopReason: string; errorMessage?: string }>,
expectedMessage: string,
responseFactory: () => Response,
): Promise<void> {
const timeoutHeaders: string[] = [];
global.fetch = createDelayedFetch(30, responseFactory, (input, init) => {
timeoutHeaders.push(getRequestHeader(input, init, "X-Stainless-Timeout") ?? "");
});
const result = await run(20);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toBe(expectedMessage);
expect(timeoutHeaders).toContain("0");
}
async function expectCallerAbort(
@@ -270,12 +282,12 @@ function getFirstTextContent(result: { content: unknown[] }): TextContent | unde
}
async function expectDelayedRequestSetupSucceeds(
run: () => Promise<{ stopReason: string; content: unknown[] }>,
run: (streamFirstEventTimeoutMs: number) => Promise<{ stopReason: string; content: unknown[] }>,
responseFactory: () => Response,
): Promise<void> {
global.fetch = createDelayedFetch(30, responseFactory);
const result = await run();
const result = await run(50);
expect(result.stopReason).toBe("stop");
expect(getFirstTextContent(result)).toMatchObject({ type: "text", text: "Hello delayed" });
@@ -285,34 +297,41 @@ afterEach(() => {
global.fetch = originalFetch;
});
describe("OpenAI-family provider stream silence", () => {
it("waits for caller abort when OpenAI responses emits no first event", async () => {
await expectProviderSilenceWaitsForCallerAbort(
(signal, streamFirstEventTimeoutMs) =>
describe("OpenAI-family first-event timeouts", () => {
it("surfaces the OpenAI responses first-event timeout message instead of a generic abort", async () => {
await expectFirstEventTimeout(
streamFirstEventTimeoutMs =>
streamOpenAIResponses(openAIResponsesModel, baseContext(), {
apiKey: "test-key",
signal,
streamFirstEventTimeoutMs,
}).result(),
"OpenAI responses stream timed out while waiting for the first event",
);
});
it("times out OpenAI responses before the stream opens and forwards the budget to the SDK request", async () => {
await expectRequestSetupTimeout(
streamFirstEventTimeoutMs =>
streamOpenAIResponses(openAIResponsesModel, baseContext(), {
apiKey: "test-key",
streamFirstEventTimeoutMs,
}).result(),
"OpenAI responses stream timed out while waiting for the first event",
createOpenAIResponsesSuccessResponse,
);
});
it("waits for caller abort when OpenAI responses streams only no-progress status events", async () => {
it("times out OpenAI responses streams that only emit no-progress status events", async () => {
global.fetch = ((input: string | URL | Request, init?: RequestInit) =>
Promise.resolve(createNoProgressOpenAIResponsesStream(getRequestSignal(input, init)))) as typeof fetch;
const controller = new AbortController();
setTimeout(() => controller.abort(), 30);
const result = await streamOpenAIResponses(openAIResponsesModel, baseContext(), {
apiKey: "test-key",
signal: controller.signal,
streamFirstEventTimeoutMs: 1_000,
streamIdleTimeoutMs: 5,
streamIdleTimeoutMs: 20,
}).result();
expect(result.stopReason).toBe("aborted");
expect(result.errorMessage).not.toBe("OpenAI responses stream stalled while waiting for the next event");
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toBe("OpenAI responses stream stalled while waiting for the next event");
expect(result.content as unknown[]).toEqual([
{
type: "toolCall",
@@ -324,31 +343,98 @@ describe("OpenAI-family provider stream silence", () => {
]);
});
it("waits for caller abort when OpenAI completions emits no first event", async () => {
await expectProviderSilenceWaitsForCallerAbort(
(signal, streamFirstEventTimeoutMs) =>
it("forwards streamSimple per-call timeout options to OpenAI-family providers", async () => {
global.fetch = createHangingFetch();
const controller = new AbortController();
const abortTimer = setTimeout(() => controller.abort(new Error("fallback abort")), 200);
abortTimer.unref();
try {
const result = await streamSimple(openAIResponsesModel, baseContext(), {
apiKey: "test-key",
signal: controller.signal,
streamFirstEventTimeoutMs: 20,
streamIdleTimeoutMs: 20,
}).result();
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toBe("OpenAI responses stream timed out while waiting for the first event");
} finally {
clearTimeout(abortTimer);
}
});
it("surfaces the OpenAI completions first-event timeout message", async () => {
await expectFirstEventTimeout(
streamFirstEventTimeoutMs =>
streamOpenAICompletions(openAICompletionsModel, baseContext(), {
apiKey: "test-key",
signal,
streamFirstEventTimeoutMs,
}).result(),
"OpenAI completions stream timed out while waiting for the first event",
);
});
it("times out OpenAI completions before the stream opens and forwards the budget to the SDK request", async () => {
await expectRequestSetupTimeout(
streamFirstEventTimeoutMs =>
streamOpenAICompletions(openAICompletionsModel, baseContext(), {
apiKey: "test-key",
streamFirstEventTimeoutMs,
}).result(),
"OpenAI completions stream timed out while waiting for the first event",
() => createOpenAICompletionsSuccessResponse(openAICompletionsModel.id),
);
});
it("waits for caller abort when Azure OpenAI responses emits no first event", async () => {
await expectProviderSilenceWaitsForCallerAbort(
(signal, streamFirstEventTimeoutMs) =>
it("surfaces the Azure OpenAI responses first-event timeout message", async () => {
await expectFirstEventTimeout(
streamFirstEventTimeoutMs =>
streamAzureOpenAIResponses(azureOpenAIResponsesModel, baseContext(), {
apiKey: "test-key",
azureBaseUrl: azureOpenAIResponsesModel.baseUrl,
azureApiVersion: "v1",
signal,
streamFirstEventTimeoutMs,
}).result(),
"Azure OpenAI responses stream timed out while waiting for the first event",
);
});
it("times out Azure OpenAI responses before the stream opens and forwards the budget to the SDK request", async () => {
await expectRequestSetupTimeout(
streamFirstEventTimeoutMs =>
streamAzureOpenAIResponses(azureOpenAIResponsesModel, baseContext(), {
apiKey: "test-key",
azureBaseUrl: azureOpenAIResponsesModel.baseUrl,
azureApiVersion: "v1",
streamFirstEventTimeoutMs,
}).result(),
"Azure OpenAI responses stream timed out while waiting for the first event",
createOpenAIResponsesSuccessResponse,
);
});
it("times out Azure responses streams that only emit no-progress status events", async () => {
global.fetch = ((input: string | URL | Request, init?: RequestInit) =>
Promise.resolve(createNoProgressOpenAIResponsesStream(getRequestSignal(input, init)))) as typeof fetch;
const controller = new AbortController();
const abortTimer = setTimeout(() => controller.abort(new Error("fallback abort")), 200);
abortTimer.unref();
try {
const result = await streamAzureOpenAIResponses(azureOpenAIResponsesModel, baseContext(), {
apiKey: "test-key",
azureBaseUrl: azureOpenAIResponsesModel.baseUrl,
azureApiVersion: "v1",
signal: controller.signal,
streamFirstEventTimeoutMs: 1_000,
streamIdleTimeoutMs: 20,
}).result();
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toBe("Azure OpenAI responses stream stalled while waiting for the next event");
} finally {
clearTimeout(abortTimer);
}
});
it("keeps caller aborts as aborted for OpenAI responses", async () => {
await expectCallerAbort(
@@ -388,69 +474,38 @@ describe("OpenAI-family provider stream silence", () => {
);
});
it("does not apply a default first-event timeout before OpenAI responses stream setup finishes", async () => {
it("does not arm the first-event watchdog before OpenAI responses stream setup finishes", async () => {
await expectDelayedRequestSetupSucceeds(
() =>
streamFirstEventTimeoutMs =>
streamOpenAIResponses(openAIResponsesModel, baseContext(), {
apiKey: "test-key",
streamFirstEventTimeoutMs,
}).result(),
createOpenAIResponsesSuccessResponse,
);
});
it("maps explicit OpenAI responses first-event timeout to the SDK request timeout", async () => {
let abortObserved = false;
global.fetch = createAbortObservingDelayedFetch(1_000, () => {
abortObserved = true;
});
const result = await streamOpenAIResponses(openAIResponsesModel, baseContext(), {
apiKey: "test-key",
streamFirstEventTimeoutMs: 10,
}).result();
expect(abortObserved).toBe(true);
expect(result.stopReason).toBe("error");
expect(getFirstTextContent(result)).toBeUndefined();
});
it("does not apply a default first-event timeout before OpenAI completions stream setup finishes", async () => {
it("does not arm the first-event watchdog before OpenAI completions stream setup finishes", async () => {
await expectDelayedRequestSetupSucceeds(
() =>
streamFirstEventTimeoutMs =>
streamOpenAICompletions(openAICompletionsModel, baseContext(), {
apiKey: "test-key",
streamFirstEventTimeoutMs,
}).result(),
() => createOpenAICompletionsSuccessResponse(openAICompletionsModel.id),
);
});
it("does not apply a default first-event timeout before Azure OpenAI responses setup finishes", async () => {
it("does not arm the first-event watchdog before Azure OpenAI responses setup finishes", async () => {
await expectDelayedRequestSetupSucceeds(
() =>
streamFirstEventTimeoutMs =>
streamAzureOpenAIResponses(azureOpenAIResponsesModel, baseContext(), {
apiKey: "test-key",
azureBaseUrl: azureOpenAIResponsesModel.baseUrl,
azureApiVersion: "v1",
streamFirstEventTimeoutMs,
}).result(),
createOpenAIResponsesSuccessResponse,
);
});
it("maps explicit Azure OpenAI responses first-event timeout to the SDK request timeout", async () => {
let abortObserved = false;
global.fetch = createAbortObservingDelayedFetch(1_000, () => {
abortObserved = true;
});
const result = await streamAzureOpenAIResponses(azureOpenAIResponsesModel, baseContext(), {
apiKey: "test-key",
azureBaseUrl: azureOpenAIResponsesModel.baseUrl,
azureApiVersion: "v1",
streamFirstEventTimeoutMs: 10,
}).result();
expect(abortObserved).toBe(true);
expect(result.stopReason).toBe("error");
expect(getFirstTextContent(result)).toBeUndefined();
});
});
@@ -89,6 +89,16 @@ describe("openai-responses cache affinity", () => {
expect(captured.clientRequestId).toBe("session-123");
expect(captured.body?.prompt_cache_key).toBe("session-123");
});
it("keeps prompt cache key separate from OpenAI routing headers when both are provided", async () => {
const captured = await captureOpenAIResponseHeaders({
sessionId: "side-channel-456",
promptCacheKey: "session-123",
});
expect(captured.sessionId).toBe("side-channel-456");
expect(captured.clientRequestId).toBe("side-channel-456");
expect(captured.body?.prompt_cache_key).toBe("session-123");
});
it("lets explicit headers override the default OpenAI session routing headers", async () => {
const captured = await captureOpenAIResponseHeaders({
+20 -14
View File
@@ -93,35 +93,41 @@ describe("register-builtins lazy streams", () => {
expect(result.errorMessage).toContain("bedrock exploded");
});
it("forwards lazy provider streams without first-event or idle watchdogs", async () => {
it("turns idle lazy provider streams into retryable terminal errors", async () => {
const partialMessage = createAssistantMessage("stop");
const finalMessage = createAssistantMessage("stop");
finalMessage.content = [{ type: "text", text: "slow ok" }];
let providerSignal: AbortSignal | undefined;
const source = {
async *[Symbol.asyncIterator]() {
await Bun.sleep(30);
yield { type: "start", partial: partialMessage } as const;
await Bun.sleep(30);
yield { type: "text_delta", contentIndex: 0, delta: "slow ok", partial: finalMessage } as const;
yield { type: "text_delta", contentIndex: 0, delta: "hello", partial: partialMessage } as const;
const { promise, reject } = Promise.withResolvers<never>();
if (providerSignal?.aborted) {
reject(new Error("Request was aborted"));
}
providerSignal?.addEventListener("abort", () => reject(new Error("Request was aborted")), {
once: true,
});
await promise;
},
result: async () => finalMessage,
} as unknown as AssistantMessageEventStream;
setBedrockProviderModule({
streamBedrock: () => source,
streamBedrock: (_model, _context, options) => {
providerSignal = options.signal;
return source;
},
});
const stream = streamBedrock(createModel(), baseContext, {
streamFirstEventTimeoutMs: 10,
streamIdleTimeoutMs: 10,
});
const stream = streamBedrock(createModel(), baseContext, { streamIdleTimeoutMs: 10 });
const result = await Promise.race([stream.result(), Bun.sleep(500).then(() => "timeout" as const)]);
expect(result).not.toBe("timeout");
if (result === "timeout") {
throw new Error("Timed out waiting for forwarded slow stream result");
throw new Error("Timed out waiting for forwarded stream stall result");
}
expect(result).toEqual(finalMessage);
expect(providerSignal?.aborted).toBe(true);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toBe("Provider stream stalled while waiting for the next event");
});
it("preserves caller aborts while forwarding lazy provider streams", async () => {
@@ -0,0 +1,182 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import {
getStreamFirstEventTimeoutMs,
getStreamIdleTimeoutMs,
iterateWithIdleTimeout,
} from "../src/utils/idle-iterator";
/**
* Per-provider fallback overrides on the stream-watchdog helpers.
*
* These are the gear that lets `google-gemini-cli` widen its first-event floor
* beyond the 100s global default without forcing every other provider to wait
* just as long. Tests pin the precedence contract callers depend on:
* caller option > env var > per-provider fallback > base default.
*/
const ENV_KEYS = [
"PI_STREAM_IDLE_TIMEOUT_MS",
"PI_OPENAI_STREAM_IDLE_TIMEOUT_MS",
"PI_STREAM_FIRST_EVENT_TIMEOUT_MS",
] as const;
const originalEnv: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>> = {};
beforeEach(() => {
for (const key of ENV_KEYS) {
originalEnv[key] = Bun.env[key];
delete Bun.env[key];
}
});
afterEach(() => {
for (const key of ENV_KEYS) {
const prior = originalEnv[key];
if (prior === undefined) {
delete Bun.env[key];
} else {
Bun.env[key] = prior;
}
}
});
describe("getStreamIdleTimeoutMs(fallbackMs)", () => {
it("returns the per-provider fallback when env vars are unset", () => {
expect(getStreamIdleTimeoutMs(300_000)).toBe(300_000);
});
it("lets PI_STREAM_IDLE_TIMEOUT_MS override the per-provider fallback", () => {
Bun.env.PI_STREAM_IDLE_TIMEOUT_MS = "42";
expect(getStreamIdleTimeoutMs(300_000)).toBe(42);
});
it("treats PI_STREAM_IDLE_TIMEOUT_MS=0 as a watchdog disable", () => {
Bun.env.PI_STREAM_IDLE_TIMEOUT_MS = "0";
expect(getStreamIdleTimeoutMs(300_000)).toBeUndefined();
});
});
describe("getStreamFirstEventTimeoutMs(idleTimeoutMs, fallbackMs)", () => {
it("returns the per-provider fallback when env unset and idle timeout is undefined", () => {
expect(getStreamFirstEventTimeoutMs(undefined, 300_000)).toBe(300_000);
});
it("floors the first-event timeout at the per-provider fallback even when idle is shorter", () => {
expect(getStreamFirstEventTimeoutMs(50_000, 300_000)).toBe(300_000);
});
it("never undershoots the steady-state idle timeout", () => {
expect(getStreamFirstEventTimeoutMs(500_000, 300_000)).toBe(500_000);
});
it("lets PI_STREAM_FIRST_EVENT_TIMEOUT_MS override the per-provider fallback", () => {
Bun.env.PI_STREAM_FIRST_EVENT_TIMEOUT_MS = "42";
expect(getStreamFirstEventTimeoutMs(undefined, 300_000)).toBe(42);
});
it("treats PI_STREAM_FIRST_EVENT_TIMEOUT_MS=0 as a watchdog disable", () => {
Bun.env.PI_STREAM_FIRST_EVENT_TIMEOUT_MS = "0";
expect(getStreamFirstEventTimeoutMs(undefined, 300_000)).toBeUndefined();
});
it("falls back to the 100s global default when no fallback or env is provided", () => {
expect(getStreamFirstEventTimeoutMs()).toBe(100_000);
});
});
async function expectRejectsWithMessage(run: () => Promise<void>, message: string): Promise<void> {
let caught: unknown;
try {
await run();
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(Error);
expect((caught as Error).message).toBe(message);
}
describe("iterateWithIdleTimeout", () => {
it("does not reset the first-progress deadline for no-progress items", async () => {
const abortController = new AbortController();
const abortTimer = setTimeout(() => abortController.abort(new Error("fallback abort")), 150);
abortTimer.unref();
async function* noProgressItems(): AsyncGenerator<{ type: "keepalive" }> {
while (true) {
await Bun.sleep(2);
yield { type: "keepalive" };
}
}
try {
const run = async (): Promise<void> => {
for await (const _item of iterateWithIdleTimeout(noProgressItems(), {
firstItemTimeoutMs: 20,
idleTimeoutMs: 1_000,
errorMessage: "idle timeout",
firstItemErrorMessage: "first progress timeout",
abortSignal: abortController.signal,
isProgressItem: () => false,
})) {
// Consume until the watchdog fires.
}
};
await expectRejectsWithMessage(run, "first progress timeout");
} finally {
clearTimeout(abortTimer);
}
});
it("cleans first-item timers when the source throws before progress", async () => {
let firstItemTimedOut = false;
// biome-ignore lint/correctness/useYield: intentionally yields nothing — the test exercises the path where the source generator throws before its first yield.
async function* failingStream(): AsyncGenerator<string> {
throw new Error("stream failed");
}
await expectRejectsWithMessage(async () => {
for await (const _item of iterateWithIdleTimeout(failingStream(), {
firstItemTimeoutMs: 10,
errorMessage: "idle timeout",
firstItemErrorMessage: "first progress timeout",
onFirstItemTimeout: () => {
firstItemTimedOut = true;
},
})) {
// Unreachable.
}
}, "stream failed");
await Bun.sleep(20);
expect(firstItemTimedOut).toBe(false);
});
it("cleans first-item timers when the consumer returns before progress", async () => {
let firstItemTimedOut = false;
async function* noProgressItems(): AsyncGenerator<{ type: "keepalive" }> {
while (true) {
await Bun.sleep(2);
yield { type: "keepalive" };
}
}
for await (const _item of iterateWithIdleTimeout(noProgressItems(), {
firstItemTimeoutMs: 10,
idleTimeoutMs: 1_000,
errorMessage: "idle timeout",
firstItemErrorMessage: "first progress timeout",
onFirstItemTimeout: () => {
firstItemTimedOut = true;
},
isProgressItem: () => false,
})) {
break;
}
await Bun.sleep(20);
expect(firstItemTimedOut).toBe(false);
});
});
+106
View File
@@ -1,6 +1,106 @@
# Changelog
## [Unreleased]
### Added
- Added interactive provider selection to `omp auth-broker logout` when no provider argument is supplied
- Added `--json` flag to `omp auth-broker list` for machine-readable output
- Added `omp auth-broker list` to enumerate supported OAuth providers (replaces `bunx @oh-my-pi/pi-ai list`).
- Added interactive provider selection to `omp auth-broker login` and `omp auth-broker logout` when no provider argument is supplied (replaces `bunx @oh-my-pi/pi-ai login` / `logout` interactive flows).
### Changed
- Changed `omp auth-broker login` to support interactive provider selection when invoked without a provider argument
- Changed `omp auth-broker logout` to support interactive provider selection from stored credentials when invoked without a provider argument
- Changed `omp auth-broker login` to drive the per-provider OAuth/API-key flow in-process via `AuthStorage.login()` instead of spawning the `pi-ai` CLI subprocess. The pi-ai bin is being removed; the same login surface now lives entirely inside `omp`.
- Changed default per-line truncation cap for search/grep output (`DEFAULT_MAX_COLUMN`) from `1024` to `512` characters.
## [15.4.3] - 2026-05-26
### Fixed
- Fixed Google Vertex cached project discovery replacing the bundled fallback catalog so `/models` does not keep showing outdated Gemini entries after authoritative Vertex discovery, while keeping the bundled fallback in place when the cached snapshot is stale or non-authoritative (e.g. after an ADC discovery failure) ([#1412](https://github.com/can1357/oh-my-pi/issues/1412)).
## [15.4.2] - 2026-05-26
### Fixed
- Fixed plan-mode subagents being unable to terminate because `yield` was registered but missing from the active tool set when `requireYieldTool` was combined with an explicit `toolNames` list ([#1408](https://github.com/can1357/oh-my-pi/issues/1408))
## [15.4.1] - 2026-05-26
### Breaking Changes
- The `vim` edit mode option is no longer available; configurations using `edit.mode: vim` will be automatically mapped to `hashline` mode
- Hashline payload semantics are now strictly inline-first: the first payload line is whatever follows the sigil on the op line itself, and subsequent lines append after it. A newline immediately after `↑`/`↓`/`:` is no longer a free separator — it produces a blank first payload line. Use `LINE↓content` for a one-line insert, `LINE↓firstline\nsecondline` for two lines; bare `LINE↓` / `LINE↑` / `LINE:` (no inline payload) still insert/replace with one blank line as before.
### Added
- Added `irc.timeoutMs` setting to configure IRC message timeout duration with a default of 120 seconds
- Added timeout enforcement for IRC send operations to prevent indefinite hangs when recipients are unresponsive
- Added evaluator state inheritance for `task`-spawned subagents so JavaScript and Python variables are visible between a parent agent and its child sessions
- Added `hashline-per` edit mode to restore the legacy per-line hashline dialect alongside the default file-hash dialect
- Added file-hash computation and validation for hashline sections to detect stale edits
- Added file-read snapshot caching with multi-snapshot ring per path for recovery from agent's own writes
- Added delete operation (`!`) support to hashline grammar for explicit line deletion
- Added structural bracket/brace balance warnings when deleting lines with unclosed constructs
- Added file-hash computation and validation for hashline sections to detect stale edits
- Added file-read snapshot caching with multi-snapshot ring per path for recovery from agent's own writes
- Added delete operation (`!`) support to hashline grammar for explicit line deletion
- Added structural bracket/brace balance warnings when deleting lines with unclosed constructs
- Added resource metadata URL (RFC 9728) support to OAuth discovery for chaining authorization server resolution from protected-resource metadata ([1407](https://github.com/can1357/oh-my-pi/pull/1407) by [@faizhasim](https://github.com/faizhasim))
- Added path-prefixed well-known URL fallback in OAuth discovery to support authorization servers behind gateways with sub-path routing ([1407](https://github.com/can1357/oh-my-pi/pull/1407) by [@faizhasim](https://github.com/faizhasim))
- Added relative URL resolution for `Mcp-Auth-Server` header values against the server URL ([1407](https://github.com/can1357/oh-my-pi/pull/1407) by [@faizhasim](https://github.com/faizhasim))
### Changed
- Changed Python shared eval sessions to be keyed by `sessionId` and `cwd` so code state no longer leaks across different directories when reusing a session
- Changed shared JavaScript and Python startup to deduplicate concurrent first-time session initialization so parallel first calls share one warm session
- Changed shared JavaScript and Python execution output handling so interleaved async runs keep their `display` output scoped to the originating run
- Changed Python tool bridge to use per-run identifiers alongside session IDs for correct routing of tool responses and output in concurrent evaluations
- Changed JavaScript and Python `eval` execution to allow overlapping asynchronous cells on the same session ID to run concurrently instead of being strictly queued
- Updated the edit mode option set to support `replace`, `patch`, `hashline`, and `apply_patch` variants
- Bare `A:` / `A-B:` (no payload, no inline body) now replaces the line/range with a single blank line, symmetric with bare `A↑` / `A↓` inserting a blank line; previously rejected as ambiguous
- Simplified hashline anchor format from `LINE+HASH` to bare `LINE` numbers in edit operations
- Updated hashline file headers to include 4-hex file hash: `¶PATH#HASH` format for anchored edits
- Changed hashline line separator from `|` to `:` in editable output (e.g., `42:content` instead of `42ab|content`)
- Removed per-line hash validation; file-level hash now validates entire section integrity
- Updated read/search output to emit file-hash headers (`¶PATH#HASH`) followed by numbered lines for hashline mode
- Modified hashline grammar to accept optional file hash in headers and removed hash requirements from line anchors
- Changed hashline diff preview format to use `LINE:content` instead of `LINE+HASH|content`
- Updated prompt documentation to reflect new `¶PATH#HASH` header and bare line-number syntax
- Bare `A:` / `A-B:` (no payload, no inline body) now replaces the line/range with a single blank line, symmetric with bare `A↑` / `A↓` inserting a blank line; previously rejected as ambiguous
- Simplified hashline anchor format from `LINE+HASH` to bare `LINE` numbers in edit operations
- Updated hashline file headers to include 4-hex file hash: `¶PATH#HASH` format for anchored edits
- Changed hashline line separator from `|` to `:` in editable output (e.g., `42:content` instead of `42ab|content`)
- Removed per-line hash validation; file-level hash now validates entire section integrity
- Updated read/search output to emit file-hash headers (`¶PATH#HASH`) followed by numbered lines for hashline mode
- Modified hashline grammar to accept optional file hash in headers and removed hash requirements from line anchors
- Changed hashline diff preview format to use `LINE:content` instead of `LINE+HASH|content`
- Updated prompt documentation to reflect new `¶PATH#HASH` header and bare line-number syntax
- Updated `discoverOAuthEndpoints` to accept `resourceMetadataUrl` parameter and prioritize the resource-metadata chain ([1407](https://github.com/can1357/oh-my-pi/pull/1407) by [@faizhasim](https://github.com/faizhasim))
- Updated `parseMcpAuthServerUrl` and `extractMcpAuthServerUrl` to accept optional `serverUrl` for relative URL resolution ([1407](https://github.com/can1357/oh-my-pi/pull/1407) by [@faizhasim](https://github.com/faizhasim))
- Updated `MCPOAuthFlow.#resolveRegistrationEndpoint` to try origin-root well-known first, then fall back to path-prefixed well-known ([1407](https://github.com/can1357/oh-my-pi/pull/1407) by [@faizhasim](https://github.com/faizhasim))
### Removed
- Removed the `installH2Fetch()` activation from CLI startup; HTTPS fetches now use Bun's default transport
- Removed the `vim` edit mode along with the `VimTool` module, prompt, and supporting buffer/engine/renderer stack
- Removed per-line hash anchors (2-letter bigram hashes) from hashline format
- Removed `RANGE_INTERIOR_HASH` constant; multi-line ranges no longer use `**` filler
- Removed `HashMismatch` type and hash mismatch error reporting; replaced with file-level validation
- Removed per-line hash anchors (2-letter bigram hashes) from hashline format
- Removed `RANGE_INTERIOR_HASH` constant; multi-line ranges no longer use `**` filler
- Removed `HashMismatch` type and hash mismatch error reporting; replaced with file-level validation
### Fixed
- Fixed missing `await` on `#tryWellKnownForRegistration` call in `#resolveRegistrationEndpoint` that caused path-prefixed well-known fallback to never actually execute, returning the unresolved Promise object instead of the registration endpoint ([1407](https://github.com/can1357/oh-my-pi/pull/1407) by [@faizhasim](https://github.com/faizhasim))
- Fixed JavaScript module reloading to refresh local re-exports when transitive dependency files are edited
- Fixed Python tool calls in warm kernels to initialize once bridge environment variables appear after startup and to return a clear `tool bridge is unavailable` error when missing
- Fixed IRC `send` handling to preserve recipient incoming messages when auto-reply timeouts instead of dropping them
- Fixed Python session disposal to cancel all concurrent active executions in a shared kernel
- Fixed JavaScript `eval` imports to preserve module-level singletons across re-imports of unchanged local files and reload them only after edits
- Fixed concurrent Python evaluator tool calls to use per-run identifiers so tool responses and output are routed to the correct execution
- Fixed the `search` tool argument validation to accept a single string `paths` value as a one-path search.
## [15.4.0] - 2026-05-26
### Breaking Changes
@@ -8,8 +108,10 @@
### Added
- Added resolved subagent model badge to the task widget status line showing `<provider>/<id>` (with optional `:<thinkingLevel>` suffix when thinking is set explicitly), opt-in via `task.showResolvedModelBadge` Appearance setting (default off)
- Added `codex` and `gemini` to the web search provider settings so users can configure OpenAI and Gemini web search directly from provider selection
- Added OpenAI (`codex`) and Gemini web search options with updated setup descriptions for `omp /login openai-codex` and Gemini OAuth login
- Added pretty-printing for wide JSON `data:` payloads in the raw provider-stream debug viewer so streamed event bodies expand across multiple `data:` lines instead of getting clipped by the per-line truncator, and updated the viewer header to read `raw provider stream (SSE + WS)` now that Codex WebSocket frames also flow through the buffer
### Changed
@@ -33,11 +135,15 @@
- Fixed three correctness issues in the `find` tool. (1) `onMatch` guard checked the outer task `signal?.aborted` instead of `combinedSignal.aborted` (which also includes the per-call timeout signal), so late matches accumulated past the timeout. (2) Timeout-drained partial results were emitted in insertion order while the normal path sorts by mtime descending; callers relying on "most recently modified first" got an inconsistent ordering when the call timed out. The timeout drain now tracks per-entry mtime in a parallel array and applies the same comparator. (3) `validateFindPathInputs` now skips backslash-escaped commas (`\,`) when checking for top-level commas, matching `search.ts:containsTopLevelComma`.
- Fixed `throwPreferredDapStartError` using a single `await Promise.resolve()` (one microtask) to let a concurrent launch/attach rejection settle before deciding which error to surface. That worked for synchronous-rejection test fakes but not real adapter I/O: the launch failure arrives via socket and lands several ticks after the `configurationDone` failure. `DapStartRequestFailure` now carries a `settled?: Promise<void>` that resolves when the underlying request settles either way, and `throwPreferredDapStartError` races against it with a 50 ms ceiling. The preferred error message (the underlying launch/attach failure rather than the cascade) now surfaces under real I/O.
- Fixed `adapter: "debugpy"` over-promising in the `debug` tool. `resolveAdapter("debugpy", cwd)` only checks for `python` in `PATH`; it does not verify that the `debugpy` module is importable. Both failure modes — `python` missing and `debugpy` module missing — used to collapse onto a generic `"No suitable debug adapter found"` error. The launch/attach action now throws a targeted `ToolError` naming `python` when `resolveAdapter` returns null for an explicit `adapter: "debugpy"`, and the `DapSessionManager` spawn-catch detects `"No module named debugpy"` in adapter stderr and surfaces a `pip install debugpy` hint. The Python debug tool documentation lists the install hint so the prompt and runtime diagnostics agree.
- Fixed the LSP symbol-resolver `BARE_IDENTIFIER_RE` (`/^[A-Za-z_][\w]*$/`) rejecting `$`-prefixed identifiers (`$store`, `$count`, RxJS observables, Svelte stores, Angular signals). Without the word-boundary check, searching for `$store` on a line containing `bar$store` returned the offset inside the compound identifier rather than the standalone occurrence, feeding a wrong column to the LSP server. Pattern now `/^[$A-Za-z_][\w$]*$/`; the companion `IDENTIFIER_CHAR_RE` already contained `$`.
- Fixed `applyWorkspaceEdit` writing all text edits before walking `documentChanges` for resource operations. LSP §3.16.2 requires clients to apply `documentChanges` in declared order, so any server emitting `{kind: "create", uri: X}` followed by a `TextDocumentEdit` for `X` (e.g. "Extract to new file" code actions, some rename responses) broke: the edit ran against a non-existent file, then the create happened. `applyWorkspaceEdit` now walks `documentChanges` once in declared order; per-URI text edits are coalesced into a pending Map and flushed immediately before any subsequent resource op for the same URI. Legacy `changes`-map-only payloads are unchanged. Folder-level `rename`/`delete` ops now flush every pending URI under the affected subtree (not just the exact target) so child-file edits queued before a parent-folder move land at the original location instead of dangling against a non-existent path on the final flush. Rename ops additionally flush pending edits queued against `renameOp.newUri` (and its descendants) **before** `fs.rename` runs, so edits intended for the pre-rename target file are applied before the rename clobbers or replaces it (relevant under `options.overwrite`/`options.ignoreIfExists`). When a `WorkspaceEdit` payload supplies both `changes` and `documentChanges`, the `documentChanges` arm is now used exclusively per LSP §3.16.2 ("if documentChanges are supplied … servers should use them in preference to changes"); previously the two were merged.
### Fixed
- Fixed built-in `explore` agent failing every invocation with `schema_violation: files.0.ref: must not be present` on releases prior to 15.3.2 by renaming the `files[].ref` property to `files[].path` in the agent's output schema; `ref` is a JTD-reserved keyword (RFC 8927) and collides with JSON Type Definition's schema-reference form, so the converter previously dropped it from the generated JSON Schema. Defense-in-depth alongside the 15.3.2 converter fix ([#1379](https://github.com/can1357/oh-my-pi/issues/1379)).
- Increased the `yield` tool's schema-validation retry budget from 1 to 3 so subagents whose first structured-output attempt mismatches the declared output schema get up to three retries before the parent's post-mortem `schema_violation` check hard-fails the task. The tool now also surfaces remaining retry attempts and an explicit "call yield again with the corrected shape" directive in each rejection message, giving the model the context it needs to converge — particularly helpful for models like GLM that tend to invent per-element field names instead of following the declared schema.
- Fixed CLI PDF file arguments being decoded as raw bytes for local vision models; `.pdf` and other supported document files now go through the same Markit conversion path as the `read` tool before entering the prompt ([#1401](https://github.com/can1357/oh-my-pi/issues/1401)).
- Fixed the `bash` tool hanging until the 305 s hard timeout when a command writes a file via heredoc on Windows (bodies > ~4 KiB) or macOS (bodies > 16-64 KiB). Root cause was in the embedded brush shell; see `@oh-my-pi/pi-natives` changelog for the underlying fix.
## [15.3.2] - 2026-05-25
### Added
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "@oh-my-pi/pi-coding-agent",
"version": "15.3.2",
"version": "15.4.3",
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
"homepage": "https://omp.sh",
"author": "Can Boluk",
-8
View File
@@ -1,14 +1,6 @@
#!/usr/bin/env bun
import { installH2Fetch } from "@oh-my-pi/pi-ai";
import { APP_NAME, MIN_BUN_VERSION, procmgr, VERSION } from "@oh-my-pi/pi-utils";
// Activate HTTP/2 for all `fetch()` calls (provider streams, OAuth, model
// discovery, web tools). Bun's HTTP/2 client is gated on a startup flag we
// can't toggle from JS, so we patch globalThis.fetch to pass
// `protocol: "http2"` per request, with transparent HTTP/1.1 fallback on
// `HTTP2Unsupported`. See @oh-my-pi/pi-ai/utils/h2-fetch for details.
installH2Fetch();
// Strip macOS malloc-stack-logging env vars before any subprocess is spawned.
// Otherwise every child bun process (subagents, plugin installs, ptree spawns,
// etc.) prints a `MallocStackLogging: can't turn off …` warning to stderr.
+171 -22
View File
@@ -17,6 +17,7 @@ import * as crypto from "node:crypto";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import * as readline from "node:readline";
import {
AuthBrokerClient,
type AuthCredential,
@@ -28,6 +29,7 @@ import {
listProvidersWithEnvKey,
type OAuthCredential,
type OAuthProvider,
type OAuthProviderInfo,
SqliteAuthCredentialStore,
startAuthBroker,
} from "@oh-my-pi/pi-ai";
@@ -36,7 +38,7 @@ import { $ } from "bun";
import chalk from "chalk";
import { resolveAuthBrokerConfig } from "../session/auth-broker-config";
export type AuthBrokerAction = "serve" | "token" | "login" | "logout" | "status" | "import" | "migrate";
export type AuthBrokerAction = "serve" | "token" | "login" | "logout" | "status" | "import" | "migrate" | "list";
export interface AuthBrokerCommandArgs {
action: AuthBrokerAction;
@@ -60,7 +62,16 @@ export interface AuthBrokerCommandArgs {
};
}
const ACTIONS: readonly AuthBrokerAction[] = ["serve", "token", "login", "logout", "import", "migrate", "status"];
const ACTIONS: readonly AuthBrokerAction[] = [
"serve",
"token",
"login",
"logout",
"import",
"migrate",
"status",
"list",
];
/** Callback ports baked from the per-provider OAuth flow modules. */
const CALLBACK_PORTS: Record<string, number> = {
@@ -168,13 +179,23 @@ async function runToken(flags: AuthBrokerCommandArgs["flags"]): Promise<void> {
}
async function runLogin(flags: AuthBrokerCommandArgs["flags"]): Promise<void> {
const providerArg = flags.provider;
const providers = getOAuthProviders();
let providerArg = flags.provider;
if (!providerArg) {
throw new Error("Usage: omp auth-broker login <provider> [--via=user@host]");
if (flags.via) {
throw new Error(
"Usage: omp auth-broker login <provider> --via=user@host (provider required for remote login)",
);
}
providerArg = await pickProviderInteractively(providers);
}
const oauthProviders = new Set<string>(getOAuthProviders().map(p => p.id));
if (!oauthProviders.has(providerArg)) {
throw new Error(`Unknown OAuth provider '${providerArg}'. Known: ${[...oauthProviders].sort().join(", ")}`);
if (!providers.some(p => p.id === providerArg)) {
throw new Error(
`Unknown OAuth provider '${providerArg}'. Known: ${providers
.map(p => p.id)
.sort()
.join(", ")}`,
);
}
if (flags.via) {
await runRemoteLogin(providerArg, flags.via, flags.dryRun ?? false);
@@ -184,18 +205,107 @@ async function runLogin(flags: AuthBrokerCommandArgs["flags"]): Promise<void> {
}
async function runLocalLogin(provider: OAuthProvider): Promise<void> {
// Spawn the pi-ai CLI in-process — it handles the per-provider OAuth dance
// and persists into the same SQLite store the broker uses.
const piAiCli = Bun.fileURLToPath(import.meta.resolve("@oh-my-pi/pi-ai/cli"));
const proc = Bun.spawn({
cmd: [process.execPath, piAiCli, "login", provider],
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
// Drive the per-provider OAuth dance in-process. Persists into the same
// SQLite store the broker uses.
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ask = (msg: string) => promptLine(rl, `${msg} `);
const store = await SqliteAuthCredentialStore.open(getAgentDbPath());
const storage = new AuthStorage(store);
await storage.reload();
try {
await storage.login(provider, {
onAuth({ url, instructions }) {
process.stdout.write(`\nOpen this URL in your browser:\n${url}\n`);
if (instructions) process.stdout.write(`${instructions}\n`);
process.stdout.write("\n");
},
onProgress(message) {
process.stdout.write(`${message}\n`);
},
onPrompt(p) {
return ask(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`);
},
});
process.stdout.write(`\nCredentials saved to ${getAgentDbPath()}\n`);
} finally {
store.close();
rl.close();
}
}
/**
* Interactive `readline` prompt that cleanly tears down on Ctrl-C / Escape so
* cancelling a half-finished login flow doesn't leave the terminal in raw mode.
*/
function promptLine(rl: readline.Interface, question: string): Promise<string> {
const { promise, resolve, reject } = Promise.withResolvers<string>();
const input = process.stdin as NodeJS.ReadStream;
const supportsRawMode = input.isTTY && typeof input.setRawMode === "function";
const wasRaw = supportsRawMode ? input.isRaw : false;
let settled = false;
const cleanup = () => {
rl.off("SIGINT", onSigint);
if (supportsRawMode) {
input.off("keypress", onKeypress);
input.setRawMode?.(wasRaw);
}
};
const finish = (result: () => void) => {
if (settled) return;
settled = true;
cleanup();
result();
};
const cancel = () => {
finish(() => reject(new Error("Login cancelled")));
};
const onSigint = () => {
cancel();
};
const onKeypress = (_str: string, key: readline.Key) => {
if (key.name === "escape" || (key.ctrl && key.name === "c")) {
cancel();
rl.close();
}
};
if (supportsRawMode) {
readline.emitKeypressEvents(input, rl);
input.setRawMode(true);
input.on("keypress", onKeypress);
}
rl.once("SIGINT", onSigint);
rl.question(question, answer => {
finish(() => resolve(answer));
});
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(`pi-ai login exited with code ${exitCode}`);
return promise;
}
async function pickProviderInteractively(providers: readonly OAuthProviderInfo[]): Promise<string> {
if (providers.length === 0) {
throw new Error("No OAuth providers registered");
}
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
try {
process.stdout.write("Select a provider:\n\n");
for (let i = 0; i < providers.length; i++) {
process.stdout.write(` ${i + 1}. ${providers[i].name}\n`);
}
process.stdout.write("\n");
const choice = await promptLine(rl, `Enter number (1-${providers.length}): `);
const index = Number.parseInt(choice, 10) - 1;
if (Number.isNaN(index) || index < 0 || index >= providers.length) {
throw new Error(`Invalid selection: ${choice}`);
}
return providers[index].id;
} finally {
rl.close();
}
}
@@ -235,12 +345,17 @@ async function runRemoteLogin(provider: string, via: string, dryRun: boolean): P
}
async function runLogout(flags: AuthBrokerCommandArgs["flags"]): Promise<void> {
const providerArg = flags.provider;
if (!providerArg) {
throw new Error("Usage: omp auth-broker logout <provider>");
}
let providerArg = flags.provider;
const store = await SqliteAuthCredentialStore.open(getAgentDbPath());
try {
if (!providerArg) {
const stored = store.listProviders();
if (stored.length === 0) {
process.stdout.write("No credentials stored.\n");
return;
}
providerArg = await pickStoredProviderInteractively(stored);
}
store.deleteAuthCredentialsForProvider(providerArg, "logged out by user");
process.stdout.write(`Logged out of ${providerArg}\n`);
} finally {
@@ -248,6 +363,37 @@ async function runLogout(flags: AuthBrokerCommandArgs["flags"]): Promise<void> {
}
}
async function pickStoredProviderInteractively(providers: string[]): Promise<string> {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
try {
process.stdout.write("Select a provider to logout:\n\n");
for (let i = 0; i < providers.length; i++) {
process.stdout.write(` ${i + 1}. ${providers[i]}\n`);
}
process.stdout.write("\n");
const choice = await promptLine(rl, `Enter number (1-${providers.length}): `);
const index = Number.parseInt(choice, 10) - 1;
if (Number.isNaN(index) || index < 0 || index >= providers.length) {
throw new Error(`Invalid selection: ${choice}`);
}
return providers[index];
} finally {
rl.close();
}
}
async function runList(flags: AuthBrokerCommandArgs["flags"]): Promise<void> {
const providers = getOAuthProviders();
if (flags.json) {
process.stdout.write(`${JSON.stringify(providers.map(p => ({ id: p.id, name: p.name })))}\n`);
return;
}
process.stdout.write("Available providers:\n\n");
for (const p of providers) {
process.stdout.write(` ${p.id.padEnd(20)} ${p.name}\n`);
}
}
// ─── CLIProxyAPI import ─────────────────────────────────────────────────
/**
@@ -732,6 +878,9 @@ export async function runAuthBrokerCommand(cmd: AuthBrokerCommandArgs): Promise<
case "status":
await runStatus(cmd.flags);
return;
case "list":
await runList(cmd.flags);
return;
default: {
// Exhaustive check.
const _exhaustive: never = cmd.action;
@@ -1,5 +1,5 @@
/**
* Process @file CLI arguments into text content and image attachments
* Process @file CLI arguments into text, document content, and image attachments
*/
import * as fs from "node:fs";
import * as path from "node:path";
@@ -9,11 +9,13 @@ import chalk from "chalk";
import { resolveReadPath } from "../tools/path-utils";
import { formatBytes } from "../tools/render-utils";
import { formatDimensionNote, resizeImage } from "../utils/image-resize";
import { convertFileWithMarkit } from "../utils/markit";
// Keep CLI startup responsive and avoid OOM when users pass huge files.
// If a file exceeds these limits, we include it as a path-only <file/> block.
const MAX_CLI_TEXT_BYTES = 5 * 1024 * 1024; // 5MB
const MAX_CLI_IMAGE_BYTES = 25 * 1024 * 1024; // 25MB
const CONVERTIBLE_EXTENSIONS = new Set([".pdf", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx", ".rtf", ".epub"]);
export interface ProcessedFiles {
text: string;
@@ -25,7 +27,7 @@ export interface ProcessFileOptions {
autoResizeImages?: boolean;
}
/** Process @file arguments into text content and image attachments */
/** Process @file arguments into text, document content, and image attachments */
export async function processFileArguments(fileArgs: string[], options?: ProcessFileOptions): Promise<ProcessedFiles> {
const autoResizeImages = options?.autoResizeImages ?? true;
let text = "";
@@ -43,6 +45,7 @@ export async function processFileArguments(fileArgs: string[], options?: Process
const imageMetadata = await readImageMetadata(absolutePath);
const mimeType = imageMetadata?.mimeType;
const ext = path.extname(absolutePath).toLowerCase();
const maxBytes = mimeType ? MAX_CLI_IMAGE_BYTES : MAX_CLI_TEXT_BYTES;
if (stat.size > maxBytes) {
console.error(
@@ -106,6 +109,13 @@ export async function processFileArguments(fileArgs: string[], options?: Process
} else {
text += `<file name="${absolutePath}"></file>\n`;
}
} else if (CONVERTIBLE_EXTENSIONS.has(ext)) {
const result = await convertFileWithMarkit(absolutePath);
if (result.ok) {
text += `<file name="${absolutePath}">\n${result.content}\n</file>\n`;
} else {
text += `<file name="${absolutePath}">[Cannot read ${ext} file: ${result.error || "conversion failed"}]</file>\n`;
}
} else {
// Handle text file
try {
@@ -56,8 +56,11 @@ export default class AuthBroker extends Command {
"# Boot on a non-default port\n omp auth-broker serve --bind=127.0.0.1:9000",
"# Print the bearer token\n omp auth-broker token",
"# Rotate the bearer token\n omp auth-broker token --regenerate",
"# List supported OAuth providers\n omp auth-broker list",
"# Local login (run on the broker host)\n omp auth-broker login anthropic",
"# Interactive provider selection\n omp auth-broker login",
"# Remote login over SSH tunnel\n omp auth-broker login anthropic --via=user@broker",
"# Log out of a provider (interactive without provider arg)\n omp auth-broker logout anthropic",
"# Import a CLIProxyAPI auth dump\n omp auth-broker import ~/.cliproxy/auth",
"# Import a single CLIProxyAPI JSON, overriding the provider mapping\n omp auth-broker import ~/.cliproxy/auth/claude-foo.json --provider anthropic",
"# Preview a migration from local store + env vars to the configured broker\n omp auth-broker migrate --from-local --include-env --dry-run",
+8 -8
View File
@@ -32,14 +32,14 @@ export default class Commit extends Command {
};
await initTheme();
// The agentic commit flow opens HTTP/2 keep-alive sockets to the model
// provider (via `installH2Fetch`) and spins up an AgentSession with
// background async-job + extension machinery. `session.dispose()` releases
// what it can, but Bun's fetch keeps idle connections warm and a few
// timers (Settings autosave, OAuth refresh) stay armed long enough to
// pin the event loop after the commit is already written. Mirror the
// `runPrintMode` exit pattern from `main.ts` so the CLI returns to the
// shell instead of stranding the user on Ctrl+C (issue #1041).
// The agentic commit flow opens keep-alive sockets to the model provider
// and spins up an AgentSession with background async-job + extension
// machinery. `session.dispose()` releases what it can, but Bun's fetch
// keeps idle connections warm and a few timers (Settings autosave, OAuth
// refresh) stay armed long enough to pin the event loop after the commit
// is already written. Mirror the `runPrintMode` exit pattern from
// `main.ts` so the CLI returns to the shell instead of stranding the user
// on Ctrl+C (issue #1041).
await runCommitCommand(cmd);
await postmortem.quit(0);
}
@@ -291,6 +291,28 @@ export function mergeDiscoveredModel<TApi extends Api>(
return model;
}
function isAuthoritativeProjectCatalogModel(model: Model<Api>): boolean {
return (
model.provider === "google-vertex" &&
model.api === "openai-completions" &&
model.baseUrl.includes("/endpoints/openapi")
);
}
function providersWithAuthoritativeProjectCatalog(models: readonly Model<Api>[]): Set<string> {
const providers = new Set<string>();
for (const model of models) {
if (isAuthoritativeProjectCatalogModel(model)) {
providers.add(model.provider);
}
}
return providers;
}
function dropProviderModels(models: readonly Model<Api>[], providers: ReadonlySet<string>): Model<Api>[] {
return models.filter(model => !providers.has(model.provider));
}
interface DiscoveryProviderConfig {
provider: string;
api: Api;
@@ -877,9 +899,24 @@ export class ModelRegistry {
this.#equivalenceConfig = equivalence;
this.#addImplicitDiscoverableProviders(configuredProviders);
const builtInModels = this.#applyHardcodedModelPolicies(this.#loadBuiltInModels(overrides));
const cachedStandardModels = this.#applyHardcodedModelPolicies(this.#loadCachedStandardProviderModels());
let builtInModels = this.#applyHardcodedModelPolicies(this.#loadBuiltInModels(overrides));
const cachedStandardResult = this.#loadCachedStandardProviderModels();
const cachedStandardModels = this.#applyHardcodedModelPolicies(cachedStandardResult.models);
const cachedDiscoveries = this.#applyHardcodedModelPolicies(this.#loadCachedDiscoverableModels());
// Only drop bundled fallback models when the cached project-catalog row is
// itself fresh AND authoritative. A stale or non-authoritative snapshot
// (e.g. after ADC discovery failure rewrote the row with authoritative=0)
// must not strip bundled Vertex Gemini entries — that would leave only the
// stale project-scoped rows in API-key-only environments.
const cachedAuthoritativeProviders = new Set<string>();
for (const provider of providersWithAuthoritativeProjectCatalog(cachedStandardModels)) {
if (cachedStandardResult.authoritativeFreshProviders.has(provider)) {
cachedAuthoritativeProviders.add(provider);
}
}
if (cachedAuthoritativeProviders.size > 0) {
builtInModels = dropProviderModels(builtInModels, cachedAuthoritativeProviders);
}
const resolvedDefaults = this.#mergeResolvedModels(
this.#mergeResolvedModels(builtInModels, cachedStandardModels),
cachedDiscoveries,
@@ -982,9 +1019,10 @@ export class ModelRegistry {
return merged;
}
#loadCachedStandardProviderModels(): Model<Api>[] {
#loadCachedStandardProviderModels(): { models: Model<Api>[]; authoritativeFreshProviders: Set<string> } {
const configuredDiscoveryProviders = new Set(this.#discoverableProviders.map(provider => provider.provider));
const cachedModels: Model<Api>[] = [];
const authoritativeFreshProviders = new Set<string>();
for (const descriptor of PROVIDER_DESCRIPTORS) {
if (configuredDiscoveryProviders.has(descriptor.providerId)) {
continue;
@@ -993,6 +1031,9 @@ export class ModelRegistry {
if (!cache) {
continue;
}
if (cache.fresh && cache.authoritative) {
authoritativeFreshProviders.add(descriptor.providerId);
}
const models = cache.models.map(model =>
model.provider === descriptor.providerId ? model : { ...model, provider: descriptor.providerId },
);
@@ -1005,7 +1046,7 @@ export class ModelRegistry {
: withTransport;
cachedModels.push(...this.#applyProviderModelOverrides(descriptor.providerId, withCompat));
}
return cachedModels;
return { models: cachedModels, authoritativeFreshProviders };
}
#loadCachedDiscoverableModels(): Model<Api>[] {
@@ -1229,7 +1270,10 @@ export class ModelRegistry {
),
),
);
const resolved = this.#mergeResolvedModels(this.#models, discoveredModels);
const authoritativeProviders = providersWithAuthoritativeProjectCatalog(discoveredModels);
const baseModels =
authoritativeProviders.size > 0 ? dropProviderModels(this.#models, authoritativeProviders) : this.#models;
const resolved = this.#mergeResolvedModels(baseModels, discoveredModels);
const withConfigModels = this.#mergeCustomModels(resolved, this.#customModelOverlays);
// Merge runtime extension models so they survive online discovery completion
const combined = this.#mergeCustomModels(withConfigModels, this.#runtimeModelOverlays);
@@ -8,7 +8,7 @@ import {
parseFrontmatter,
prompt,
} from "@oh-my-pi/pi-utils";
import { computeLineHash, HL_BODY_SEP } from "../hashline/hash";
import { HL_LINE_BODY_SEP } from "../hashline/hash";
import { jtdToTypeScript } from "../tools/jtd-to-typescript";
import { parseCommandArgs, substituteArgs } from "../utils/command-args";
@@ -34,7 +34,7 @@ function formatHashlineRef(lineNum: unknown, content: unknown): { num: number; t
const num = typeof lineNum === "number" ? lineNum : Number.parseInt(String(lineNum), 10);
const raw = typeof content === "string" ? content : String(content ?? "");
const text = raw.replace(/\\t/g, "\t").replace(/\\n/g, "\n").replace(/\\r/g, "\r");
const ref = `${num}${computeLineHash(num, text)}`;
const ref = `${num}`;
return { num, text, ref };
}
@@ -124,11 +124,11 @@ function resolveHashlineRef(state: HashlineHelperState, args: unknown[]): string
}
/**
* {{href lineNum "content"}} — compute a real hashline ref for prompt examples.
* {{href lineNum "content"}} — compute a hashline line ref for prompt examples.
* {{href lineNum}} — quote the ref remembered by the earlier {{hline lineNum "..."}}
* {{href}} — quote the ref from the previous {{hline}} call.
* {{href "[" "]"}} — wrap the previous {{hline}} ref with pre/post chars.
* Returns `"lineNumBIGRAM"` (e.g., `"42nd"`), or `"[42nd]"` when pre/post are supplied.
* Returns `"lineNum"` (e.g., `"42"`), or `"[42]"` when pre/post are supplied.
*/
prompt.registerHelper("href", function (this: unknown, ...args: unknown[]): string {
const { positional, options } = splitHelperArgs(args);
@@ -143,7 +143,7 @@ prompt.registerHelper("hrefr", function (this: unknown, ...args: unknown[]): str
/**
* {{hline lineNum "content"}} — format a full read-style line with prefix.
* Returns `"lineNumBIGRAM|content"` (pipe between anchor and content).
* Returns `"lineNum:content"` (colon between line number and content).
*/
prompt.registerHelper("hline", function (this: unknown, ...args: unknown[]): string {
const { positional, options } = splitHelperArgs(args);
@@ -151,7 +151,7 @@ prompt.registerHelper("hline", function (this: unknown, ...args: unknown[]): str
const { num, ref, text } = formatHashlineRef(lineNum, content);
const state = getHashlineHelperState(this, options);
rememberHashlineRef(state, num, ref);
return `${ref}${HL_BODY_SEP}${text}`;
return `${ref}${HL_LINE_BODY_SEP}${text}`;
});
const INLINE_ARG_SHELL_PATTERN = /\$(?:ARGUMENTS|@(?:\[\d+(?::\d*)?\])?|\d+)/;
@@ -1528,7 +1528,7 @@ export const SETTINGS_SCHEMA = {
ui: {
tab: "editing",
label: "Edit Mode",
description: "Select the edit tool variant (replace, patch, hashline, vim, or apply_patch)",
description: "Select the edit tool variant (replace, patch, hashline, or apply_patch)",
},
},
@@ -1603,7 +1603,8 @@ export const SETTINGS_SCHEMA = {
ui: {
tab: "editing",
label: "Hash Lines",
description: "Include line hashes in read output for hashline edit mode (LINE+ID|content)",
description:
"Include file-hash headers and line numbers in read output for hashline edit mode (¶PATH#hash plus LINE:content)",
},
},
@@ -1905,6 +1906,24 @@ export const SETTINGS_SCHEMA = {
},
},
"irc.timeoutMs": {
type: "number",
default: 120_000,
ui: {
tab: "tools",
label: "IRC Timeout",
description:
"Drop IRC messages whose recipient does not respond within this many milliseconds (0 disables the timeout)",
options: [
{ value: "0", label: "Disabled" },
{ value: "30000", label: "30 seconds" },
{ value: "60000", label: "1 minute" },
{ value: "120000", label: "2 minutes" },
{ value: "300000", label: "5 minutes" },
],
},
},
// Optional tools
"renderMermaid.enabled": {
@@ -2458,6 +2477,16 @@ export const SETTINGS_SCHEMA = {
},
},
"task.showResolvedModelBadge": {
type: "boolean",
default: false,
ui: {
tab: "appearance",
label: "Show Resolved Model Badge",
description: "Display the actual model ID used by each subagent in the task widget status line",
},
},
// Skills
"skills.enabled": { type: "boolean", default: true },
+5 -5
View File
@@ -408,7 +408,7 @@ export class Settings {
/**
* Get the edit variant for a specific model.
* Returns "patch", "replace", "hashline", "vim", "apply_patch", or null (use global default).
* Returns "patch", "replace", "hashline", "apply_patch", or null (use global default).
*/
getEditVariantForModel(model: string | undefined): EditMode | null {
if (!model) return null;
@@ -643,22 +643,22 @@ export class Settings {
}
}
// edit.mode: removed "atom" variant is now "hashline"
// edit.mode: removed "atom" and "vim" variants map back to "hashline"
const editObj = raw.edit as Record<string, unknown> | undefined;
if (editObj) {
if (editObj.mode === "atom") {
if (editObj.mode === "atom" || editObj.mode === "vim") {
editObj.mode = "hashline";
}
const modelVariants = editObj.modelVariants as Record<string, unknown> | undefined;
if (modelVariants && typeof modelVariants === "object" && !Array.isArray(modelVariants)) {
for (const [pattern, variant] of Object.entries(modelVariants)) {
if (variant === "atom") {
if (variant === "atom" || variant === "vim") {
modelVariants[pattern] = "hashline";
}
}
}
}
if (raw["edit.mode"] === "atom") {
if (raw["edit.mode"] === "atom" || raw["edit.mode"] === "vim") {
raw["edit.mode"] = "hashline";
}
+68 -3
View File
@@ -2,15 +2,56 @@ import { type Component, matchesKey, padding, replaceTabs, truncateToWidth, visi
import { sanitizeText } from "@oh-my-pi/pi-utils";
import { theme } from "../modes/theme/theme";
import { copyToClipboard } from "../utils/clipboard";
import { formatRawSseIsoTime, type RawSseDebugBuffer, rawSseRecordLines } from "./raw-sse-buffer";
import {
formatRawSseIsoTime,
type RawSseDebugBuffer,
type RawSseDebugRecord,
rawSseRecordLines,
} from "./raw-sse-buffer";
const MIN_VIEWER_WIDTH = 20;
const VIEWER_FRAME_LINES = 5;
// `data:` lines below this width render fine on a single row; anything wider gets pretty-printed
// across multiple `data:` lines so streamed JSON blobs stop getting clipped by `truncateToWidth`.
const PRETTY_PRINT_DATA_THRESHOLD = 100;
function sanitizeFrameLine(line: string, width: number): string {
return truncateToWidth(replaceTabs(sanitizeText(line)), width);
}
// Walks the SSE wire lines and replaces single-line `data: <json>` payloads with
// multi-line `data: <indented-json>` entries when the JSON is wide enough to clip.
// Multi-line `data:` is still valid SSE (the spec joins lines with `\n`), so the
// transformed view round-trips back to the same event when copied.
/** @internal Exported for tests. */
export function expandPrettyDataLines(raw: readonly string[]): string[] {
const out: string[] = [];
for (const line of raw) {
if (!line.startsWith("data: ") || line.length <= PRETTY_PRINT_DATA_THRESHOLD) {
out.push(line);
continue;
}
const body = line.slice("data: ".length);
const trimmed = body.trim();
if (trimmed.length === 0 || (trimmed[0] !== "{" && trimmed[0] !== "[")) {
out.push(line);
continue;
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
out.push(line);
continue;
}
const pretty = JSON.stringify(parsed, null, 2);
for (const prettyLine of pretty.split("\n")) {
out.push(`data: ${prettyLine}`);
}
}
return out;
}
export interface RawSseViewerOptions {
buffer: RawSseDebugBuffer;
terminalRows: number;
@@ -30,6 +71,12 @@ export class RawSseViewerComponent implements Component {
#followTail = true;
#lastRenderWidth = MIN_VIEWER_WIDTH;
#statusMessage: string | undefined;
// Pretty-printed wire lines keyed by `record.sequence`. Pretty-printing is
// the JSON.parse + JSON.stringify per `data:` line, so we cache the result —
// the render path runs on every keypress and from `#maxScrollOffset()`.
// Sequences are monotonic; we prune entries below the oldest live record
// after each render so the cache tracks the buffer's eviction window.
readonly #prettyLinesCache = new Map<number, string[]>();
constructor(options: RawSseViewerOptions) {
this.#buffer = options.buffer;
@@ -131,8 +178,9 @@ export class RawSseViewerComponent implements Component {
);
lines.push("");
}
const firstSequence = snapshot.records[0]?.sequence;
for (const record of snapshot.records) {
for (const line of rawSseRecordLines(record)) {
for (const line of this.#prettyLinesFor(record)) {
lines.push(sanitizeFrameLine(line, innerWidth));
}
if (record.kind === "event" && record.truncated) {
@@ -140,14 +188,31 @@ export class RawSseViewerComponent implements Component {
}
lines.push("");
}
if (firstSequence !== undefined) this.#pruneCache(firstSequence);
return lines;
}
#prettyLinesFor(record: RawSseDebugRecord): string[] {
const cached = this.#prettyLinesCache.get(record.sequence);
if (cached) return cached;
const expanded = expandPrettyDataLines(rawSseRecordLines(record));
this.#prettyLinesCache.set(record.sequence, expanded);
return expanded;
}
#pruneCache(firstSequence: number): void {
// Bounded by the buffer eviction rate; with `MAX_RAW_SSE_EVENTS = 1000`
// this rarely runs and only walks freshly-evicted entries.
for (const key of this.#prettyLinesCache.keys()) {
if (key < firstSequence) this.#prettyLinesCache.delete(key);
}
}
#summaryText(): string {
const snapshot = this.#buffer.snapshot();
const last = snapshot.lastUpdatedAt ? ` last=${formatRawSseIsoTime(snapshot.lastUpdatedAt)}` : "";
const follow = this.#followTail ? "follow:on" : "follow:off";
return ` # raw SSE | events=${snapshot.totalEvents} records=${snapshot.records.length}${last} | ${follow} | Esc back Ctrl+C copy End follow`;
return ` # raw provider stream (SSE + WS) | events=${snapshot.totalEvents} records=${snapshot.records.length}${last} | ${follow} | Esc back Ctrl+C copy End follow`;
}
#statusText(): string {
@@ -11,68 +11,101 @@
* Scoped per `ToolSession`: the cache lives on the session object itself, so
* different sessions never share snapshots and entries get reclaimed when
* the session goes out of scope. Each session keeps a small LRU window of
* paths; the cache always reflects what *this* session most recently saw,
* so it stays correct by construction even when this session writes the
* file itself — the next read after the write refreshes the entry.
* paths; each path keeps a short ring of recent snapshots so follow-up edits
* can recover from the agent's own prior writes as well as stale reads.
*/
import { LRUCache } from "lru-cache/raw";
import type { ToolSession } from "../tools";
const MAX_PATHS_PER_SESSION = 30;
const MAX_SNAPSHOTS_PER_PATH = 4;
export interface FileReadSnapshot {
/** 1-indexed line number → exact line content as observed by `read`/`search`. */
lines: Map<number, string>;
/** Full normalized text when the read path observed the whole file. */
fullText?: string;
/** 4-hex hash of `fullText`, or a sparse snapshot hash supplied by search. */
fileHash?: string;
recordedAt: number;
}
interface FileReadSnapshotMetadata {
fullText?: string;
fileHash?: string;
}
export class FileReadCache {
#snapshots = new LRUCache<string, FileReadSnapshot>({ max: MAX_PATHS_PER_SESSION });
#snapshots = new LRUCache<string, FileReadSnapshot[]>({ max: MAX_PATHS_PER_SESSION });
/** Look up the most recent snapshot for `absPath`, or `null` if absent. */
get(absPath: string): FileReadSnapshot | null {
return this.#snapshots.get(absPath) ?? null;
return this.#snapshots.get(absPath)?.[0] ?? null;
}
/** Look up the most recent snapshot for `absPath` whose file hash matches. */
getByHash(absPath: string, fileHash: string): FileReadSnapshot | null {
const history = this.#snapshots.get(absPath);
return history?.find(snapshot => snapshot.fileHash === fileHash) ?? null;
}
/** Record a contiguous run of lines (e.g. from a `read` tool). `startLine` is 1-indexed. */
recordContiguous(absPath: string, startLine: number, lines: readonly string[]): void {
if (lines.length === 0) return;
recordContiguous(
absPath: string,
startLine: number,
lines: readonly string[],
metadata: FileReadSnapshotMetadata = {},
): void {
if (lines.length === 0 && metadata.fullText === undefined) return;
const entries: Array<readonly [number, string]> = lines.map((line, idx) => [startLine + idx, line] as const);
this.#record(absPath, entries);
this.#record(absPath, entries, metadata);
}
/** Record sparse `(lineNumber, content)` pairs (e.g. `search` matches plus context). */
recordSparse(absPath: string, entries: Iterable<readonly [number, string]>): void {
recordSparse(
absPath: string,
entries: Iterable<readonly [number, string]>,
metadata: FileReadSnapshotMetadata = {},
): void {
const arr = Array.from(entries);
if (arr.length === 0) return;
this.#record(absPath, arr);
if (arr.length === 0 && metadata.fullText === undefined) return;
this.#record(absPath, arr, metadata);
}
/** Drop the snapshot for a single path. */
/** Drop the snapshot history for a single path. */
invalidate(absPath: string): void {
this.#snapshots.delete(absPath);
}
/** Drop every snapshot. */
/** Drop every snapshot history. */
clear(): void {
this.#snapshots.clear();
}
#record(absPath: string, entries: ReadonlyArray<readonly [number, string]>): void {
const existing = this.#snapshots.get(absPath);
if (existing && hasConflict(existing.lines, entries)) {
// File content has changed since we last recorded. Drop the stale
// snapshot and start fresh with whatever we just observed.
this.#snapshots.set(absPath, { lines: new Map(entries), recordedAt: Date.now() });
return;
}
if (existing) {
for (const [lineNum, content] of entries) existing.lines.set(lineNum, content);
existing.recordedAt = Date.now();
#record(
absPath: string,
entries: ReadonlyArray<readonly [number, string]>,
metadata: FileReadSnapshotMetadata,
): void {
const history = this.#snapshots.get(absPath) ?? [];
const head = history[0];
const now = Date.now();
if (head && !hasConflict(head.lines, entries) && !hasHashConflict(head, metadata)) {
for (const [lineNum, content] of entries) head.lines.set(lineNum, content);
if (metadata.fullText !== undefined) head.fullText = metadata.fullText;
if (metadata.fileHash !== undefined) head.fileHash = metadata.fileHash;
head.recordedAt = now;
// `get` above already touched LRU recency for this key.
return;
}
this.#snapshots.set(absPath, { lines: new Map(entries), recordedAt: Date.now() });
const nextSnapshot: FileReadSnapshot = {
lines: new Map(entries),
...metadata,
recordedAt: now,
};
const dedupedHistory = history.filter(snapshot => !isSameSnapshotIdentity(snapshot, nextSnapshot));
this.#snapshots.set(absPath, [nextSnapshot, ...dedupedHistory].slice(0, MAX_SNAPSHOTS_PER_PATH));
}
}
@@ -84,6 +117,16 @@ function hasConflict(existing: Map<number, string>, incoming: ReadonlyArray<read
return false;
}
function hasHashConflict(existing: FileReadSnapshot, metadata: FileReadSnapshotMetadata): boolean {
return metadata.fileHash !== undefined && existing.fileHash !== undefined && metadata.fileHash !== existing.fileHash;
}
function isSameSnapshotIdentity(left: FileReadSnapshot, right: FileReadSnapshot): boolean {
if (left.fileHash !== undefined && right.fileHash !== undefined) return left.fileHash === right.fileHash;
if (left.fullText !== undefined && right.fullText !== undefined) return left.fullText === right.fullText;
return false;
}
/**
* Look up (or lazily create) the file-read cache attached to a session. The
* cache is stored as `session.fileReadCache` so it lives exactly as long as
+6 -37
View File
@@ -1,6 +1,5 @@
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import { prompt } from "@oh-my-pi/pi-utils";
import type * as z from "zod/v4";
import {
executeHashlineSingle,
HashlineMismatchError,
@@ -21,9 +20,7 @@ import hashlineDescription from "../prompts/tools/hashline.md" with { type: "tex
import patchDescription from "../prompts/tools/patch.md" with { type: "text" };
import replaceDescription from "../prompts/tools/replace.md" with { type: "text" };
import type { ToolSession } from "../tools";
import { VimTool, vimSchema } from "../tools/vim";
import { type EditMode, normalizeEditMode, resolveEditMode } from "../utils/edit-mode";
import type { VimToolDetails } from "../vim/types";
import { type ApplyPatchParams, applyPatchSchema, expandApplyPatchToEntries } from "./modes/apply-patch";
import applyPatchGrammar from "./modes/apply-patch.lark" with { type: "text" };
import { executePatchSingle, type PatchEditEntry, type PatchParams, patchEditSchema } from "./modes/patch";
@@ -35,7 +32,7 @@ export * from "./apply-patch";
export * from "./diff";
export * from "./file-read-cache";
// Resolve the `$HFMT$`, `$HOP_*$`, `$HOP_CHARS$`, and `$HFILE$` placeholders in the hashline Lark grammar.
// Resolve hashline grammar placeholders from the TypeScript constants.
const hashlineGrammar = resolveHashlineGrammarPlaceholders(hashlineGrammarTemplate);
export * from "../hashline";
@@ -50,12 +47,9 @@ type TInput =
| typeof replaceEditSchema
| typeof patchEditSchema
| typeof hashlineEditParamsSchema
| typeof vimSchema
| typeof applyPatchSchema;
type VimParams = z.infer<typeof vimSchema>;
type EditParams = ReplaceParams | PatchParams | HashlineParams | VimParams | ApplyPatchParams;
type EditToolResultDetails = EditToolDetails | VimToolDetails;
type EditParams = ReplaceParams | PatchParams | HashlineParams | ApplyPatchParams;
type EditModeDefinition = {
description: (session: ToolSession) => string;
@@ -65,8 +59,8 @@ type EditModeDefinition = {
params: EditParams,
signal: AbortSignal | undefined,
batchRequest: LspBatchRequest | undefined,
onUpdate?: (partialResult: AgentToolResult<EditToolResultDetails, TInput>) => void,
) => Promise<AgentToolResult<EditToolResultDetails, TInput>>;
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
) => Promise<AgentToolResult<EditToolDetails, TInput>>;
};
function resolveConfiguredEditMode(rawEditMode: string): EditMode | undefined {
@@ -284,7 +278,6 @@ export class EditTool implements AgentTool<TInput> {
readonly #fuzzyThreshold: number;
readonly #writethrough: WritethroughCallback;
readonly #editMode?: EditMode;
readonly #vimTool: VimTool;
readonly #pendingDeferredFetches = new Map<string, AbortController>();
constructor(private readonly session: ToolSession) {
@@ -298,7 +291,6 @@ export class EditTool implements AgentTool<TInput> {
this.#allowFuzzy = resolveAllowFuzzy(session, editFuzzy);
this.#fuzzyThreshold = resolveFuzzyThreshold(session, editFuzzyThreshold);
this.#writethrough = createEditWritethrough(session);
this.#vimTool = new VimTool(session);
}
get mode(): EditMode {
@@ -341,9 +333,9 @@ export class EditTool implements AgentTool<TInput> {
_toolCallId: string,
params: EditParams,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<EditToolResultDetails, TInput>,
onUpdate?: AgentToolUpdateCallback<EditToolDetails, TInput>,
context?: AgentToolContext,
): Promise<AgentToolResult<EditToolResultDetails, TInput>> {
): Promise<AgentToolResult<EditToolDetails, TInput>> {
const modeDefinition = this.#getModeDefinition();
return modeDefinition.execute(this, params, signal, getLspBatchRequest(context?.toolCall), onUpdate);
}
@@ -460,29 +452,6 @@ export class EditTool implements AgentTool<TInput> {
return executeSinglePathEntries(path, runs, batchRequest, onUpdate);
},
},
vim: {
description: () => this.#vimTool.description,
parameters: vimSchema,
execute: async (
tool: EditTool,
params: EditParams,
signal: AbortSignal | undefined,
_batchRequest: LspBatchRequest | undefined,
onUpdate?: (partialResult: AgentToolResult<EditToolResultDetails, TInput>) => void,
) => {
const handleUpdate = onUpdate
? (partialResult: AgentToolResult<VimToolDetails>) => {
onUpdate(partialResult as AgentToolResult<EditToolResultDetails, TInput>);
}
: undefined;
return (await tool.#vimTool.execute(
"edit",
params as VimParams,
signal,
handleUpdate,
)) as AgentToolResult<EditToolResultDetails, TInput>;
},
},
}[this.mode];
}
+8 -46
View File
@@ -25,10 +25,8 @@ import {
shortenPath,
truncateDiffByHunk,
} from "../tools/render-utils";
import { type VimRenderArgs, vimToolRenderer } from "../tools/vim";
import { fileHyperlink, Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui";
import type { EditMode } from "../utils/edit-mode";
import type { VimToolDetails } from "../vim/types";
import type { DiffError, DiffResult } from "./diff";
import { type ApplyPatchEntry, expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch";
import type { Operation } from "./modes/patch";
@@ -127,31 +125,6 @@ interface ApplyPatchRenderSummary {
error?: string;
}
function isVimRenderArgs(args: EditRenderArgs | VimRenderArgs): args is VimRenderArgs {
return (
typeof args === "object" &&
args !== null &&
typeof (args as { file?: unknown }).file === "string" &&
!("path" in args) &&
!("file_path" in args) &&
!("edits" in args)
);
}
function isVimToolDetails(details: unknown): details is VimToolDetails {
if (!details || typeof details !== "object" || Array.isArray(details)) {
return false;
}
const cursor = (details as { cursor?: unknown }).cursor;
const viewportLines = (details as { viewportLines?: unknown }).viewportLines;
return (
typeof (details as { file?: unknown }).file === "string" &&
typeof cursor === "object" &&
cursor !== null &&
Array.isArray(viewportLines)
);
}
/** Extended context for edit tool rendering */
export interface EditRenderContext {
/** Edit mode resolved by the caller; lets the renderer dispatch without shape-sniffing */
@@ -332,13 +305,15 @@ const MISSING_APPLY_PATCH_END_ERROR = "The last line of the patch must be '*** E
function normalizeHashlineInputPreviewPath(rawPath: string): string {
const trimmed = rawPath.trim();
if (trimmed.length < 2) return trimmed;
const first = trimmed[0];
const last = trimmed[trimmed.length - 1];
const hashStart = /#[0-9a-f]{4}$/u.exec(trimmed)?.index;
const withoutHash = hashStart === undefined ? trimmed : trimmed.slice(0, hashStart);
if (withoutHash.length < 2) return withoutHash;
const first = withoutHash[0];
const last = withoutHash[withoutHash.length - 1];
if ((first === '"' || first === "'") && first === last) {
return trimmed.slice(1, -1);
return withoutHash.slice(1, -1);
}
return trimmed;
return withoutHash;
}
function parseHashlineInputPreviewHeader(line: string): string | null {
@@ -460,16 +435,11 @@ export const editToolRenderer = {
mergeCallAndResult: true,
renderCall(
args: EditRenderArgs | VimRenderArgs,
args: EditRenderArgs,
options: RenderResultOptions & { renderContext?: EditRenderContext },
uiTheme: Theme,
): Component {
const renderContext = options.renderContext;
// Dispatch on the explicit editMode when available; fall back to the
// shape probe for legacy call sites that don't thread renderContext.
if (renderContext?.editMode === "vim" || isVimRenderArgs(args)) {
return vimToolRenderer.renderCall(args as VimRenderArgs, options, uiTheme);
}
const editArgs = args as EditRenderArgs;
const hashlineInputSummary = getHashlineInputRenderSummary(editArgs, renderContext?.editMode);
@@ -514,14 +484,6 @@ export const editToolRenderer = {
uiTheme: Theme,
args?: EditRenderArgs,
): Component {
if (options.renderContext?.editMode === "vim" || isVimToolDetails(result.details)) {
return vimToolRenderer.renderResult(
result as { content: Array<{ type: string; text?: string }>; details?: VimToolDetails; isError?: boolean },
options,
uiTheme,
);
}
const perFileResults = result.details?.perFileResults;
const totalFiles = args?.edits ? countEditFiles(args.edits) : 0;
if (perFileResults && (perFileResults.length > 1 || totalFiles > 1)) {
+52 -54
View File
@@ -22,8 +22,7 @@ import {
containsRecognizableHashlineOperations,
END_PATCH_MARKER,
type HashlineInputSection,
HL_FILE_PREFIX,
isHashlineOpLineText,
HashlineTokenizer,
splitHashlineInputs,
} from "../hashline";
import type { Theme } from "../modes/theme/theme";
@@ -78,39 +77,25 @@ export interface EditStreamingStrategy<Args = unknown> {
const STREAMING_FALLBACK_LINES = 12;
const STREAMING_FALLBACK_WIDTH = 80;
function isHashlineHeaderLine(line: string): boolean {
return line.trimEnd().startsWith(HL_FILE_PREFIX);
}
function parseHashlineHeaderPath(line: string): string {
const trimmed = line.trimEnd();
let prefixEnd = 0;
while (prefixEnd < trimmed.length && trimmed[prefixEnd] === HL_FILE_PREFIX) prefixEnd++;
return trimmed.slice(prefixEnd).trim();
}
function isHashlineOpLine(line: string): boolean {
return isHashlineOpLineText(line);
}
function isHashlineEnvelopeMarkerLine(line: string): boolean {
const trimmed = line.trimEnd();
return trimmed === BEGIN_PATCH_MARKER || trimmed === END_PATCH_MARKER || trimmed === ABORT_MARKER;
}
// Streaming-preview classification reuses one tokenizer instance for the
// stateless predicates and `tokenize`/`tokenizeAll` helpers; instances are
// cheap, but keeping a single module-level reference matches the rest of
// the hashline package.
const HASHLINE_TOKENIZER = new HashlineTokenizer();
function trimHashlineStreamingSyntax(lines: string[]): string[] {
let index = lines.findIndex(line => line.trim().length > 0);
if (index === -1) return [];
if (lines[index].trimEnd() === BEGIN_PATCH_MARKER) {
if (HASHLINE_TOKENIZER.tokenize(lines[index]).kind === "envelope-begin") {
index++;
while (index < lines.length && lines[index].trim().length === 0) index++;
}
if (index < lines.length && isHashlineHeaderLine(lines[index])) {
if (index < lines.length && HASHLINE_TOKENIZER.tokenize(lines[index]).kind === "header") {
index++;
}
return lines.slice(index).filter(line => !isHashlineEnvelopeMarkerLine(line));
return lines.slice(index).filter(line => !HASHLINE_TOKENIZER.isEnvelopeMarker(line));
}
function renderHashlineInputFallback(input: string, uiTheme: Theme): string {
@@ -380,32 +365,61 @@ function buildHashlineNaturalOrderPreviews(
input: string,
defaultPath: string | undefined,
): PerFileDiffPreview[] | null {
const lines = input.split("\n");
const groups = new Map<string, string[]>();
let currentPath = defaultPath ?? "";
const ensure = (path: string): string[] => {
let bucket = groups.get(path);
const ensure = (sectionPath: string): string[] => {
let bucket = groups.get(sectionPath);
if (!bucket) {
bucket = [];
groups.set(path, bucket);
groups.set(sectionPath, bucket);
}
return bucket;
};
for (const raw of lines) {
if (isHashlineEnvelopeMarkerLine(raw)) continue;
if (isHashlineHeaderLine(raw)) {
currentPath = parseHashlineHeaderPath(raw);
if (currentPath) ensure(currentPath);
continue;
// Per-call instance: the streaming preview re-runs each tick with the
// cumulative input, and we need the line counter to start at 1. A
// dedicated tokenizer keeps the shared HASHLINE_TOKENIZER above free
// for stateless predicate use elsewhere in this module.
const streamer = new HashlineTokenizer();
for (const token of streamer.tokenizeAll(input)) {
switch (token.kind) {
case "envelope-begin":
case "envelope-end":
case "abort":
case "op-delete":
continue;
case "header":
currentPath = token.path;
if (currentPath) ensure(currentPath);
continue;
case "op-insert":
case "op-replace":
// Inline body on the op line itself (`N↓payload`, `A-B:payload`) is
// payload content that just happens to share a line with the op
// header — render it the same as a standalone payload token so
// the very first character the model types after the sigil shows
// up in the streaming preview. Without this, the preview is
// empty until a newline arrives, and the renderer falls back to
// raw input ("A-B: bla bla bla") instead of "+ bla bla bla".
if (!currentPath || token.inlineBody === undefined) continue;
ensure(currentPath).push(`+${token.inlineBody}`);
continue;
case "blank":
if (!currentPath) continue;
ensure(currentPath).push("+");
continue;
case "payload":
if (!currentPath) continue;
ensure(currentPath).push(`+${token.text}`);
continue;
}
if (isHashlineOpLine(raw) || !currentPath) continue;
ensure(currentPath).push(`+${raw}`);
}
if (groups.size === 0) return null;
const previews: PerFileDiffPreview[] = [];
for (const [path, body] of groups) {
for (const [sectionPath, body] of groups) {
if (body.length === 0) continue;
previews.push({ path, diff: body.join("\n") });
previews.push({ path: sectionPath, diff: body.join("\n") });
}
return previews.length > 0 ? previews : null;
}
@@ -523,27 +537,11 @@ const applyPatchStrategy: EditStreamingStrategy<ApplyPatchArgs> = {
return "";
},
};
// Vim streaming preview is handled by the existing vimToolRenderer inside
// edit/renderer.ts. The strategy here is a no-op so the registry is total.
const vimStrategy: EditStreamingStrategy<unknown> = {
extractCompleteEdits(args) {
return args;
},
async computeDiffPreview() {
return null;
},
renderStreamingFallback() {
return "";
},
};
export const EDIT_MODE_STRATEGIES: Record<EditMode, EditStreamingStrategy<unknown>> = {
replace: replaceStrategy as EditStreamingStrategy<unknown>,
patch: patchStrategy as EditStreamingStrategy<unknown>,
hashline: hashlineStrategy as EditStreamingStrategy<unknown>,
apply_patch: applyPatchStrategy as EditStreamingStrategy<unknown>,
vim: vimStrategy,
};
export { resolveEditMode };
@@ -0,0 +1,520 @@
import { afterAll, afterEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
import { TempDir } from "@oh-my-pi/pi-utils";
import type { ModelRegistry } from "../../config/model-registry";
import { Settings } from "../../config/settings";
import type { LoadExtensionsResult } from "../../extensibility/extensions/types";
import type { CreateAgentSessionOptions, CreateAgentSessionResult } from "../../sdk";
import * as sdkModule from "../../sdk";
import type { AgentSession, AgentSessionEvent, PromptOptions } from "../../session/agent-session";
import { TaskTool } from "../../task";
import * as discoveryModule from "../../task/discovery";
import type { AgentDefinition, TaskParams } from "../../task/types";
import type { ToolSession } from "../../tools";
import { EventBus } from "../../utils/event-bus";
import { disposeAllVmContexts } from "../js/context-manager";
import { executeJs } from "../js/executor";
import { disposeAllKernelSessions, executePython } from "../py/executor";
function createToolSession(cwd: string, sessionFile: string | null, evalSessionId?: string): ToolSession {
const modelRegistry = {
authStorage: undefined,
refresh: async () => {},
getAvailable: () => [],
getApiKey: async () => null,
} as unknown as ModelRegistry;
return {
cwd,
hasUI: false,
settings: Settings.isolated({
"async.enabled": false,
"task.isolation.mode": "none",
}),
getSessionFile: () => sessionFile,
getSessionSpawns: () => "*",
getEvalSessionId: evalSessionId ? () => evalSessionId : undefined,
modelRegistry,
} as unknown as ToolSession;
}
function createBridgeToolSession(resultText: string, calls: unknown[]): ToolSession {
const readTool = {
name: "read",
label: "read",
description: "read",
parameters: { type: "object" },
async execute(_id: string, args: unknown) {
calls.push(args);
return { content: [{ type: "text" as const, text: resultText }] };
},
};
const tools = new Map<string, unknown>([["read", readTool]]);
return { getToolByName: (name: string) => tools.get(name) } as unknown as ToolSession;
}
function assistantStopMessage(text: string): AssistantMessage {
return {
role: "assistant",
content: text ? [{ type: "text", text }] : [],
api: "openai-responses",
provider: "openai",
model: "mock",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
}
function createYieldingSubagentSession(onPrompt: () => Promise<void>): AgentSession {
const listeners: Array<(event: AgentSessionEvent) => void> = [];
const state = { messages: [] as AssistantMessage[] };
const emit = (event: AgentSessionEvent) => {
for (const listener of listeners) listener(event);
};
return {
state,
agent: { state: { systemPrompt: ["test"] } },
model: undefined,
extensionRunner: undefined,
sessionManager: {
appendSessionInit: () => {},
},
getActiveToolNames: () => ["eval", "yield"],
setActiveToolsByName: async () => {},
subscribe: (listener: (event: AgentSessionEvent) => void) => {
listeners.push(listener);
return () => {
const index = listeners.indexOf(listener);
if (index >= 0) listeners.splice(index, 1);
};
},
prompt: async (_text: string, _options?: PromptOptions) => {
await onPrompt();
state.messages.push(assistantStopMessage("done"));
emit({
type: "tool_execution_end",
toolCallId: "yield-call",
toolName: "yield",
result: {
content: [{ type: "text", text: "Result submitted." }],
details: { status: "success", data: { ok: true } },
},
isError: false,
});
},
waitForIdle: async () => {},
getLastAssistantMessage: () => state.messages[state.messages.length - 1],
abort: async () => {},
dispose: async () => {},
} as unknown as AgentSession;
}
const taskAgent: AgentDefinition = {
name: "task",
description: "Task agent",
systemPrompt: "Read eval state and yield.",
source: "bundled",
tools: ["eval", "yield"],
};
const taskParams: TaskParams = {
agent: "task",
tasks: [{ id: "ReadEval", description: "Read eval state", assignment: "Read parent eval state." }],
};
describe("shared eval executors", () => {
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(async () => {
await disposeAllVmContexts();
await disposeAllKernelSessions();
});
it("shares JavaScript state across executeJs calls with one session id", async () => {
using tempDir = TempDir.createSync("@omp-eval-js-shared-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `js-shared:${crypto.randomUUID()}`;
const session = createToolSession(tempDir.path(), sessionFile);
await executeJs("globalThis.x = 41;", { sessionId, session, sessionFile });
const result = await executeJs("return globalThis.x + 1;", { sessionId, session, sessionFile });
expect(result.exitCode).toBe(0);
expect(result.output.trim()).toBe("42");
});
it("shares Python state across executePython calls with one session id", async () => {
using tempDir = TempDir.createSync("@omp-eval-py-shared-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `py-shared:${crypto.randomUUID()}`;
await executePython("x = 41", { cwd: tempDir.path(), sessionId, sessionFile });
const result = await executePython("print(x + 1)", { cwd: tempDir.path(), sessionId, sessionFile });
expect(result.exitCode).toBe(0);
expect(result.output.trim()).toBe("42");
});
it("deduplicates concurrent first JavaScript session acquisition", async () => {
using tempDir = TempDir.createSync("@omp-eval-js-cold-start-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `js-cold-start:${crypto.randomUUID()}`;
const session = createToolSession(tempDir.path(), sessionFile);
const [first, second] = await Promise.all([
executeJs(
"globalThis.sharedMarker ??= crypto.randomUUID(); await Bun.sleep(50); return globalThis.sharedMarker;",
{
sessionId,
session,
sessionFile,
},
),
executeJs("globalThis.sharedMarker ??= crypto.randomUUID(); return globalThis.sharedMarker;", {
sessionId,
session,
sessionFile,
}),
]);
const third = await executeJs("return globalThis.sharedMarker;", { sessionId, session, sessionFile });
expect(first.exitCode).toBe(0);
expect(second.exitCode).toBe(0);
expect(third.exitCode).toBe(0);
expect(first.output.trim()).toBe(second.output.trim());
expect(third.output.trim()).toBe(first.output.trim());
});
it("deduplicates concurrent first Python session acquisition", async () => {
using tempDir = TempDir.createSync("@omp-eval-py-cold-start-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `py-cold-start:${crypto.randomUUID()}`;
const [first, second] = await Promise.all([
executePython(
`import asyncio, uuid
shared_marker = globals().get("shared_marker") or str(uuid.uuid4())
globals()["shared_marker"] = shared_marker
await asyncio.sleep(0.05)
print(shared_marker)`,
{ cwd: tempDir.path(), sessionId, sessionFile },
),
executePython(
`import uuid
shared_marker = globals().get("shared_marker") or str(uuid.uuid4())
globals()["shared_marker"] = shared_marker
print(shared_marker)`,
{ cwd: tempDir.path(), sessionId, sessionFile },
),
]);
const third = await executePython("print(shared_marker)", { cwd: tempDir.path(), sessionId, sessionFile });
expect(first.exitCode).toBe(0);
expect(second.exitCode).toBe(0);
expect(third.exitCode).toBe(0);
expect(first.output.trim()).toBe(second.output.trim());
expect(third.output.trim()).toBe(first.output.trim());
});
it("splits retained Python kernels by cwd for one shared session id", async () => {
using tempDir = TempDir.createSync("@omp-eval-py-cwd-");
const dirA = path.join(tempDir.path(), "a");
const dirB = path.join(tempDir.path(), "b");
await fs.mkdir(dirA);
await fs.mkdir(dirB);
const realDirA = await fs.realpath(dirA);
const realDirB = await fs.realpath(dirB);
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `py-cwd:${crypto.randomUUID()}`;
const first = await executePython(
`import os
token = "from-a"
print(os.getcwd())`,
{
cwd: dirA,
sessionId,
sessionFile,
},
);
const second = await executePython(
`import os
print(os.getcwd())
print("token" in globals())`,
{
cwd: dirB,
sessionId,
sessionFile,
},
);
const third = await executePython("print(token)", { cwd: dirA, sessionId, sessionFile });
expect(first.exitCode).toBe(0);
expect(first.output.trim()).toBe(realDirA);
expect(second.exitCode).toBe(0);
expect(second.output.trim().split("\n")).toEqual([realDirB, "False"]);
expect(third.exitCode).toBe(0);
expect(third.output.trim()).toBe("from-a");
});
it("interrupts timed out synchronous Python cells before they mutate shared state", async () => {
using tempDir = TempDir.createSync("@omp-eval-py-sync-timeout-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `py-sync-timeout:${crypto.randomUUID()}`;
const timedOut = await executePython("import time\ntime.sleep(0.2)\nleaked_after_timeout = True", {
cwd: tempDir.path(),
sessionId,
sessionFile,
timeoutMs: 20,
});
await Bun.sleep(250);
const probe = await executePython('print("leaked_after_timeout" in globals())', {
cwd: tempDir.path(),
sessionId,
sessionFile,
});
expect(timedOut.cancelled).toBe(true);
expect(probe.exitCode).toBe(0);
expect(probe.output.trim()).toBe("False");
});
it("settles Python cells that raise SystemExit", async () => {
using tempDir = TempDir.createSync("@omp-eval-py-system-exit-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `py-system-exit:${crypto.randomUUID()}`;
const result = await executePython('raise SystemExit("bye")', {
cwd: tempDir.path(),
sessionId,
sessionFile,
timeoutMs: 500,
});
expect(result.exitCode).toBe(1);
expect(result.output).toContain("SystemExit");
expect(result.output).toContain("bye");
});
it("lets a subagent inherit parent JavaScript and Python eval state", async () => {
using tempDir = TempDir.createSync("@omp-eval-subagent-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const evalSessionId = `session:${sessionFile}:cwd:${tempDir.path()}`;
const parentSession = createToolSession(tempDir.path(), sessionFile, evalSessionId);
let seenJs = "";
let seenPy = "";
let capturedOptions: CreateAgentSessionOptions | undefined;
await executeJs('globalThis.parentSecret = "hello-js";', {
sessionId: `js:${evalSessionId}`,
session: parentSession,
sessionFile,
});
await executePython('parent_secret = "hello-py"', {
cwd: tempDir.path(),
sessionId: `python:${evalSessionId}`,
sessionFile,
});
vi.spyOn(discoveryModule, "discoverAgents").mockResolvedValue({ agents: [taskAgent], projectAgentsDir: null });
vi.spyOn(sdkModule, "createAgentSession").mockImplementation(async (options = {}) => {
capturedOptions = options;
const inherited = options.parentEvalSessionId;
if (!inherited) throw new Error("Missing parent eval session id");
return {
session: createYieldingSubagentSession(async () => {
const jsResult = await executeJs("return globalThis.parentSecret;", {
sessionId: `js:${inherited}`,
session: parentSession,
sessionFile,
});
const pyResult = await executePython("print(parent_secret)", {
cwd: tempDir.path(),
sessionId: `python:${inherited}`,
sessionFile,
});
seenJs = jsResult.output.trim();
seenPy = pyResult.output.trim();
}),
extensionsResult: {} as unknown as LoadExtensionsResult,
setToolUIContext: () => {},
eventBus: new EventBus(),
} satisfies CreateAgentSessionResult;
});
const tool = await TaskTool.create(parentSession);
await tool.execute("tool-call", taskParams);
expect(capturedOptions?.parentEvalSessionId).toBe(evalSessionId);
expect(seenJs).toBe("hello-js");
expect(seenPy).toBe("hello-py");
});
it("routes interleaved JavaScript display output to the matching run", async () => {
using tempDir = TempDir.createSync("@omp-eval-js-interleave-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `js-interleave:${crypto.randomUUID()}`;
const session = createToolSession(tempDir.path(), sessionFile);
const first = executeJs('await Bun.sleep(80); display({ label: "A" });', {
sessionId,
session,
sessionFile,
});
await Bun.sleep(10);
const second = executeJs('display({ label: "B" });', {
sessionId,
session,
sessionFile,
});
const [firstResult, secondResult] = await Promise.all([first, second]);
expect(firstResult.exitCode).toBe(0);
expect(secondResult.exitCode).toBe(0);
expect(firstResult.displayOutputs).toEqual([{ type: "json", data: { label: "A" } }]);
expect(secondResult.displayOutputs).toEqual([{ type: "json", data: { label: "B" } }]);
});
it("routes interleaved Python display output to the matching run", async () => {
using tempDir = TempDir.createSync("@omp-eval-py-interleave-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `py-interleave:${crypto.randomUUID()}`;
const first = executePython(
`import asyncio
await asyncio.sleep(0.08)
display({"label": "A"})`,
{
cwd: tempDir.path(),
sessionId,
sessionFile,
},
);
await Bun.sleep(10);
const second = executePython('display({"label": "B"})', {
cwd: tempDir.path(),
sessionId,
sessionFile,
});
const [firstResult, secondResult] = await Promise.all([first, second]);
expect(firstResult.exitCode).toBe(0);
expect(secondResult.exitCode).toBe(0);
expect(firstResult.displayOutputs).toEqual([{ type: "json", data: { label: "A" } }]);
expect(secondResult.displayOutputs).toEqual([{ type: "json", data: { label: "B" } }]);
});
it("preserves module-level singleton state across re-imports of an unchanged file", async () => {
using tempDir = TempDir.createSync("@omp-eval-js-mtime-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `js-mtime:${crypto.randomUUID()}`;
const session = createToolSession(tempDir.path(), sessionFile);
const modulePath = path.join(tempDir.path(), "singleton.ts");
const moduleSpec = JSON.stringify(modulePath);
await Bun.write(
modulePath,
"let value = 0;\nexport function set(v) { value = v; }\nexport function get() { return value; }\n",
);
const initResult = await executeJs(`const mod = await import(${moduleSpec}); mod.set(42); return mod.get();`, {
sessionId,
session,
sessionFile,
});
expect(initResult.exitCode).toBe(0);
expect(initResult.output.trim()).toBe("42");
// Unchanged file: re-import must reuse the existing module namespace so the
// counter is still 42. This is the regression — the previous unconditional
// `delete require.cache[target]` reset singletons on every dynamic import.
const reuseResult = await executeJs(`const mod = await import(${moduleSpec}); return mod.get();`, {
sessionId,
session,
sessionFile,
});
expect(reuseResult.exitCode).toBe(0);
expect(reuseResult.output.trim()).toBe("42");
// Bump mtime by 5s to simulate an edit; the next import must evict the cache
// and re-evaluate the file, dropping the counter back to its initializer.
const future = new Date(Date.now() + 5_000);
await fs.utimes(modulePath, future, future);
const reloadResult = await executeJs(`const mod = await import(${moduleSpec}); return mod.get();`, {
sessionId,
session,
sessionFile,
});
expect(reloadResult.exitCode).toBe(0);
expect(reloadResult.output.trim()).toBe("0");
});
it("reloads a local re-export when a transitive dependency changes", async () => {
using tempDir = TempDir.createSync("@omp-eval-js-transitive-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `js-transitive:${crypto.randomUUID()}`;
const session = createToolSession(tempDir.path(), sessionFile);
const leafPath = path.join(tempDir.path(), "leaf.ts");
const entryPath = path.join(tempDir.path(), "entry.ts");
const entrySpec = JSON.stringify(entryPath);
await Bun.write(leafPath, "export const value = 1;\n");
await Bun.write(entryPath, 'export { value } from "./leaf.ts";\n');
const initial = await executeJs(`const mod = await import(${entrySpec}); return mod.value;`, {
sessionId,
session,
sessionFile,
});
expect(initial.exitCode).toBe(0);
expect(initial.output.trim()).toBe("1");
await Bun.write(leafPath, "export const value = 2;\n");
const future = new Date(Date.now() + 5_000);
await fs.utimes(leafPath, future, future);
const reloaded = await executeJs(`const mod = await import(${entrySpec}); return mod.value;`, {
sessionId,
session,
sessionFile,
});
expect(reloaded.exitCode).toBe(0);
expect(reloaded.output.trim()).toBe("2");
});
it("refreshes the Python tool proxy when bridge env appears after kernel warm-up", async () => {
using tempDir = TempDir.createSync("@omp-eval-py-tool-proxy-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `py-tool-proxy:${crypto.randomUUID()}`;
const bridgeCalls: unknown[] = [];
const bridgeSession = createBridgeToolSession("bridge-ok", bridgeCalls);
const withoutBridge = await executePython(
'try:\n print(tool.read({"path": "foo.txt"}))\nexcept Exception as exc:\n print(type(exc).__name__)\n print(str(exc))',
{ cwd: tempDir.path(), sessionId, sessionFile },
);
const withBridge = await executePython('print(tool.read({"path": "foo.txt"}))', {
cwd: tempDir.path(),
sessionId,
sessionFile,
toolSession: bridgeSession,
});
expect(withoutBridge.exitCode).toBe(0);
expect(withoutBridge.output).toContain("RuntimeError");
expect(withoutBridge.output).toContain("tool bridge is unavailable");
expect(withBridge.exitCode).toBe(0);
expect(withBridge.output.trim()).toBe("bridge-ok");
expect(bridgeCalls).toEqual([{ path: "foo.txt", _i: "py prelude" }]);
});
});
@@ -48,10 +48,11 @@ interface JsSession {
worker: WorkerHandle;
state: "alive" | "dead";
pending: Map<string, PendingRun>;
queue: Promise<void>;
}
const sessions = new Map<string, JsSession>();
const startingSessions = new Map<string, Promise<JsSession>>();
const resettingSessions = new Set<string>();
const READY_TIMEOUT_MS_DEFAULT = 5_000;
export async function executeInVmContext(options: {
@@ -66,45 +67,46 @@ export async function executeInVmContext(options: {
runState: VmRunState;
}): Promise<{ value: unknown }> {
if (options.reset) {
await resetVmContext(options.sessionKey);
if (resettingSessions.has(options.sessionKey)) {
throw new ToolError("JS context reset already in progress");
}
resettingSessions.add(options.sessionKey);
try {
await resetVmContext(options.sessionKey);
} finally {
resettingSessions.delete(options.sessionKey);
}
} else if (resettingSessions.has(options.sessionKey)) {
throw new ToolError("JS context reset in progress");
}
const session = await acquireSession(
options.sessionKey,
{ cwd: options.cwd, sessionId: options.sessionId },
options.timeoutMs,
);
return await runQueued(session, () => runOnce(session, options));
return await runOnce(session, options);
}
export async function resetVmContext(sessionKey: string): Promise<void> {
const session = sessions.get(sessionKey);
const session = sessions.get(sessionKey) ?? (await startingSessions.get(sessionKey)?.catch(() => undefined));
if (!session) return;
sessions.delete(sessionKey);
await killSession(session, new ToolError("JS context reset"));
}
export async function disposeAllVmContexts(): Promise<void> {
const pending = [...startingSessions.values()];
startingSessions.clear();
const started = await Promise.allSettled(pending);
const all = [...sessions.values()];
for (const result of started) {
if (result.status !== "fulfilled") continue;
if (!all.includes(result.value)) all.push(result.value);
}
sessions.clear();
await Promise.all(all.map(session => killSession(session, new ToolError("JS context disposed"))));
}
async function runQueued<T>(session: JsSession, work: () => Promise<T>): Promise<T> {
const previous = session.queue;
const { promise, resolve } = Promise.withResolvers<void>();
session.queue = promise;
try {
await previous;
} catch {
// Previous run's failure must not poison this one.
}
try {
return await work();
} finally {
resolve();
}
}
async function runOnce(
session: JsSession,
options: {
@@ -162,43 +164,52 @@ async function runOnce(
async function acquireSession(sessionKey: string, snapshot: SessionSnapshot, timeoutMs?: number): Promise<JsSession> {
const existing = sessions.get(sessionKey);
if (existing && existing.state === "alive") return existing;
const starting = startingSessions.get(sessionKey);
if (starting) return await starting;
const worker = await spawnJsWorker();
const session: JsSession = {
sessionKey,
worker,
state: "alive",
pending: new Map(),
queue: Promise.resolve(),
};
const { promise: readyPromise, resolve: resolveReady, reject: rejectReady } = Promise.withResolvers<void>();
let resolved = false;
const unsubscribe = worker.onMessage(msg => {
if (!resolved && msg.type === "ready") {
resolved = true;
resolveReady();
return;
const startup = (async (): Promise<JsSession> => {
const worker = await spawnJsWorker();
const session: JsSession = {
sessionKey,
worker,
state: "alive",
pending: new Map(),
};
const { promise: readyPromise, resolve: resolveReady, reject: rejectReady } = Promise.withResolvers<void>();
let resolved = false;
const unsubscribe = worker.onMessage(msg => {
if (!resolved && msg.type === "ready") {
resolved = true;
resolveReady();
return;
}
if (!resolved && msg.type === "init-failed") {
resolved = true;
rejectReady(errorFromPayload(msg.error));
return;
}
handleSessionMessage(session, msg);
});
try {
// Cold-start can exceed 5s on slow hosts. Let the caller's per-cell timeout dominate so
// users can grant more headroom when they raise `timeout` on a cell.
const readyTimeoutMs = Math.max(READY_TIMEOUT_MS_DEFAULT, timeoutMs ?? 0);
await raceWithTimeout(readyPromise, readyTimeoutMs, "Timed out initializing JS eval worker");
worker.send({ type: "init", snapshot });
sessions.set(sessionKey, session);
return session;
} catch (error) {
unsubscribe();
await worker.terminate().catch(() => undefined);
throw error;
}
if (!resolved && msg.type === "init-failed") {
resolved = true;
rejectReady(errorFromPayload(msg.error));
return;
}
handleSessionMessage(session, msg);
});
})();
startingSessions.set(sessionKey, startup);
try {
// Cold-start can exceed 5s on slow hosts. Let the caller's per-cell timeout dominate so
// users can grant more headroom when they raise `timeout` on a cell.
const readyTimeoutMs = Math.max(READY_TIMEOUT_MS_DEFAULT, timeoutMs ?? 0);
await raceWithTimeout(readyPromise, readyTimeoutMs, "Timed out initializing JS eval worker");
} catch (error) {
unsubscribe();
await worker.terminate().catch(() => undefined);
throw error;
return await startup;
} finally {
if (startingSessions.get(sessionKey) === startup) startingSessions.delete(sessionKey);
}
worker.send({ type: "init", snapshot });
sessions.set(sessionKey, session);
return session;
}
function handleSessionMessage(session: JsSession, msg: WorkerOutbound): void {
@@ -0,0 +1,265 @@
import * as fs from "node:fs";
import { createRequire } from "node:module";
import * as path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import * as vm from "node:vm";
import { collectModuleSourceSpecifiers, stripTypeScriptSyntax } from "./rewrite-imports";
interface LocalModuleEntry {
version: number;
identifier: string;
module: vm.SourceTextModule;
}
export type LocalImportResolution = { mode: "local"; value: unknown } | { mode: "external"; target: string };
const LOCAL_MODULE_EXTENSIONS = new Set([".js", ".jsx", ".mjs", ".ts", ".tsx", ".mts"]);
export class LocalModuleLoader {
#context: vm.Context;
#sessionTag: string;
#moduleMtimes = new Map<string, number>();
#moduleDeps = new Map<string, Set<string>>();
#moduleParents = new Map<string, Set<string>>();
#moduleVersions = new Map<string, number>();
#moduleEntries = new Map<string, LocalModuleEntry>();
#moduleBuilds = new Map<string, Promise<LocalModuleEntry>>();
#externalModules = new Map<string, Promise<vm.Module>>();
#requireCache = new Map<string, NodeJS.Require>();
constructor(sessionId: string) {
this.#context = vm.createContext(globalThis);
this.#sessionTag = Bun.hash(sessionId).toString(16);
}
async resolveForRun(cwd: string, source: string): Promise<LocalImportResolution> {
this.#refreshTrackedLocalModules();
return await this.#resolveFromBase(cwd, source);
}
async resolveForModule(moduleUrl: string, source: string, cwd: string): Promise<LocalImportResolution> {
this.#refreshTrackedLocalModules();
const modulePath = this.filenameForUrl(moduleUrl);
const baseDir = modulePath ? path.dirname(modulePath) : cwd;
return await this.#resolveFromBase(baseDir, source);
}
requireForFile(moduleUrlOrPath: string | undefined, cwd: string): NodeJS.Require {
const basePath = this.filenameForUrl(moduleUrlOrPath) ?? path.join(cwd, "[eval]");
let cached = this.#requireCache.get(basePath);
if (!cached) {
cached = buildRequire(basePath);
this.#requireCache.set(basePath, cached);
}
return cached;
}
filenameForUrl(moduleUrlOrPath: string | undefined): string | null {
if (!moduleUrlOrPath) return null;
if (moduleUrlOrPath.startsWith("file://")) return fileURLToPath(moduleUrlOrPath);
return path.isAbsolute(moduleUrlOrPath) ? moduleUrlOrPath : null;
}
dirnameForUrl(moduleUrlOrPath: string | undefined, cwd: string): string {
const filename = this.filenameForUrl(moduleUrlOrPath);
return filename ? path.dirname(filename) : cwd;
}
async #resolveFromBase(baseDir: string, source: string): Promise<LocalImportResolution> {
const resolved = resolveImportSpecifier(baseDir, source);
if (isLocalPathSpecifier(source) && isManagedLocalModulePath(resolved)) {
const entry = await this.#ensureLocalModule(resolved);
return { mode: "local", value: entry.module.namespace };
}
return { mode: "external", target: normalizeImportTarget(resolved) };
}
async #ensureLocalModule(modulePath: string): Promise<LocalModuleEntry> {
const existing = this.#moduleEntries.get(modulePath);
if (existing) return existing;
const building = this.#moduleBuilds.get(modulePath);
if (building) return await building;
const buildPromise = this.#buildLocalModule(modulePath).finally(() => {
if (this.#moduleBuilds.get(modulePath) === buildPromise) this.#moduleBuilds.delete(modulePath);
});
this.#moduleBuilds.set(modulePath, buildPromise);
return await buildPromise;
}
async #buildLocalModule(modulePath: string): Promise<LocalModuleEntry> {
const rawSource = fs.readFileSync(modulePath, "utf8");
const stripped = stripTypeScriptSyntax(rawSource);
const moduleDir = path.dirname(modulePath);
const localDeps = new Set<string>();
for (const specifier of collectModuleSourceSpecifiers(stripped)) {
const resolved = resolveImportSpecifier(moduleDir, specifier);
if (isLocalPathSpecifier(specifier) && isManagedLocalModulePath(resolved)) {
localDeps.add(resolved);
}
}
this.#setModuleDependencies(modulePath, localDeps);
this.#moduleMtimes.set(modulePath, fs.statSync(modulePath).mtimeMs);
const version = this.#moduleVersions.get(modulePath) ?? 1;
this.#moduleVersions.set(modulePath, version);
const fileUrl = pathToFileURL(modulePath).href;
const identifier = `${fileUrl}?omp-session=${this.#sessionTag}&v=${version}`;
const wrappedSource = buildModuleSource(stripped, modulePath);
const module = new vm.SourceTextModule(wrappedSource, {
context: this.#context,
identifier,
initializeImportMeta: meta => {
(meta as { url?: string; path?: string; dir?: string }).url = fileUrl;
(meta as { url?: string; path?: string; dir?: string }).path = modulePath;
(meta as { url?: string; path?: string; dir?: string }).dir = moduleDir;
},
importModuleDynamically: async specifier => {
return await this.#resolveLinkedModule(modulePath, String(specifier));
},
});
const entry: LocalModuleEntry = { version, identifier, module };
this.#moduleEntries.set(modulePath, entry);
try {
await module.link(async specifier => await this.#resolveLinkedModule(modulePath, specifier));
await module.evaluate();
return entry;
} catch (error) {
this.#moduleEntries.delete(modulePath);
throw error;
}
}
async #resolveLinkedModule(referrerPath: string, specifier: string): Promise<vm.Module> {
const baseDir = path.dirname(referrerPath);
const resolved = resolveImportSpecifier(baseDir, specifier);
if (isLocalPathSpecifier(specifier) && isManagedLocalModulePath(resolved)) {
return (await this.#ensureLocalModule(resolved)).module;
}
return await this.#ensureExternalModule(normalizeImportTarget(resolved));
}
async #ensureExternalModule(target: string): Promise<vm.Module> {
const existing = this.#externalModules.get(target);
if (existing) return await existing;
const loadPromise = (async () => {
const namespace = await import(target);
const exportNames = Object.keys(namespace);
const module = new vm.SyntheticModule(
exportNames,
function () {
for (const name of exportNames) {
this.setExport(name, namespace[name as keyof typeof namespace]);
}
},
{ context: this.#context, identifier: target },
);
await module.link(() => {
throw new Error("Synthetic external modules have no dependencies");
});
await module.evaluate();
return module;
})();
this.#externalModules.set(target, loadPromise);
try {
return await loadPromise;
} catch (error) {
if (this.#externalModules.get(target) === loadPromise) this.#externalModules.delete(target);
throw error;
}
}
#refreshTrackedLocalModules(): void {
const changed: string[] = [];
for (const [modulePath, previousMtime] of this.#moduleMtimes.entries()) {
let nextMtime: number | undefined;
try {
nextMtime = fs.statSync(modulePath).mtimeMs;
} catch {
nextMtime = undefined;
}
if (nextMtime === previousMtime) continue;
if (nextMtime === undefined) this.#moduleMtimes.delete(modulePath);
else this.#moduleMtimes.set(modulePath, nextMtime);
changed.push(modulePath);
}
for (const modulePath of changed) {
this.#invalidateModuleAndParents(modulePath, new Set());
}
}
#invalidateModuleAndParents(modulePath: string, seen: Set<string>): void {
if (seen.has(modulePath)) return;
seen.add(modulePath);
this.#moduleEntries.delete(modulePath);
this.#moduleBuilds.delete(modulePath);
this.#moduleVersions.set(modulePath, (this.#moduleVersions.get(modulePath) ?? 1) + 1);
const parents = [...(this.#moduleParents.get(modulePath) ?? [])];
for (const parent of parents) this.#invalidateModuleAndParents(parent, seen);
}
#setModuleDependencies(modulePath: string, deps: Set<string>): void {
const previousDeps = this.#moduleDeps.get(modulePath);
if (previousDeps) {
for (const dep of previousDeps) {
const parents = this.#moduleParents.get(dep);
if (!parents) continue;
parents.delete(modulePath);
if (parents.size === 0) this.#moduleParents.delete(dep);
}
}
this.#moduleDeps.set(modulePath, new Set(deps));
for (const dep of deps) {
const parents = this.#moduleParents.get(dep) ?? new Set<string>();
parents.add(modulePath);
this.#moduleParents.set(dep, parents);
}
}
}
function buildRequire(fromPath: string): NodeJS.Require {
const basePath = path.extname(fromPath) ? fromPath : path.join(fromPath, "[eval]");
return createRequire(pathToFileURL(basePath).href);
}
function buildModuleSource(source: string, modulePath: string): string {
const moduleDir = path.dirname(modulePath);
return [
`const require = globalThis.__omp_get_require__(${JSON.stringify(pathToFileURL(modulePath).href)});`,
`const __filename = ${JSON.stringify(modulePath)};`,
`const __dirname = ${JSON.stringify(moduleDir)};`,
source,
].join("\n");
}
function resolveImportSpecifier(cwd: string, source: string): string {
if (/^[a-z][a-z0-9+.-]*:/i.test(source)) return source;
try {
return Bun.resolveSync(source, cwd);
} catch {
return source;
}
}
function isLocalPathSpecifier(source: string): boolean {
return (
source.startsWith("./") ||
source.startsWith("../") ||
source === "." ||
source === ".." ||
source.startsWith("/") ||
source.startsWith("~/") ||
/^[a-zA-Z]:[\\/]/.test(source)
);
}
function isManagedLocalModulePath(target: string): boolean {
return (
path.isAbsolute(target) &&
LOCAL_MODULE_EXTENSIONS.has(path.extname(target)) &&
!target.includes(`${path.sep}node_modules${path.sep}`)
);
}
function normalizeImportTarget(target: string): string {
if (path.isAbsolute(target)) return pathToFileURL(target).href;
return target;
}
@@ -51,6 +51,10 @@ if (!globalThis.__omp_js_prelude_loaded__) {
warn: (...args) => globalThis.__omp_log__("warn", ...formatArgs(args)),
error: (...args) => globalThis.__omp_log__("error", ...formatArgs(args)),
debug: (...args) => globalThis.__omp_log__("debug", ...formatArgs(args)),
table: (data, columns) =>
columns === undefined
? globalThis.__omp_table__(data)
: globalThis.__omp_table__(data, columns),
};
globalThis.console = consoleBridge;
@@ -57,6 +57,10 @@ type BabelExpressionStatement = {
};
type BabelProgramNode = BabelImportDeclaration | BabelLexicalDecl | BabelExpressionStatement | { type: string };
type BabelModuleSourceDeclaration = {
type: "ImportDeclaration" | "ExportNamedDeclaration" | "ExportAllDeclaration";
source?: { value: string; start: number; end: number } | null;
};
type BabelNode = { type: string; start: number; end: number; [key: string]: unknown };
@@ -189,6 +193,84 @@ export function rewriteImports(code: string): string {
}
return result;
}
export function collectModuleSourceSpecifiers(code: string): string[] {
const ast = parseProgram(code);
if (!ast) return [];
const sources: string[] = [];
for (const node of ast.program.body) {
if (
(node.type === "ImportDeclaration" ||
node.type === "ExportNamedDeclaration" ||
node.type === "ExportAllDeclaration") &&
typeof (node as BabelModuleSourceDeclaration).source?.value === "string"
) {
sources.push((node as BabelModuleSourceDeclaration).source!.value);
}
}
return sources;
}
export function rewriteModuleSourceSpecifiers(code: string, replacer: (source: string) => string): string {
const ast = parseProgram(code);
if (!ast) return code;
type Edit = { start: number; end: number; text: string };
const edits: Edit[] = [];
for (const node of ast.program.body) {
if (
node.type !== "ImportDeclaration" &&
node.type !== "ExportNamedDeclaration" &&
node.type !== "ExportAllDeclaration"
) {
continue;
}
const source = (node as BabelModuleSourceDeclaration).source;
if (!source || typeof source.value !== "string") continue;
const next = replacer(source.value);
if (next === source.value) continue;
edits.push({ start: source.start, end: source.end, text: JSON.stringify(next) });
}
if (edits.length === 0) return code;
edits.sort((a, b) => b.start - a.start);
let result = code;
for (const edit of edits) {
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
}
return result;
}
export function rewriteDynamicImports(code: string, callee = "__omp_import__"): string {
if (!code.includes("import")) return code;
const ast = parseProgram(code);
if (!ast) return code;
type Edit = { start: number; end: number; text: string };
const edits: Edit[] = [];
walkNodes(ast, node => {
if (node.type !== "CallExpression") return;
const call = node as unknown as { callee?: { type?: string; start?: number; end?: number } };
const callCallee = call.callee;
if (
!callCallee ||
callCallee.type !== "Import" ||
typeof callCallee.start !== "number" ||
typeof callCallee.end !== "number"
) {
return;
}
edits.push({ start: callCallee.start, end: callCallee.end, text: callee });
});
if (edits.length === 0) return code;
edits.sort((a, b) => b.start - a.start);
let result = code;
for (const edit of edits) {
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
}
return result;
}
function collectBindingNames(pattern: unknown, names: string[]): void {
if (!pattern || typeof pattern !== "object") return;
@@ -390,6 +472,9 @@ function stripTypeScript(code: string): string {
return code;
}
}
export function stripTypeScriptSyntax(code: string): string {
return stripTypeScript(code);
}
// Heuristic: any of the obvious TS-only tokens. Plain JS using `as` only inside strings
// won't match because we require a leading word boundary plus a colon/keyword neighbor.
@@ -1,23 +1,23 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { Console } from "node:console";
import * as fs from "node:fs";
import { createRequire } from "node:module";
import * as path from "node:path";
import { pathToFileURL } from "node:url";
import { Writable } from "node:stream";
import * as util from "node:util";
import { logger } from "@oh-my-pi/pi-utils";
import { ToolError } from "../../../tools/tool-errors";
import { createHelpers, type HelperBundle } from "./helpers";
import { awaitMaybePromise, indirectEval } from "./indirect-eval";
import { LocalModuleLoader } from "./local-module-loader";
import { JAVASCRIPT_PRELUDE_SOURCE } from "./prelude";
import { wrapCode } from "./rewrite-imports";
import type { JsDisplayOutput, JsStatusEvent } from "./types";
/**
* Per-run callbacks. Returned by `getHooks()` on each helper/tool/display invocation so
* the embedding worker can route emissions to the currently active run. Returning `null`
* makes status/display/tool calls reject with an error — useful for guarding against
* helpers being invoked outside a run window.
* Per-run callbacks. Runtime globals resolve these from AsyncLocalStorage so
* overlapping async cells can route output/tool calls back to their own run.
*/
export interface RuntimeHooks {
onText(chunk: string): void;
@@ -25,11 +25,17 @@ export interface RuntimeHooks {
callTool(name: string, args: unknown): Promise<unknown>;
}
export interface RunContext {
runId: string;
hooks: RuntimeHooks;
cwd: string;
finalExpressionSet: boolean;
finalExpressionValue: unknown;
}
export interface RuntimeOptions {
initialCwd: string;
sessionId: string;
/** Resolve hooks for the run currently in flight, or `null` if nothing is active. */
getHooks(): RuntimeHooks | null;
/**
* Extra globals installed alongside `__omp_helpers__` / prelude. Use for stable, lifetime-
* of-the-worker bindings (e.g. browser's `page`, `browser`). Per-run scope should be set
@@ -118,19 +124,18 @@ export class JsRuntime {
#cwd: string;
readonly sessionId: string;
#env: Map<string, string>;
#getHooks: () => RuntimeHooks | null;
#finalExpressionSet = false;
#finalExpressionValue: unknown;
#als = new AsyncLocalStorage<RunContext>();
#moduleLoader: LocalModuleLoader;
constructor(opts: RuntimeOptions) {
this.#cwd = opts.initialCwd;
this.sessionId = opts.sessionId;
this.#env = new Map();
this.#getHooks = opts.getHooks;
this.#moduleLoader = new LocalModuleLoader(this.sessionId);
this.helpers = createHelpers({
cwd: () => this.#cwd,
cwd: () => this.#activeCwd(),
env: this.#env,
emitStatus: event => this.#getHooks()?.onDisplay({ type: "status", event }),
emitStatus: event => this.#activeHooks("emitStatus")?.onDisplay({ type: "status", event }),
});
this.#install(opts.extraGlobals);
}
@@ -154,29 +159,42 @@ export class JsRuntime {
Object.assign(globalThis, scope);
}
async run(code: string, filename?: string): Promise<unknown> {
this.#finalExpressionSet = false;
this.#finalExpressionValue = undefined;
const wrapped = wrapCode(code);
const value = indirectEval(wrapped.source, filename);
if (wrapped.finalExpressionReturned) {
const awaited = await awaitMaybePromise(value);
if (this.#finalExpressionSet) {
const finalValue = this.#finalExpressionValue;
this.#finalExpressionSet = false;
this.#finalExpressionValue = undefined;
const resolved = await awaitMaybePromise(finalValue);
return resolved;
async run(
code: string,
filename: string | undefined,
hooks: RuntimeHooks,
options: { runId?: string; cwd?: string } = {},
): Promise<unknown> {
const context: RunContext = {
runId: options.runId ?? crypto.randomUUID(),
hooks,
cwd: options.cwd ?? this.#cwd,
finalExpressionSet: false,
finalExpressionValue: undefined,
};
return await this.#als.run(context, async () => {
const wrapped = wrapCode(code);
const value = indirectEval(wrapped.source, filename);
if (wrapped.finalExpressionReturned) {
const awaited = await awaitMaybePromise(value);
if (context.finalExpressionSet) {
const finalValue = context.finalExpressionValue;
context.finalExpressionSet = false;
context.finalExpressionValue = undefined;
return await awaitMaybePromise(finalValue);
}
return awaited;
}
return awaited;
}
return await awaitMaybePromise(value);
return await awaitMaybePromise(value);
});
}
displayValue(value: unknown): void {
displayValue(value: unknown, hooks: RuntimeHooks | undefined = this.#als.getStore()?.hooks): void {
if (value === undefined) return;
const hooks = this.#getHooks();
if (!hooks) return;
if (!hooks) {
logger.warn("js runtime display called outside an active run");
return;
}
if (value && typeof value === "object") {
const record = value as Record<string, unknown>;
if (record.type === "image" && typeof record.mimeType === "string") {
@@ -207,45 +225,108 @@ export class JsRuntime {
hooks.onText(`${String(value)}\n`);
}
#activeCwd(): string {
return this.#als.getStore()?.cwd ?? this.#cwd;
}
#activeHooks(action: string): RuntimeHooks | undefined {
const hooks = this.#als.getStore()?.hooks;
if (!hooks) {
logger.warn("js runtime helper called outside an active run", { action });
}
return hooks;
}
#activeRequire(moduleUrlOrPath?: string): NodeJS.Require {
return this.#moduleLoader.requireForFile(moduleUrlOrPath, this.#activeCwd());
}
#moduleFilename(moduleUrlOrPath?: string): string {
return this.#moduleLoader.filenameForUrl(moduleUrlOrPath) ?? path.join(this.#activeCwd(), "[eval]");
}
#moduleDirname(moduleUrlOrPath?: string): string {
return this.#moduleLoader.dirnameForUrl(moduleUrlOrPath, this.#activeCwd());
}
#buildDynamicRequire(): NodeJS.Require {
const dynamicRequire = ((id: string) => this.#activeRequire()(id)) as NodeJS.Require;
const resolve = ((id: string, options?: { paths?: string[] }) =>
this.#activeRequire().resolve(id, options)) as NodeJS.Require["resolve"] & {
paths(request: string): string[] | null;
};
resolve.paths = request => this.#activeRequire().resolve.paths(request);
Object.defineProperties(dynamicRequire, {
resolve: { value: resolve, configurable: true },
cache: { get: () => this.#activeRequire().cache, configurable: true },
extensions: { get: () => this.#activeRequire().extensions, configurable: true },
main: { get: () => this.#activeRequire().main, configurable: true },
});
return dynamicRequire;
}
#install(extraGlobals: Record<string, unknown> | undefined): void {
const injected: Record<string, unknown> = {
__omp_session__: { cwd: this.#cwd, sessionId: this.sessionId },
__omp_helpers__: this.helpers,
__omp_call_tool__: async (name: string, args: unknown) => {
const hooks = this.#getHooks();
if (!hooks) throw new ToolError("Tool calls are only valid inside an active run");
const hooks = this.#activeHooks("tool");
if (!hooks) return undefined;
return await hooks.callTool(name, args);
},
__omp_import__: async (source: string, options?: ImportCallOptions) => {
const target = resolveImportSpecifier(this.#cwd, source);
// Always invalidate cached module records for user-owned source files so edits
// between cells are picked up. Bun ignores query-string busting on `file:` URLs
// but honors `delete require.cache[absPath]`; bare specifiers and URL schemes are
// left alone to keep package identity stable across cells.
if (isLocalPathSpecifier(source) && path.isAbsolute(target)) {
delete require.cache[target];
}
const resolved = await this.#moduleLoader.resolveForRun(this.#activeCwd(), source);
if (resolved.mode === "local") return resolved.value;
const target = resolved.target;
return options !== undefined ? await import(target, options) : await import(target);
},
__omp_import_from__: async (moduleUrl: string, source: string, options?: ImportCallOptions) => {
const resolved = await this.#moduleLoader.resolveForModule(moduleUrl, source, this.#activeCwd());
if (resolved.mode === "local") return resolved.value;
const target = resolved.target;
return options !== undefined ? await import(target, options) : await import(target);
},
__omp_get_require__: (moduleUrl?: string) => this.#activeRequire(moduleUrl),
__omp_get_filename__: (moduleUrl?: string) => this.#moduleFilename(moduleUrl),
__omp_get_dirname__: (moduleUrl?: string) => this.#moduleDirname(moduleUrl),
__omp_emit_status__: (op: string, data: Record<string, unknown> = {}) => {
const event: JsStatusEvent = { op, ...data };
this.#getHooks()?.onDisplay({ type: "status", event });
this.#activeHooks("emitStatus")?.onDisplay({ type: "status", event });
},
__omp_log__: (level: string, ...args: unknown[]) => {
const prefix = level === "error" ? "[error] " : level === "warn" ? "[warn] " : "";
const text = `${prefix}${formatConsoleArgs(args)}`;
this.#getHooks()?.onText(text.endsWith("\n") ? text : `${text}\n`);
this.#activeHooks("log")?.onText(text.endsWith("\n") ? text : `${text}\n`);
},
__omp_table__: (...args: unknown[]) => {
const hooks = this.#activeHooks("table");
if (!hooks) return;
let buffer = "";
const stream = new Writable({
write(chunk, _enc, cb) {
buffer += typeof chunk === "string" ? chunk : (chunk as Buffer).toString("utf8");
cb();
},
});
const tableConsole = new Console({ stdout: stream, colorMode: false });
(tableConsole.table as (...a: unknown[]) => void)(...args);
hooks.onText(buffer.endsWith("\n") ? buffer : `${buffer}\n`);
},
__omp_display__: (value: unknown) => this.displayValue(value),
__omp_set_final_expr__: (value: unknown) => {
this.#finalExpressionSet = true;
this.#finalExpressionValue = value;
const context = this.#als.getStore();
if (!context) {
logger.warn("js runtime final expression set outside an active run");
return;
}
context.finalExpressionSet = true;
context.finalExpressionValue = value;
},
webcrypto: crypto,
// `process` is intentionally not overridden — user code gets the host worker's real
// `process` object. Subsetting it caused segfaults in workers that share state with
// puppeteer/worker_threads internals.
require: buildRequire(this.#cwd),
require: this.#buildDynamicRequire(),
createRequire,
fs,
};
@@ -261,41 +342,3 @@ function formatConsoleArgs(args: unknown[]): string {
.map(arg => (typeof arg === "string" ? arg : util.inspect(arg, { depth: 6, colors: false, breakLength: 120 })))
.join(" ");
}
function buildRequire(cwd: string): NodeJS.Require {
return createRequire(pathToFileURL(path.join(cwd, "[eval]")).href);
}
/**
* Resolve an import specifier emitted by `rewriteImports` against the active session
* cwd. Relative paths (`./`, `../`, `/`) and bare specifiers (`pkg`, `@scope/pkg`) both go
* through `Bun.resolveSync` rooted at the cwd so user-pasted ESM behaves as if it lived in
* the project — not next to the worker module. URL-like specifiers (`file://`, `data:`,
* `node:`, `http:`) are passed through unchanged.
*/
function resolveImportSpecifier(cwd: string, source: string): string {
if (/^[a-z][a-z0-9+.-]*:/i.test(source)) return source;
try {
return Bun.resolveSync(source, cwd);
} catch {
return source;
}
}
/**
* Returns true when the original specifier is a relative or absolute filesystem path
* (i.e. user-owned source the agent is iterating on). Bare specifiers and URL schemes
* are excluded — `node:` built-ins cannot be reloaded, and busting bare packages would
* defeat module identity for every cell while bringing no editing benefit.
*/
function isLocalPathSpecifier(source: string): boolean {
return (
source.startsWith("./") ||
source.startsWith("../") ||
source === "." ||
source === ".." ||
source.startsWith("/") ||
source.startsWith("~/") ||
/^[a-zA-Z]:[\\/]/.test(source)
);
}
@@ -3,6 +3,7 @@ import { JsRuntime, type RuntimeHooks } from "./shared/runtime";
import type { RunErrorPayload, SessionSnapshot, ToolReply, Transport, WorkerInbound } from "./worker-protocol";
interface PendingTool {
runId: string;
resolve(value: unknown): void;
reject(error: Error): void;
}
@@ -36,8 +37,7 @@ function errorFromPayload(payload: RunErrorPayload): Error {
export class WorkerCore {
#transport: Transport;
#runtime: JsRuntime | null = null;
#queue: Promise<void> = Promise.resolve();
#active: ActiveRun | null = null;
#runs = new Map<string, ActiveRun>();
#unsubscribe: () => void;
constructor(transport: Transport) {
@@ -52,7 +52,7 @@ export class WorkerCore {
this.#ensureRuntime(msg.snapshot);
return;
case "run":
this.#enqueueRun(msg.runId, msg.code, msg.filename, msg.snapshot);
void this.#runOne(msg.runId, msg.code, msg.filename, msg.snapshot);
return;
case "tool-reply":
this.#deliverToolReply(msg.id, msg.reply);
@@ -71,73 +71,58 @@ export class WorkerCore {
this.#runtime = new JsRuntime({
initialCwd: snapshot.cwd,
sessionId: snapshot.sessionId,
getHooks: () => this.#hooksForCurrentRun(),
});
return this.#runtime;
}
#hooksForCurrentRun(): RuntimeHooks | null {
const active = this.#active;
if (!active) return null;
const runId = active.runId;
return {
onText: chunk => this.#transport.send({ type: "text", runId, chunk }),
onDisplay: output => this.#transport.send({ type: "display", runId, output }),
callTool: (name, args) => this.#callTool(active, name, args),
};
}
#enqueueRun(runId: string, code: string, filename: string, snapshot: SessionSnapshot): void {
const previous = this.#queue;
const next = (async () => {
await previous.catch(() => undefined);
await this.#runOne(runId, code, filename, snapshot);
})();
this.#queue = next.catch(() => undefined);
}
async #runOne(runId: string, code: string, filename: string, snapshot: SessionSnapshot): Promise<void> {
const runtime = this.#ensureRuntime(snapshot);
runtime.setCwd(snapshot.cwd);
this.#active = { runId, pendingTools: new Map() };
const active: ActiveRun = { runId, pendingTools: new Map() };
this.#runs.set(runId, active);
const hooks: RuntimeHooks = {
onText: chunk => this.#transport.send({ type: "text", runId, chunk }),
onDisplay: output => this.#transport.send({ type: "display", runId, output }),
callTool: (name, args) => this.#callTool(active, name, args),
};
try {
const value = await runtime.run(code, filename);
runtime.displayValue(value);
const value = await runtime.run(code, filename, hooks, { runId, cwd: snapshot.cwd });
runtime.displayValue(value, hooks);
this.#transport.send({ type: "result", runId, ok: true });
} catch (error) {
this.#transport.send({ type: "result", runId, ok: false, error: errorPayload(error) });
} finally {
this.#active = null;
this.#runs.delete(runId);
}
}
async #callTool(active: ActiveRun, name: string, args: unknown): Promise<unknown> {
const id = `tc-${active.runId}-${crypto.randomUUID()}`;
const { promise, resolve, reject } = Promise.withResolvers<unknown>();
active.pendingTools.set(id, { resolve, reject });
active.pendingTools.set(id, { runId: active.runId, resolve, reject });
this.#transport.send({ type: "tool-call", id, runId: active.runId, name, args });
return await promise;
}
#deliverToolReply(id: string, reply: ToolReply): void {
const active = this.#active;
if (!active) return;
const pending = active.pendingTools.get(id);
if (!pending) return;
active.pendingTools.delete(id);
if (reply.ok) pending.resolve(reply.value);
else pending.reject(errorFromPayload(reply.error));
for (const active of this.#runs.values()) {
const pending = active.pendingTools.get(id);
if (!pending) continue;
active.pendingTools.delete(id);
if (reply.ok) pending.resolve(reply.value);
else pending.reject(errorFromPayload(reply.error));
return;
}
}
#close(): void {
const active = this.#active;
if (active) {
for (const active of this.#runs.values()) {
for (const pending of active.pendingTools.values()) {
pending.reject(new ToolError("JS worker closed"));
}
active.pendingTools.clear();
}
this.#active = null;
this.#runs.clear();
this.#runtime = null;
this.#transport.send({ type: "closed" });
this.#unsubscribe();
+155 -84
View File
@@ -1,14 +1,17 @@
import * as path from "node:path";
import { getProjectDir, logger } from "@oh-my-pi/pi-utils";
import { Settings } from "../../config/settings";
import { OutputSink } from "../../session/streaming-output";
import type { ToolSession } from "../../tools";
import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../../tools/output-meta";
import type { JsStatusEvent } from "../js/shared/types";
import type { KernelDisplayOutput } from "./display";
import {
checkPythonKernelAvailability,
type KernelDisplayOutput,
type KernelExecuteOptions,
type KernelExecuteResult,
type KernelRuntimeEnv,
PythonKernel,
} from "./kernel";
import { ensurePyToolBridge, registerPyToolBridge } from "./tool-bridge";
@@ -92,20 +95,32 @@ export interface PythonResult {
// ---------------------------------------------------------------------------
// Session bookkeeping
//
// One PythonKernel subprocess per session id. Sessions are reused until they
// die or are explicitly disposed. Multiple agent owners can register against
// the same session id; the kernel stays alive until the last owner detaches.
// One PythonKernel subprocess per (session id, cwd) tuple. The runner mutates
// process-global cwd/sys.path during execution, so cross-directory work MUST
// never share a live kernel. Multiple agent owners can still register against
// the same tuple; the kernel stays alive until the last owner detaches.
// ---------------------------------------------------------------------------
interface PythonSession {
sessionKey: string;
sessionId: string;
cwd: string;
kernel: PythonKernel;
ownerIds: Set<string>;
hasFallbackOwner: boolean;
queue: Promise<void>;
}
const sessions = new Map<string, PythonSession>();
const startingSessions = new Map<string, Promise<PythonSession>>();
const resettingSessions = new Set<string>();
function normalizeSessionCwd(cwd: string): string {
return path.resolve(cwd);
}
function buildSessionKey(sessionId: string, cwd: string): string {
return `${sessionId}\0${normalizeSessionCwd(cwd)}`;
}
// ---------------------------------------------------------------------------
// Cancellation plumbing
@@ -240,19 +255,40 @@ function createCancelledPythonResult(timedOut: boolean, timeoutMs?: number): Pyt
// Kernel start helpers
// ---------------------------------------------------------------------------
const MANAGED_KERNEL_ENV_KEYS = [
"PI_SESSION_FILE",
"PI_ARTIFACTS_DIR",
"PI_TOOL_BRIDGE_URL",
"PI_TOOL_BRIDGE_TOKEN",
"PI_TOOL_BRIDGE_SESSION",
] as const;
function buildKernelEnvPatch(options: {
sessionFile?: string;
artifactsDir?: string;
bridgeSessionId?: string;
bridge?: { url: string; token: string };
}): KernelRuntimeEnv {
return {
PI_SESSION_FILE: options.sessionFile ?? null,
PI_ARTIFACTS_DIR: options.artifactsDir ?? null,
PI_TOOL_BRIDGE_URL: options.bridge?.url ?? null,
PI_TOOL_BRIDGE_TOKEN: options.bridge?.token ?? null,
PI_TOOL_BRIDGE_SESSION: options.bridge && options.bridgeSessionId ? options.bridgeSessionId : null,
};
}
function buildKernelEnv(options: {
sessionFile?: string;
artifactsDir?: string;
bridgeSessionId?: string;
bridge?: { url: string; token: string };
}): Record<string, string> | undefined {
const patch = buildKernelEnvPatch(options);
const env: Record<string, string> = {};
if (options.sessionFile) env.PI_SESSION_FILE = options.sessionFile;
if (options.artifactsDir) env.PI_ARTIFACTS_DIR = options.artifactsDir;
if (options.bridge && options.bridgeSessionId) {
env.PI_TOOL_BRIDGE_URL = options.bridge.url;
env.PI_TOOL_BRIDGE_TOKEN = options.bridge.token;
env.PI_TOOL_BRIDGE_SESSION = options.bridgeSessionId;
for (const key of MANAGED_KERNEL_ENV_KEYS) {
const value = patch[key];
if (value !== null) env[key] = value;
}
return Object.keys(env).length > 0 ? env : undefined;
}
@@ -282,23 +318,44 @@ function attachOwner(session: PythonSession, sessionId: string, ownerId: string
}
}
async function acquireSession(sessionId: string, cwd: string, options: PythonExecutorOptions): Promise<PythonSession> {
const existing = sessions.get(sessionId);
async function acquireSession(
sessionKey: string,
sessionId: string,
cwd: string,
options: PythonExecutorOptions,
): Promise<PythonSession> {
const existing = sessions.get(sessionKey);
if (existing) {
attachOwner(existing, sessionId, options.kernelOwnerId);
return existing;
}
const kernel = await startKernel(cwd, options);
const session: PythonSession = {
sessionId,
kernel,
ownerIds: new Set(),
hasFallbackOwner: false,
queue: Promise.resolve(),
};
attachOwner(session, sessionId, options.kernelOwnerId);
sessions.set(sessionId, session);
return session;
const starting = startingSessions.get(sessionKey);
if (starting) {
const session = await starting;
attachOwner(session, sessionId, options.kernelOwnerId);
return session;
}
const startup = (async () => {
const kernel = await startKernel(cwd, options);
const session: PythonSession = {
sessionKey,
sessionId,
cwd,
kernel,
ownerIds: new Set(),
hasFallbackOwner: false,
};
sessions.set(sessionKey, session);
return session;
})();
startingSessions.set(sessionKey, startup);
try {
const session = await startup;
attachOwner(session, sessionId, options.kernelOwnerId);
return session;
} finally {
if (startingSessions.get(sessionKey) === startup) startingSessions.delete(sessionKey);
}
}
async function replaceSessionKernel(
@@ -311,52 +368,40 @@ async function replaceSessionKernel(
await old
.shutdown(remaining !== undefined ? { timeoutMs: Math.max(0, remaining) } : undefined)
.catch(() => undefined);
if (sessions.get(session.sessionId) !== session) {
if (sessions.get(session.sessionKey) !== session) {
throw new PythonExecutionCancelledError(false);
}
requireRemainingTimeoutMs(options.deadlineMs);
const next = await startKernel(cwd, options);
if (sessions.get(session.sessionId) !== session) {
if (sessions.get(session.sessionKey) !== session) {
await next.shutdown().catch(() => undefined);
throw new PythonExecutionCancelledError(false);
}
session.kernel = next;
}
async function resetSession(sessionId: string): Promise<void> {
const existing = sessions.get(sessionId);
async function resetSession(sessionKey: string): Promise<void> {
const existing = sessions.get(sessionKey) ?? (await startingSessions.get(sessionKey)?.catch(() => undefined));
if (!existing) return;
sessions.delete(sessionId);
sessions.delete(sessionKey);
await existing.kernel.shutdown().catch(() => undefined);
}
async function runQueued<T>(
session: PythonSession,
options: Pick<PythonExecutorOptions, "signal" | "deadlineMs">,
work: () => Promise<T>,
): Promise<T> {
const previous = session.queue;
const { promise: ourSlot, resolve: releaseSlot } = Promise.withResolvers<void>();
// Keep the queue chained even if WE bail out: future runs must still wait
// for `previous` to finish before they touch the kernel.
session.queue = previous.catch(() => undefined).then(() => ourSlot);
try {
await waitForPromiseWithCancellation(
previous.catch(() => undefined),
options,
);
return await work();
} finally {
releaseSlot();
}
}
// ---------------------------------------------------------------------------
// Public dispose entry points
// ---------------------------------------------------------------------------
export async function disposeAllKernelSessions(): Promise<void> {
const pending = [...startingSessions.values()];
startingSessions.clear();
const started = await Promise.allSettled(pending);
const all = [...sessions.entries()];
for (const result of started) {
if (result.status !== "fulfilled") continue;
if (!all.some(([, session]) => session === result.value)) {
all.push([result.value.sessionKey, result.value]);
}
}
for (const [id, session] of all) {
if (sessions.get(id) === session) sessions.delete(id);
}
@@ -366,7 +411,12 @@ export async function disposeAllKernelSessions(): Promise<void> {
const result = results[i];
if (result.status === "fulfilled" && result.value?.confirmed !== false) continue;
const reason = result.status === "rejected" ? result.reason : "not confirmed";
logger.warn("Python kernel shutdown not confirmed", { sessionId: id, reason });
logger.warn("Python kernel shutdown not confirmed", {
sessionId: session.sessionId,
sessionKey: id,
cwd: session.cwd,
reason,
});
if (!sessions.has(id)) sessions.set(id, session);
}
}
@@ -382,7 +432,7 @@ export async function disposeKernelSessionsByOwner(ownerId: string): Promise<voi
session.ownerIds.delete(ownerId);
}
for (const session of toShutdown) {
if (sessions.get(session.sessionId) === session) sessions.delete(session.sessionId);
if (sessions.get(session.sessionKey) === session) sessions.delete(session.sessionKey);
}
const results = await Promise.allSettled(toShutdown.map(session => session.kernel.shutdown()));
for (let i = 0; i < toShutdown.length; i += 1) {
@@ -393,8 +443,13 @@ export async function disposeKernelSessionsByOwner(ownerId: string): Promise<voi
continue;
}
const reason = result.status === "rejected" ? result.reason : "not confirmed";
logger.warn("Python kernel shutdown not confirmed", { sessionId: session.sessionId, reason });
if (!sessions.has(session.sessionId)) sessions.set(session.sessionId, session);
logger.warn("Python kernel shutdown not confirmed", {
sessionId: session.sessionId,
sessionKey: session.sessionKey,
cwd: session.cwd,
reason,
});
if (!sessions.has(session.sessionKey)) sessions.set(session.sessionKey, session);
}
}
@@ -424,9 +479,10 @@ async function executeWithKernel(
((event: JsStatusEvent) => {
displayOutputs.push({ type: "status", event });
});
const runId = `py-${crypto.randomUUID()}`;
const unregisterBridge =
options?.toolSession && options?.bridgeSessionId
? registerPyToolBridge(options.bridgeSessionId, {
? registerPyToolBridge(options.bridgeSessionId, runId, {
toolSession: options.toolSession,
signal: options.signal,
emitStatus,
@@ -436,6 +492,9 @@ async function executeWithKernel(
try {
executionTimeoutMs = requireRemainingTimeoutMs(deadlineMs);
const result = await kernel.execute(code, {
cwd: options?.cwd,
env: buildKernelEnvPatch(options ?? {}),
id: runId,
signal: options?.signal,
timeoutMs: executionTimeoutMs,
onChunk: text => sink.push(text),
@@ -516,7 +575,7 @@ async function executePerCall(code: string, cwd: string, options: PythonExecutor
}
const kernel = await startKernel(cwd, options);
try {
return await executeWithKernel(kernel, code, options);
return await executeWithKernel(kernel, code, { ...options, cwd: undefined });
} finally {
await kernel.shutdown().catch(() => undefined);
}
@@ -524,42 +583,53 @@ async function executePerCall(code: string, cwd: string, options: PythonExecutor
async function executeOnSession(code: string, cwd: string, options: PythonExecutorOptions): Promise<PythonResult> {
const sessionId = options.sessionId ?? `session:${cwd}`;
const sessionKey = buildSessionKey(sessionId, cwd);
if (options.bridge && !options.bridgeSessionId) {
options.bridgeSessionId = sessionId;
}
if (options.reset) {
await resetSession(sessionId);
}
const session = await acquireSession(sessionId, cwd, options);
return await runQueued(session, options, async () => {
if (options.signal?.aborted) {
throw new PythonExecutionCancelledError(isTimedOutCancellation(options.signal.reason, options.signal));
if (resettingSessions.has(sessionKey)) {
throw new Error("Python kernel reset already in progress");
}
if (sessions.get(session.sessionId) !== session) {
resettingSessions.add(sessionKey);
try {
await resetSession(sessionKey);
} finally {
resettingSessions.delete(sessionKey);
}
} else if (resettingSessions.has(sessionKey)) {
throw new Error("Python kernel reset in progress");
}
const session = await acquireSession(sessionKey, sessionId, cwd, options);
if (options.signal?.aborted) {
throw new PythonExecutionCancelledError(isTimedOutCancellation(options.signal.reason, options.signal));
}
if (sessions.get(session.sessionKey) !== session) {
throw new PythonExecutionCancelledError(false);
}
if (!session.kernel.isAlive()) {
await replaceSessionKernel(session, cwd, options);
if (sessions.get(session.sessionKey) !== session) {
throw new PythonExecutionCancelledError(false);
}
if (!session.kernel.isAlive()) {
await replaceSessionKernel(session, cwd, options);
if (sessions.get(session.sessionId) !== session) {
throw new PythonExecutionCancelledError(false);
}
}
const runOptions = { ...options, cwd: undefined };
try {
return await executeWithKernel(session.kernel, code, runOptions);
} catch (err) {
if (isCancellationError(err) || options.signal?.aborted) throw err;
if (session.kernel.isAlive()) throw err;
if (sessions.get(session.sessionKey) !== session) {
throw new PythonExecutionCancelledError(false);
}
try {
return await executeWithKernel(session.kernel, code, options);
} catch (err) {
if (isCancellationError(err) || options.signal?.aborted) throw err;
if (session.kernel.isAlive()) throw err;
if (sessions.get(session.sessionId) !== session) {
throw new PythonExecutionCancelledError(false);
}
// Kernel died during execute. Replace it and retry once on a fresh one.
await replaceSessionKernel(session, cwd, options);
if (sessions.get(session.sessionId) !== session) {
throw new PythonExecutionCancelledError(false);
}
return await executeWithKernel(session.kernel, code, options);
// Shared kernels are keyed by cwd, so a dead kernel can be recreated in place
// without risking cross-directory state bleed.
await replaceSessionKernel(session, cwd, options);
if (sessions.get(session.sessionKey) !== session) {
throw new PythonExecutionCancelledError(false);
}
});
return await executeWithKernel(session.kernel, code, runOptions);
}
}
export async function executePythonWithKernel(
@@ -571,10 +641,11 @@ export async function executePythonWithKernel(
}
export async function executePython(code: string, options?: PythonExecutorOptions): Promise<PythonResult> {
const cwd = options?.cwd ?? getProjectDir();
const cwd = normalizeSessionCwd(options?.cwd ?? getProjectDir());
const deadlineMs = getExecutionDeadlineMs(options);
const executionOptions: PythonExecutorOptions = {
...(options ?? {}),
cwd,
deadlineMs,
};
+10 -1
View File
@@ -51,7 +51,14 @@ const STARTUP_TIMEOUT_MS = 10_000;
// kernel's state, so we only kill as a last-resort recovery path.
const INTERRUPT_ESCALATION_MS = 5_000;
export type KernelRuntimeEnv = Record<string, string | null>;
export interface KernelExecuteOptions {
id?: string;
/** Runtime working directory applied immediately before this request executes. */
cwd?: string;
/** Managed runtime environment variables applied immediately before this request executes. */
env?: KernelRuntimeEnv;
signal?: AbortSignal;
onChunk?: (text: string) => Promise<void> | void;
onDisplay?: (output: KernelDisplayOutput) => Promise<void> | void;
@@ -260,7 +267,7 @@ export class PythonKernel {
throw new Error("Python kernel is not running");
}
const msgId = Snowflake.next();
const msgId = options?.id ?? Snowflake.next();
const { promise, resolve } = Promise.withResolvers<KernelExecuteResult>();
const pending: PendingExecution = {
resolve,
@@ -345,6 +352,8 @@ export class PythonKernel {
const payload = JSON.stringify({
id: msgId,
code,
cwd: options?.cwd,
env: options?.env,
silent: options?.silent ?? false,
storeHistory: options?.storeHistory ?? !(options?.silent ?? false),
});
+22 -24
View File
@@ -377,13 +377,20 @@ if "__omp_prelude_loaded__" not in globals():
return current
def _tool_proxy_from_env() -> tuple[str, str, str]:
base = os.environ.get("PI_TOOL_BRIDGE_URL")
token = os.environ.get("PI_TOOL_BRIDGE_TOKEN")
session = os.environ.get("PI_TOOL_BRIDGE_SESSION")
if not base or not token or not session:
raise RuntimeError("tool bridge is unavailable in this kernel")
return (base.rstrip("/"), token, session)
class _ToolCallable:
"""Invokes one host-side tool via the loopback HTTP bridge."""
__slots__ = ("_proxy", "_name")
__slots__ = ("_name",)
def __init__(self, proxy: "_ToolProxy", name: str):
self._proxy = proxy
def __init__(self, name: str):
self._name = name
def __repr__(self) -> str:
@@ -402,16 +409,19 @@ if "__omp_prelude_loaded__" not in globals():
merged.update(kwargs)
if "_i" not in merged:
merged["_i"] = "py prelude"
base, token, session = _tool_proxy_from_env()
_run_id_getter = globals().get("__omp_current_run_id__")
_run_id = _run_id_getter() if callable(_run_id_getter) else globals().get("__omp_run_id__")
payload = json.dumps(
{"session": self._proxy._session, "name": self._name, "args": merged}
{"session": session, "run": _run_id, "name": self._name, "args": merged}
).encode("utf-8")
req = urllib.request.Request(
f"{self._proxy._base}/v1/tool",
f"{base}/v1/tool",
data=payload,
method="POST",
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {self._proxy._token}",
"Authorization": f"Bearer {token}",
},
)
try:
@@ -433,30 +443,18 @@ if "__omp_prelude_loaded__" not in globals():
class _ToolProxy:
"""`tool.<name>(args)` proxy mirroring the JS runtime bridge."""
__slots__ = ("_base", "_token", "_session")
def __init__(self, base: str, token: str, session: str):
self._base = base.rstrip("/")
self._token = token
self._session = session
__slots__ = ()
def __getattr__(self, name: str) -> _ToolCallable:
if name.startswith("_"):
raise AttributeError(name)
return _ToolCallable(self, name)
return _ToolCallable(name)
def __getitem__(self, name: str) -> _ToolCallable:
return _ToolCallable(self, name)
return _ToolCallable(name)
def __repr__(self) -> str:
return f"<tool proxy session={self._session}>"
session = os.environ.get("PI_TOOL_BRIDGE_SESSION")
return f"<tool proxy session={session}>" if session else "<tool proxy unavailable>"
if all(
_k in os.environ
for _k in ("PI_TOOL_BRIDGE_URL", "PI_TOOL_BRIDGE_TOKEN", "PI_TOOL_BRIDGE_SESSION")
):
tool = _ToolProxy(
os.environ["PI_TOOL_BRIDGE_URL"],
os.environ["PI_TOOL_BRIDGE_TOKEN"],
os.environ["PI_TOOL_BRIDGE_SESSION"],
)
tool = _ToolProxy()
+205 -87
View File
@@ -5,6 +5,7 @@ wrapper writes typed frames back.
Host -> wrapper:
{"id": str, "code": str, "silent": bool?, "storeHistory": bool?}
{"id": str, "code": str, "silent": bool?, "storeHistory": bool?, "cwd": str?, "env": dict?}
{"type": "exit"} # graceful shutdown
Wrapper -> host:
@@ -27,6 +28,7 @@ from __future__ import annotations
import asyncio
import ast
import contextvars
import base64
import builtins
import inspect
@@ -43,7 +45,7 @@ import threading
import time
import traceback
from pathlib import Path
from typing import Any, Callable
from typing import Any
# ---------------------------------------------------------------------------
# Frame writer
@@ -93,7 +95,7 @@ class _StreamProxy(io.TextIOBase):
data = str(data)
if not data:
return 0
rid = _STATE.current_id
rid = _CURRENT_RID.get()
if rid is None:
_RAW_STDERR.write(data)
_RAW_STDERR.flush()
@@ -112,7 +114,6 @@ class _StreamProxy(io.TextIOBase):
class _RunnerState:
def __init__(self) -> None:
self.current_id: str | None = None
self.execution_count: int = 0
self.cancel_requested: bool = False
# User globals — kept across requests when running in session mode.
@@ -123,8 +124,11 @@ class _RunnerState:
}
self.last_install_marker: int = 0
self.loop: asyncio.AbstractEventLoop | None = None
self.active_executions: int = 0
_CURRENT_RID: contextvars.ContextVar[str | None] = contextvars.ContextVar("omp_current_rid", default=None)
_STATE = _RunnerState()
@@ -284,7 +288,7 @@ def cell_magic(name: str) -> Callable[[Callable[[str, str], Any]], Callable[[str
def _emit_status(op: str, **data: Any) -> None:
bundle = {"application/x-omp-status": {"op": op, **data}}
rid = _STATE.current_id
rid = _CURRENT_RID.get()
if rid is None:
return
_emit({"type": "display", "id": rid, "bundle": bundle})
@@ -424,7 +428,7 @@ def _magic_reset(_args: str) -> None:
def _magic_load(args: str) -> None:
path = Path(os.path.expanduser(args.strip()))
source = path.read_text(encoding="utf-8")
_emit({"type": "display", "id": _STATE.current_id, "bundle": {"text/plain": source}})
_emit({"type": "display", "id": _CURRENT_RID.get(), "bundle": {"text/plain": source}})
_exec_source(source, _STATE.user_ns)
@@ -622,7 +626,7 @@ def _mime_bundle(value: Any) -> dict:
def _emit_display(bundle: dict, *, kind: str = "display") -> None:
rid = _STATE.current_id
rid = _CURRENT_RID.get()
if rid is None:
return
_emit({"type": kind, "id": rid, "bundle": bundle})
@@ -681,6 +685,7 @@ def _install_builtins(ns: dict) -> None:
ns["__omp_magic"] = __omp_magic
ns["__omp_magic_cell"] = __omp_magic_cell
ns["__omp_shell"] = __omp_shell
ns["__omp_current_run_id__"] = lambda: _CURRENT_RID.get()
_install_builtins(_STATE.user_ns)
@@ -694,25 +699,20 @@ _install_builtins(_STATE.user_ns)
_TLA_FLAG = getattr(ast, "PyCF_ALLOW_TOP_LEVEL_AWAIT", 0x2000)
def _get_event_loop() -> asyncio.AbstractEventLoop:
loop = _STATE.loop
if loop is None or loop.is_closed():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
_STATE.loop = loop
return loop
def _await_sync(coro) -> Any:
try:
running_loop = asyncio.get_running_loop()
except RuntimeError:
running_loop = None
if running_loop is not None and running_loop.is_running():
raise RuntimeError("top-level await is not supported from synchronous magic execution")
return asyncio.run(coro)
def _run_compiled(code, ns: dict, *, want_value: bool) -> Any:
"""Execute a code object, awaiting it if compiled as a coroutine.
``want_value`` is True for the trailing expression — we return ``eval``'s
result (or the awaited coroutine's value). For statement blocks the
return is always ``None``.
"""
def _run_compiled_sync(code, ns: dict, *, want_value: bool) -> Any:
"""Synchronous execution path used by nested magic helpers."""
if code.co_flags & inspect.CO_COROUTINE:
coro = eval(code, ns)
result = _get_event_loop().run_until_complete(coro)
result = _await_sync(eval(code, ns))
return result if want_value else None
if want_value:
return eval(code, ns)
@@ -720,15 +720,27 @@ def _run_compiled(code, ns: dict, *, want_value: bool) -> Any:
return None
def _exec_source(source: str, ns: dict) -> None:
"""Compile + execute ``source``; if the last node is an expression, route
its value through ``__omp_display`` so dataframes/figures render rich.
Top-level ``await`` / ``async for`` / ``async with`` is permitted; the
cell is driven through the runner's persistent event loop."""
module = ast.parse(source, mode="exec")
async def _run_compiled_async(code, ns: dict, *, want_value: bool) -> Any:
"""Execute a code object in the persistent event loop.
Coroutine code is awaited in this task so top-level ``await`` interleaves
with sibling requests. Plain statement/expression code runs on the main
runner thread so SIGINT can interrupt it reliably.
"""
if code.co_flags & inspect.CO_COROUTINE:
result = await eval(code, ns)
return result if want_value else None
if want_value:
return eval(code, ns)
exec(code, ns)
return None
def _compile_source(source: str) -> tuple[Any, Any | None, bool]:
module = ast.parse(source, mode="exec")
if not module.body:
return
return None, None, False
last = module.body[-1]
if isinstance(last, ast.Expr):
@@ -737,14 +749,36 @@ def _exec_source(source: str, ns: dict) -> None:
ast.copy_location(expr_module, last)
body_code = compile(body_module, "<cell>", "exec", flags=_TLA_FLAG)
expr_code = compile(expr_module, "<cell>", "eval", flags=_TLA_FLAG)
_run_compiled(body_code, ns, want_value=False)
value = _run_compiled(expr_code, ns, want_value=True)
return body_code, expr_code, True
return compile(module, "<cell>", "exec", flags=_TLA_FLAG), None, False
def _exec_source(source: str, ns: dict) -> None:
"""Synchronous source execution for legacy magic helpers."""
body_code, expr_code, has_expr = _compile_source(source)
if body_code is None:
return
_run_compiled_sync(body_code, ns, want_value=False)
if has_expr and expr_code is not None:
value = _run_compiled_sync(expr_code, ns, want_value=True)
if value is not None:
__omp_display(value, kind="result")
return
code = compile(module, "<cell>", "exec", flags=_TLA_FLAG)
_run_compiled(code, ns, want_value=False)
async def _exec_source_async(source: str, ns: dict) -> None:
"""Compile + execute ``source``; if the last node is an expression, route
its value through ``__omp_display`` so dataframes/figures render rich.
Top-level ``await`` / ``async for`` / ``async with`` is permitted; awaited
regions yield to other requests in the runner's persistent event loop."""
body_code, expr_code, has_expr = _compile_source(source)
if body_code is None:
return
await _run_compiled_async(body_code, ns, want_value=False)
if has_expr and expr_code is not None:
value = await _run_compiled_async(expr_code, ns, want_value=True)
if value is not None:
__omp_display(value, kind="result")
# ---------------------------------------------------------------------------
@@ -767,6 +801,46 @@ def _install_exec_sigint() -> None:
pass
def _begin_exec_sigint() -> None:
_STATE.active_executions += 1
_install_exec_sigint()
def _end_exec_sigint() -> None:
if _STATE.active_executions > 0:
_STATE.active_executions -= 1
if _STATE.active_executions == 0:
_install_idle_sigint()
_MANAGED_ENV_KEYS = (
"PI_SESSION_FILE",
"PI_ARTIFACTS_DIR",
"PI_TOOL_BRIDGE_URL",
"PI_TOOL_BRIDGE_TOKEN",
"PI_TOOL_BRIDGE_SESSION",
)
def _apply_request_runtime(req: dict) -> None:
cwd = req.get("cwd")
if isinstance(cwd, str) and cwd:
os.chdir(cwd)
try:
sys.path.remove(cwd)
except ValueError:
pass
sys.path.insert(0, cwd)
env = req.get("env")
if isinstance(env, dict):
for key in _MANAGED_ENV_KEYS:
value = env.get(key)
if isinstance(value, str):
os.environ[key] = value
elif value is None:
os.environ.pop(key, None)
def _start_parent_watchdog() -> None:
"""Self-terminate when the host process dies.
@@ -802,61 +876,72 @@ def _start_parent_watchdog() -> None:
# ---------------------------------------------------------------------------
def _handle_request(req: dict) -> None:
if req.get("type") == "exit":
sys.exit(0)
async def _handle_request_async(req: dict) -> None:
rid = str(req.get("id"))
code = req.get("code", "")
_STATE.current_id = rid
token = _CURRENT_RID.set(rid)
_STATE.user_ns["__omp_run_id__"] = rid
_STATE.cancel_requested = False
_STATE.execution_count += 1
execution_count = _STATE.execution_count
_emit({"type": "started", "id": rid})
status: str = "ok"
cancelled = False
try:
transformed = transform_cell(code)
except SyntaxError as exc:
_emit_error(rid, exc)
try:
_apply_request_runtime(req)
transformed = transform_cell(req.get("code", ""))
except SyntaxError as exc:
_emit_error(rid, exc)
_emit({
"type": "done",
"id": rid,
"status": "error",
"executionCount": execution_count,
"cancelled": False,
})
return
except BaseException as exc: # noqa: BLE001 - runtime setup errors must settle the request
_emit_error(rid, exc)
_emit({
"type": "done",
"id": rid,
"status": "error",
"executionCount": execution_count,
"cancelled": False,
})
return
_begin_exec_sigint()
try:
await _exec_source_async(transformed, _STATE.user_ns)
except KeyboardInterrupt:
cancelled = True
status = "error"
_emit_error(rid, KeyboardInterrupt("Execution interrupted"))
except SystemExit as exc:
status = "error"
_emit_error(rid, exc)
except BaseException as exc: # noqa: BLE001 - we want to surface every user error
status = "error"
_emit_error(rid, exc)
finally:
_end_exec_sigint()
try:
_flush_matplotlib_figures()
except Exception:
pass
_emit({
"type": "done",
"id": rid,
"status": "error",
"executionCount": _STATE.execution_count,
"cancelled": False,
"status": status,
"executionCount": execution_count,
"cancelled": cancelled,
})
_STATE.current_id = None
return
_install_exec_sigint()
try:
_exec_source(transformed, _STATE.user_ns)
except KeyboardInterrupt:
cancelled = True
status = "error"
_emit_error(rid, KeyboardInterrupt("Execution interrupted"))
except SystemExit:
raise
except BaseException as exc: # noqa: BLE001 - we want to surface every user error
status = "error"
_emit_error(rid, exc)
finally:
_install_idle_sigint()
try:
_flush_matplotlib_figures()
except Exception:
pass
_emit({
"type": "done",
"id": rid,
"status": status,
"executionCount": _STATE.execution_count,
"cancelled": cancelled,
})
_STATE.current_id = None
_CURRENT_RID.reset(token)
def _emit_error(rid: str, exc: BaseException) -> None:
@@ -875,16 +960,7 @@ def _emit_error(rid: str, exc: BaseException) -> None:
# ---------------------------------------------------------------------------
def main() -> None:
sys.stdout = _StreamProxy("stdout")
sys.stderr = _StreamProxy("stderr")
_install_idle_sigint()
_start_parent_watchdog()
stdin = sys.__stdin__
if stdin is None:
return
def _read_stdin(loop: asyncio.AbstractEventLoop, queue: asyncio.Queue, stdin) -> None:
for raw_line in stdin:
line = raw_line.strip()
if not line:
@@ -900,10 +976,52 @@ def main() -> None:
"traceback": [],
})
continue
loop.call_soon_threadsafe(queue.put_nowait, req)
loop.call_soon_threadsafe(queue.put_nowait, {"type": "exit"})
async def _main_async() -> None:
sys.stdout = _StreamProxy("stdout")
sys.stderr = _StreamProxy("stderr")
_install_idle_sigint()
_start_parent_watchdog()
stdin = sys.__stdin__
if stdin is None:
return
loop = asyncio.get_running_loop()
_STATE.loop = loop
queue: asyncio.Queue = asyncio.Queue()
reader = threading.Thread(target=_read_stdin, args=(loop, queue, stdin), name="omp-stdin-reader", daemon=True)
reader.start()
tasks: set[asyncio.Task] = set()
def _task_done(task: asyncio.Task) -> None:
tasks.discard(task)
try:
_handle_request(req)
except SystemExit:
exc = task.exception()
except asyncio.CancelledError:
return
if exc is not None:
_emit_error("", exc)
try:
while True:
req = await queue.get()
if req.get("type") == "exit":
break
task = asyncio.create_task(_handle_request_async(req))
tasks.add(task)
task.add_done_callback(_task_done)
finally:
for task in tasks:
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
def main() -> None:
asyncio.run(_main_async())
if __name__ == "__main__":
@@ -44,21 +44,23 @@ async function startServer(): Promise<BridgeServer> {
return new Response("Forbidden", { status: 403 });
}
let body: { session?: unknown; name?: unknown; args?: unknown };
let body: { session?: unknown; run?: unknown; name?: unknown; args?: unknown };
try {
body = (await req.json()) as { session?: unknown; name?: unknown; args?: unknown };
body = (await req.json()) as { session?: unknown; run?: unknown; name?: unknown; args?: unknown };
} catch {
return Response.json({ ok: false, error: "Invalid JSON body" }, { status: 400 });
}
const sessionId = typeof body.session === "string" ? body.session : "";
const runId = typeof body.run === "string" ? body.run : "";
const name = typeof body.name === "string" ? body.name : "";
if (!sessionId || !name) {
return Response.json({ ok: false, error: "Missing session/name" }, { status: 400 });
if (!sessionId || !runId || !name) {
return Response.json({ ok: false, error: "Missing session/run/name" }, { status: 400 });
}
const entry = registrations.get(sessionId);
const registrationKey = bridgeRegistrationKey(sessionId, runId);
const entry = registrations.get(registrationKey) ?? registrations.get(sessionId);
if (!entry) {
return Response.json(
{ ok: false, error: `No active Python tool bridge session: ${sessionId}` },
{ ok: false, error: `No active Python tool bridge session: ${registrationKey}` },
{ status: 200 },
);
}
@@ -111,11 +113,16 @@ export async function ensurePyToolBridge(): Promise<PyToolBridgeInfo> {
* Register a tool session for the duration of one execution. The returned
* function MUST be called to remove the entry once execution finishes.
*/
export function registerPyToolBridge(sessionId: string, entry: PyToolBridgeEntry): () => void {
registrations.set(sessionId, entry);
function bridgeRegistrationKey(sessionId: string, runId: string): string {
return `${sessionId}:${runId}`;
}
export function registerPyToolBridge(sessionId: string, runId: string, entry: PyToolBridgeEntry): () => void {
const key = bridgeRegistrationKey(sessionId, runId);
registrations.set(key, entry);
return () => {
if (registrations.get(sessionId) === entry) {
registrations.delete(sessionId);
if (registrations.get(key) === entry) {
registrations.delete(key);
}
};
}
@@ -0,0 +1,8 @@
import type { ToolSession } from "../tools";
export type EvalSessionSource = Pick<ToolSession, "cwd" | "getSessionFile">;
export function defaultEvalSessionId(session: EvalSessionSource): string {
const sessionFile = session.getSessionFile?.() ?? undefined;
return sessionFile ? `session:${sessionFile}:cwd:${session.cwd}` : `cwd:${session.cwd}`;
}
@@ -462,7 +462,6 @@ export class ExtensionRunner {
hasPendingMessages: () => this.#hasPendingMessagesFn(),
shutdown: () => this.#shutdownHandler(),
getSystemPrompt: () => this.#getSystemPromptFn(),
hasQueuedMessages: () => this.#hasPendingMessagesFn(), // deprecated alias
};
}
@@ -283,8 +283,6 @@ export interface ExtensionContext {
shutdown(): void;
/** Get the current effective system prompt. */
getSystemPrompt(): string[];
/** @deprecated Use hasPendingMessages() instead */
hasQueuedMessages(): boolean;
}
/**
+56 -65
View File
@@ -1,113 +1,104 @@
import { formatCodeFrameLine } from "../tools/render-utils";
import { MISMATCH_CONTEXT } from "./constants";
import { computeLineHash, describeAnchorExamples, HL_ANCHOR_RE_RAW, HL_BODY_SEP } from "./hash";
import type { HashMismatch } from "./types";
import { formatNumberedLine, HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./hash";
const HL_HASH_HINT_RE = /^[a-z]{2}$/i;
const HL_ANCHOR_EXAMPLES = describeAnchorExamples("160");
const PARSE_TAG_RE = new RegExp(`^${HL_ANCHOR_RE_RAW}`);
const LINE_REF_RE = /^\s*[>+\-*]*\s*(\d+)(?::.*)?\s*$/;
export function formatFullAnchorRequirement(raw?: string): string {
const suffix = typeof raw === "string" ? raw.trim() : "";
const hashOnlyHint = HL_HASH_HINT_RE.test(suffix)
? ` It looks like you supplied only the hash suffix (${JSON.stringify(suffix)}). ` +
`Copy the full anchor exactly as shown (for example, "160${suffix}").`
: "";
const received = raw === undefined ? "" : ` Received ${JSON.stringify(raw)}.`;
return (
`the full anchor exactly as shown by read/search output ` +
`(line number + hash, for example ${HL_ANCHOR_EXAMPLES})${received}${hashOnlyHint}`
`a bare line number from read/search output plus the section header file hash ` +
`(for example ${HL_FILE_PREFIX}src/foo.ts${HL_FILE_HASH_SEP}1a2b and line "160")${received}`
);
}
export function parseTag(ref: string): { line: number; hash: string } {
const match = ref.match(PARSE_TAG_RE);
export function parseTag(ref: string): { line: number } {
const match = ref.match(LINE_REF_RE);
if (!match) {
throw new Error(`Invalid line reference. Expected ${formatFullAnchorRequirement(ref)}.`);
}
const line = Number.parseInt(match[1], 10);
if (line < 1) throw new Error(`Line number must be >= 1, got ${line} in "${ref}".`);
return { line, hash: match[2] };
return { line };
}
function getMismatchDisplayLines(mismatches: HashMismatch[], fileLines: string[]): number[] {
export interface HashlineMismatchDetails {
path?: string;
expectedFileHash: string;
actualFileHash: string;
fileLines: string[];
anchorLines?: readonly number[];
}
function getMismatchDisplayLines(anchorLines: readonly number[], fileLines: string[]): number[] {
const displayLines = new Set<number>();
for (const mismatch of mismatches) {
const lo = Math.max(1, mismatch.line - MISMATCH_CONTEXT);
const hi = Math.min(fileLines.length, mismatch.line + MISMATCH_CONTEXT);
for (const line of anchorLines) {
if (line < 1 || line > fileLines.length) continue;
const lo = Math.max(1, line - MISMATCH_CONTEXT);
const hi = Math.min(fileLines.length, line + MISMATCH_CONTEXT);
for (let lineNum = lo; lineNum <= hi; lineNum++) displayLines.add(lineNum);
}
return [...displayLines].sort((a, b) => a - b);
}
export class HashlineMismatchError extends Error {
readonly remaps: ReadonlyMap<string, string>;
readonly path: string | undefined;
readonly expectedFileHash: string;
readonly actualFileHash: string;
readonly fileLines: string[];
readonly anchorLines: readonly number[];
constructor(
public readonly mismatches: HashMismatch[],
public readonly fileLines: string[],
) {
super(HashlineMismatchError.formatMessage(mismatches, fileLines));
constructor(details: HashlineMismatchDetails) {
super(HashlineMismatchError.formatMessage(details));
this.name = "HashlineMismatchError";
const remaps = new Map<string, string>();
for (const mismatch of mismatches) {
const actual = computeLineHash(mismatch.line, fileLines[mismatch.line - 1] ?? "");
remaps.set(`${mismatch.line}${mismatch.expected}`, `${mismatch.line}${actual}`);
}
this.remaps = remaps;
this.path = details.path;
this.expectedFileHash = details.expectedFileHash;
this.actualFileHash = details.actualFileHash;
this.fileLines = details.fileLines;
this.anchorLines = details.anchorLines ?? [];
}
get displayMessage(): string {
return HashlineMismatchError.formatDisplayMessage(this.mismatches, this.fileLines);
return HashlineMismatchError.formatDisplayMessage({
path: this.path,
expectedFileHash: this.expectedFileHash,
actualFileHash: this.actualFileHash,
fileLines: this.fileLines,
anchorLines: this.anchorLines,
});
}
private static rejectionHeader(mismatches: HashMismatch[]): string[] {
const noun = mismatches.length > 1 ? "anchors do" : "anchor does";
static rejectionHeader(details: HashlineMismatchDetails): string[] {
const pathText = details.path ? ` for ${details.path}` : "";
return [
`Edit rejected: ${mismatches.length} ${noun} not match the current file (marked *).`,
"The edit was NOT applied, please use the updated file content shown below, and issue another edit tool-call.",
`Edit rejected${pathText}: file changed between read and edit.`,
`Section is bound to ${HL_FILE_HASH_SEP}${details.expectedFileHash}, but the current file hashes to ${HL_FILE_HASH_SEP}${details.actualFileHash}; re-read and try again.`,
];
}
static formatDisplayMessage(mismatches: HashMismatch[], fileLines: string[]): string {
const mismatchSet = new Set<number>(mismatches.map(m => m.line));
const displayLines = getMismatchDisplayLines(mismatches, fileLines);
const width = displayLines.reduce((cur, n) => Math.max(cur, String(n).length), 0);
const out = [...HashlineMismatchError.rejectionHeader(mismatches), ""];
let previous = -1;
for (const lineNum of displayLines) {
if (previous !== -1 && lineNum > previous + 1) out.push("...");
previous = lineNum;
const marker = mismatchSet.has(lineNum) ? "*" : " ";
out.push(formatCodeFrameLine(marker, lineNum, fileLines[lineNum - 1] ?? "", width));
}
return out.join("\n");
static formatDisplayMessage(details: HashlineMismatchDetails): string {
return HashlineMismatchError.formatMessage(details);
}
static formatMessage(mismatches: HashMismatch[], fileLines: string[]): string {
const mismatchSet = new Set<number>(mismatches.map(m => m.line));
const lines = HashlineMismatchError.rejectionHeader(mismatches);
static formatMessage(details: HashlineMismatchDetails): string {
const anchorSet = new Set(details.anchorLines ?? []);
const lines = HashlineMismatchError.rejectionHeader(details);
const displayLines = getMismatchDisplayLines(details.anchorLines ?? [], details.fileLines);
if (displayLines.length === 0) return lines.join("\n");
lines.push("");
let previous = -1;
for (const lineNum of getMismatchDisplayLines(mismatches, fileLines)) {
for (const lineNum of displayLines) {
if (previous !== -1 && lineNum > previous + 1) lines.push("...");
previous = lineNum;
const text = fileLines[lineNum - 1] ?? "";
const hash = computeLineHash(lineNum, text);
const marker = mismatchSet.has(lineNum) ? "*" : " ";
lines.push(`${marker}${lineNum}${hash}${HL_BODY_SEP}${text}`);
const text = details.fileLines[lineNum - 1] ?? "";
const marker = anchorSet.has(lineNum) ? "*" : " ";
lines.push(`${marker}${formatNumberedLine(lineNum, text)}`);
}
return lines.join("\n");
}
}
export function validateLineRef(ref: { line: number; hash: string }, fileLines: string[]): void {
export function validateLineRef(ref: { line: number }, fileLines: string[]): void {
if (ref.line < 1 || ref.line > fileLines.length) {
throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`);
}
const actualHash = computeLineHash(ref.line, fileLines[ref.line - 1] ?? "");
if (actualHash !== ref.hash) {
throw new HashlineMismatchError([{ line: ref.line, expected: ref.hash, actual: actualHash }], fileLines);
}
}
+29 -31
View File
@@ -1,8 +1,5 @@
import { HashlineMismatchError } from "./anchors";
import { RANGE_INTERIOR_HASH } from "./constants";
import { computeLineHash } from "./hash";
import { cloneCursor } from "./parser";
import type { Anchor, HashlineApplyOptions, HashlineCursor, HashlineEdit, HashMismatch } from "./types";
import { cloneCursor } from "./tokenizer";
import type { Anchor, HashlineApplyOptions, HashlineCursor, HashlineEdit } from "./types";
export interface HashlineApplyResult {
lines: string;
@@ -43,26 +40,17 @@ function getHashlineEditAnchors(edit: HashlineEdit): Anchor[] {
}
/**
* Verify every anchor's hash. Any mismatch is reported as a `HashMismatch`;
* there is no auto-rebase. Callers are expected to surface mismatches as
* `HashlineMismatchError` so the model re-reads and re-anchors.
* Verify every anchored edit points at an existing line. File-version binding is
* checked once per section via the header hash before this function runs.
*/
function validateHashlineAnchors(edits: HashlineEdit[], fileLines: string[]): HashMismatch[] {
const mismatches: HashMismatch[] = [];
function validateHashlineLineBounds(edits: HashlineEdit[], fileLines: string[]): void {
for (const edit of edits) {
for (const anchor of getHashlineEditAnchors(edit)) {
if (anchor.line < 1 || anchor.line > fileLines.length) {
throw new Error(`Line ${anchor.line} does not exist (file has ${fileLines.length} lines)`);
}
if (anchor.hash === RANGE_INTERIOR_HASH) continue;
const actualHash = computeLineHash(anchor.line, fileLines[anchor.line - 1] ?? "");
if (actualHash === anchor.hash) continue;
mismatches.push({ line: anchor.line, expected: anchor.hash, actual: actualHash });
}
}
return mismatches;
}
function insertAtStart(fileLines: string[], lineOrigins: HashlineLineOrigin[], lines: string[]): void {
@@ -287,15 +275,10 @@ function contiguousRange(start: number, count: number): number[] {
return Array.from({ length: count }, (_, offset) => start + offset);
}
function deleteEditForAutoAbsorbedLine(
line: number,
sourceLineNum: number,
index: number,
fileLines: string[],
): HashlineEdit {
function deleteEditForAutoAbsorbedLine(line: number, sourceLineNum: number, index: number): HashlineEdit {
return {
kind: "delete",
anchor: { line, hash: computeLineHash(line, fileLines[line - 1] ?? "") },
anchor: { line },
lineNum: sourceLineNum,
index,
};
@@ -314,7 +297,7 @@ function cursorMatches(a: HashlineCursor, b: HashlineCursor): boolean {
if (a.kind === "bof" || a.kind === "eof") return true;
const aAnchor = (a as { anchor: Anchor }).anchor;
const bAnchor = (b as { anchor: Anchor }).anchor;
return aAnchor.line === bAnchor.line && aAnchor.hash === bAnchor.hash;
return aAnchor.line === bAnchor.line;
}
/**
@@ -606,13 +589,13 @@ function absorbReplacementBoundaryDuplicates(
}
for (const line of contiguousRange(startLine - safePrefixCount, safePrefixCount)) {
absorbed.push(deleteEditForAutoAbsorbedLine(line, group.sourceLineNum, nextSyntheticIndex++, fileLines));
absorbed.push(deleteEditForAutoAbsorbedLine(line, group.sourceLineNum, nextSyntheticIndex++));
}
for (let groupIndex = group.startIndex; groupIndex <= group.endIndex; groupIndex++) {
absorbed.push(edits[groupIndex]);
}
for (const line of contiguousRange(endLine + 1, safeSuffixCount)) {
absorbed.push(deleteEditForAutoAbsorbedLine(line, group.sourceLineNum, nextSyntheticIndex++, fileLines));
absorbed.push(deleteEditForAutoAbsorbedLine(line, group.sourceLineNum, nextSyntheticIndex++));
}
index = group.endIndex;
@@ -653,8 +636,7 @@ export function applyHashlineEdits(
if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line;
};
const mismatches = validateHashlineAnchors(edits, fileLines);
if (mismatches.length > 0) throw new HashlineMismatchError(mismatches, fileLines);
validateHashlineLineBounds(edits, fileLines);
const normalizedEdits = absorbReplacementBoundaryDuplicates(edits, fileLines, warnings, options);
@@ -669,10 +651,9 @@ export function applyHashlineEdits(
continue;
}
const nextLineNum = anchorLine + 1;
const nextContent = fileLines[nextLineNum - 1] ?? "";
edit.cursor = {
kind: "before_anchor",
anchor: { line: nextLineNum, hash: computeLineHash(nextLineNum, nextContent) },
anchor: { line: nextLineNum },
};
}
@@ -711,6 +692,23 @@ export function applyHashlineEdits(
}
if (beforeLines.length === 0 && !deleteLine) continue;
const replaceMode = beforeLines.length > 0;
if (deleteLine && !replaceMode) {
const balance = computeDelimiterBalance([currentLine]);
const trimmedCurrentLine = currentLine.trim();
const touchesStructuralBoundary =
trimmedCurrentLine.startsWith(")") ||
trimmedCurrentLine.startsWith("]") ||
trimmedCurrentLine.startsWith("}") ||
trimmedCurrentLine.endsWith("(") ||
trimmedCurrentLine.endsWith("[") ||
trimmedCurrentLine.endsWith("{");
if (balance.paren !== 0 || balance.bracket !== 0 || balance.brace !== 0 || touchesStructuralBoundary) {
warnings.push(
`Deleted line ${line} contains a structural bracket/brace boundary (${JSON.stringify(trimmedCurrentLine)}); verify the file is still balanced or use 'A:<replacement>' to keep the boundary intact.`,
);
}
}
const replacement = deleteLine ? beforeLines : [...beforeLines, currentLine];
const origins = replacement.map((): HashlineLineOrigin => (deleteLine ? "replacement" : "insert"));
if (!deleteLine) {
@@ -1,9 +1,6 @@
/** Lines of context shown either side of a hash mismatch. */
export const MISMATCH_CONTEXT = 2;
/** Filler hash used for the interior of a multi-line range; not validated. */
export const RANGE_INTERIOR_HASH = "**";
/** Optional patch envelope start marker; silently consumed when present. */
export const BEGIN_PATCH_MARKER = "*** Begin Patch";
@@ -1,4 +1,3 @@
import { computeLineHash, HL_BODY_SEP } from "./hash";
import type { CompactHashlineDiffOptions, CompactHashlineDiffPreview } from "./types";
export function buildCompactHashlineDiffPreview(
@@ -11,7 +10,7 @@ export function buildCompactHashlineDiffPreview(
// `generateDiffString` numbers `+` lines with the post-edit line number,
// `-` lines with the pre-edit line number, and context lines with the
// pre-edit line number. To emit fresh anchors usable for follow-up edits,
// pre-edit line number. To emit fresh line numbers usable for follow-up edits,
// we convert context-line numbers to post-edit positions by tracking the
// running offset (added so far - removed so far) as we walk the diff.
const formatted = lines.map(line => {
@@ -28,13 +27,13 @@ export function buildCompactHashlineDiffPreview(
switch (kind) {
case "+":
addedLines++;
return `+${lineNumber}${computeLineHash(lineNumber, content)}${HL_BODY_SEP}${content}`;
return `+${lineNumber}:${content}`;
case "-":
removedLines++;
return `-${lineNumber}--${HL_BODY_SEP}${content}`;
return `-${lineNumber}:${content}`;
default: {
const newLineNumber = lineNumber + addedLines - removedLines;
return ` ${newLineNumber}${computeLineHash(newLineNumber, content)}${HL_BODY_SEP}${content}`;
return ` ${newLineNumber}:${content}`;
}
}
});
+30 -4
View File
@@ -3,9 +3,10 @@ import { normalizeToLF, stripBom } from "../edit/normalize";
import { readEditFileText } from "../edit/read-file";
import { resolveToCwd } from "../tools/path-utils";
import { applyHashlineEdits } from "./apply";
import { type HashlineInputSection, splitHashlineInputs } from "./input";
import { parseHashline } from "./parser";
import type { HashlineApplyOptions } from "./types";
import { parseHashline } from "./executor";
import { computeFileHash } from "./hash";
import { splitHashlineInputs } from "./input";
import type { HashlineApplyOptions, HashlineEdit, HashlineInputSection } from "./types";
async function readHashlineFileText(
_file: { text(): Promise<string> },
@@ -20,6 +21,28 @@ async function readHashlineFileText(
}
}
function hasAnchorScopedEdit(edits: readonly HashlineEdit[]): boolean {
return edits.some(edit => {
if (edit.kind === "delete") return true;
return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
});
}
function validateSectionHash(
section: HashlineInputSection,
text: string,
edits: readonly HashlineEdit[],
): string | null {
if (section.fileHash === undefined) {
return hasAnchorScopedEdit(edits)
? `Missing hashline file hash for anchored edit to ${section.path}; use \`${section.path}#hash\` from your latest read.`
: null;
}
const currentHash = computeFileHash(text);
if (currentHash === section.fileHash) return null;
return `Hashline file hash mismatch for ${section.path}: section is bound to #${section.fileHash}, but current file hashes to #${currentHash}; re-read and try again.`;
}
export async function computeHashlineSectionDiff(
section: HashlineInputSection,
cwd: string,
@@ -30,7 +53,10 @@ export async function computeHashlineSectionDiff(
const rawContent = await readHashlineFileText(Bun.file(absolutePath), absolutePath, section.path);
const { text: content } = stripBom(rawContent);
const normalized = normalizeToLF(content);
const result = applyHashlineEdits(normalized, parseHashline(section.diff), options);
const { edits } = parseHashline(section.diff);
const hashError = validateSectionHash(section, normalized, edits);
if (hashError) return { error: hashError };
const result = applyHashlineEdits(normalized, edits, options);
if (normalized === result.lines) return { error: `No changes would be made to ${section.path}.` };
return generateDiffString(normalized, result.lines);
} catch (err) {
+91 -26
View File
@@ -13,13 +13,15 @@ import { enforcePlanModeWrite, resolvePlanPath } from "../tools/plan-mode-guard"
import { HashlineMismatchError } from "./anchors";
import { applyHashlineEdits, type HashlineApplyResult } from "./apply";
import { buildCompactHashlineDiffPreview } from "./diff-preview";
import { type HashlineInputSection, splitHashlineInputs } from "./input";
import { parseHashlineWithWarnings } from "./parser";
import { parseHashline } from "./executor";
import { computeFileHash } from "./hash";
import { splitHashlineInputs } from "./input";
import { tryRecoverHashlineWithCache } from "./recovery";
import type {
ExecuteHashlineSingleOptions,
HashlineApplyOptions,
HashlineEdit,
HashlineInputSection,
hashlineEditParamsSchema,
} from "./types";
@@ -46,6 +48,27 @@ function hasAnchorScopedEdit(edits: HashlineEdit[]): boolean {
});
}
function collectAnchorLines(edits: HashlineEdit[]): number[] {
const lines = new Set<number>();
for (const edit of edits) {
if (edit.kind === "delete") {
lines.add(edit.anchor.line);
continue;
}
if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor") {
lines.add(edit.cursor.anchor.line);
}
}
return [...lines].sort((a, b) => a - b);
}
function assertSectionHashAllowed(sectionPath: string, fileHash: string | undefined, edits: HashlineEdit[]): void {
if (fileHash !== undefined || !hasAnchorScopedEdit(edits)) return;
throw new Error(
`Missing hashline file hash for anchored edit to ${sectionPath}; use \`${sectionPath}#hash\` from your latest read.`,
);
}
function formatNoChangeDiagnostic(pathText: string): string {
return `Edits to ${pathText} resulted in no changes being made.`;
}
@@ -65,36 +88,48 @@ function getEditDetails(result: AgentToolResult<EditToolDetails>): EditToolDetai
}
/**
* Apply hashline edits with anchor-stale recovery: on `HashlineMismatchError`,
* consult the read-snapshot cache for the file and 3-way-merge the edits onto
* the current text. If recovery succeeds, return the merged result with a
* synthetic warning. Otherwise re-throw the original mismatch error.
* Apply hashline edits with file-hash stale recovery. The section hash gates
* line-number edits against the version shown to the model; if the live file
* drifted, snapshot recovery attempts a strict 3-way merge.
*/
function applyHashlineEditsWithRecovery(
session: ToolSession,
absolutePath: string,
pathText: string,
text: string,
fileHash: string | undefined,
edits: HashlineEdit[],
options: HashlineApplyOptions,
): HashlineApplyResult {
try {
return applyHashlineEdits(text, edits, options);
} catch (err) {
if (!(err instanceof HashlineMismatchError)) throw err;
const recovered = tryRecoverHashlineWithCache({
cache: getFileReadCache(session),
absolutePath,
currentText: text,
edits,
options,
});
if (!recovered) throw err;
if (fileHash === undefined) return applyHashlineEdits(text, edits, options);
const currentHash = computeFileHash(text);
if (currentHash === fileHash) return applyHashlineEdits(text, edits, options);
const cache = getFileReadCache(session);
const recovered = tryRecoverHashlineWithCache({
cache,
absolutePath,
currentText: text,
fileHash,
edits,
options,
});
if (recovered) {
return {
lines: recovered.lines,
firstChangedLine: recovered.firstChangedLine,
warnings: recovered.warnings,
};
}
throw new HashlineMismatchError({
path: pathText,
expectedFileHash: fileHash,
actualFileHash: currentHash,
fileLines: text.split("\n"),
anchorLines: collectAnchorLines(edits),
});
}
/**
@@ -103,10 +138,11 @@ function applyHashlineEditsWithRecovery(
* any changes in a multi-section batch.
*/
async function preflightHashlineSection(options: ExecuteHashlineSingleOptions & HashlineInputSection): Promise<void> {
const { session, path: sectionPath, diff } = options;
const { session, path: sectionPath, fileHash, diff } = options;
const absolutePath = resolvePlanPath(session, sectionPath);
const { edits } = parseHashlineWithWarnings(diff);
const { edits } = parseHashline(diff);
assertSectionHashAllowed(sectionPath, fileHash, edits);
enforcePlanModeWrite(session, sectionPath, { op: "update" });
const source = await readHashlineFile(absolutePath, sectionPath);
@@ -118,7 +154,9 @@ async function preflightHashlineSection(options: ExecuteHashlineSingleOptions &
const result = applyHashlineEditsWithRecovery(
session,
absolutePath,
sectionPath,
normalized,
source.exists ? fileHash : undefined,
edits,
getHashlineApplyOptions(session),
);
@@ -131,6 +169,7 @@ async function executeHashlineSection(
const {
session,
path: sourcePath,
fileHash,
diff,
signal,
batchRequest,
@@ -139,7 +178,8 @@ async function executeHashlineSection(
} = options;
const absolutePath = resolvePlanPath(session, sourcePath);
const { edits, warnings: parseWarnings } = parseHashlineWithWarnings(diff);
const { edits, warnings: parseWarnings } = parseHashline(diff);
assertSectionHashAllowed(sourcePath, fileHash, edits);
enforcePlanModeWrite(session, sourcePath, { op: "update" });
const source = await readHashlineFile(absolutePath, sourcePath);
@@ -152,7 +192,9 @@ async function executeHashlineSection(
const result = applyHashlineEditsWithRecovery(
session,
absolutePath,
sourcePath,
originalNormalized,
source.exists ? fileHash : undefined,
edits,
getHashlineApplyOptions(session),
);
@@ -182,7 +224,10 @@ async function executeHashlineSection(
// of the file: the model just received it back as the diff/preview. Cache
// it so a follow-up edit anchored against this state can still recover
// if the file is touched out-of-band before the next edit lands.
getFileReadCache(session).recordContiguous(absolutePath, 1, result.lines.split("\n"));
getFileReadCache(session).recordContiguous(absolutePath, 1, result.lines.split("\n"), {
fullText: result.lines,
fileHash: computeFileHash(result.lines),
});
const diffResult = generateDiffString(originalNormalized, result.lines);
const meta = outputMeta()
@@ -257,11 +302,31 @@ export async function executeHashlineSingle(
* Path order is preserved by first occurrence.
*/
function mergeSamePathSections(sections: HashlineInputSection[]): HashlineInputSection[] {
const byPath = new Map<string, string[]>();
const byPath = new Map<string, { fileHash?: string; diffs: string[] }>();
for (const section of sections) {
const existing = byPath.get(section.path);
if (existing) existing.push(section.diff);
else byPath.set(section.path, [section.diff]);
if (existing) {
if (
existing.fileHash !== undefined &&
section.fileHash !== undefined &&
existing.fileHash !== section.fileHash
) {
throw new Error(
`Conflicting hashline file hashes for ${section.path}: #${existing.fileHash} and #${section.fileHash}. Re-read the file and retry with one current header.`,
);
}
if (existing.fileHash === undefined && section.fileHash !== undefined) existing.fileHash = section.fileHash;
existing.diffs.push(section.diff);
continue;
}
byPath.set(section.path, {
...(section.fileHash !== undefined ? { fileHash: section.fileHash } : {}),
diffs: [section.diff],
});
}
return Array.from(byPath, ([path, diffs]) => ({ path, diff: diffs.join("\n") }));
return Array.from(byPath, ([path, entry]) => ({
path,
...(entry.fileHash !== undefined ? { fileHash: entry.fileHash } : {}),
diff: entry.diffs.join("\n"),
}));
}

Some files were not shown because too many files have changed in this diff Show More