diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td new file mode 100644 index 000000000..772bd36cd --- /dev/null +++ b/.github/VOUCHED.td @@ -0,0 +1,107 @@ +# The list of vouched (or actively denounced) users for this repository. +# +# Only vouched users can open PRs here; unvouched/denounced PRs are +# auto-closed by .github/workflows/vouch-pr.yml (mitchellh/vouch check-pr). +# Issues are intentionally NOT gated here — robomp triages those. +# +# Write-access collaborators and bots are auto-allowed and need no entry. +# A denounced user ("-handle") is always blocked, even if also listed. +# +# Syntax: +# - One handle per line (without @), sorted alphabetically. +# - Optional platform prefix: `platform:username` (default platform: github). +# - Denounce by prefixing with minus: `-username` / `-platform:username`. +# - Optional free-text reason after a space following the handle. +# +# Maintainers manage this list by commenting `!vouch` / `!denounce [user]` +# on a discussion (see .github/workflows/vouch-manage.yml). +# +# Seed (2026-06-19): authors with >=2 merged PRs in the prior 6 months. +# Audit found 0 denounce-worthy actors; all reverts were maintainer +# technical rollbacks, not abuse. See the PR/commit history for provenance. + +a-glapinski +ak4153 +apoc +AsafMah +azais-corentin +basedcorp99 +BayLee4 +bjin +cagedbird043 +cexll +chan1103 +chuaaron +ckumar1 +daandden +daaximus +danzaio +DarkPhilosophy +DeprecatedLuke +djdembeck +dmarsh-gusto +elikoga +enieuwy +ForeverYoungPp +GratefulDave +H4vC +HabibPro1999 +handlecusion +haosenwang1018 +hezhiyang2000 +infernix +inprealpha +itertea +jchristman +jiwangyihao +kamafozilov +KamijoToma +Kukkerem +ldx +lederniermagicien +loftiskg +lyc-aon +makoMakoGo +masonc15 +maximhar +maxvisionai +metaphorics +MikeeI +Mokto +mouyase +muness +nnk97 +ogrodev +oldschoola +Parsifa1 +phanthh +pidevxplay +qfrtt +ravshansbox +rburketaylor +RensTillmann +riverpilot +romanalexander +RzNmKX +scarthread +segmentationf4u1t +shoucandanghehe +shyndman +sit +slact +smileynet +sundbp +superhedge22 +tc97222 +tdiant +tsagi2045 +turbomolli +usr-bin-roygbiv +vmcall +VoidChecksum +voiys +watzon +WodenJay +wolfiesch +zakhar-kogan +zamorakpds diff --git a/.github/workflows/vouch-manage.yml b/.github/workflows/vouch-manage.yml new file mode 100644 index 000000000..e7ce41b11 --- /dev/null +++ b/.github/workflows/vouch-manage.yml @@ -0,0 +1,44 @@ +name: Vouch (manage) + +# Let maintainers vouch/denounce/unvouch by commenting on a Discussion: +# !vouch vouch the discussion author +# !vouch @user [reason] vouch a specific user +# !denounce [@user] [reason] +# !unvouch [@user] +# Only collaborators with admin/maintain/write are honored (triage EXCLUDED; +# upstream's default `roles` includes triage, which we override below). +# +# Commits the VOUCHED.td change back to the default branch using the stock +# GITHUB_TOKEN (no GitHub App needed). NOTE: this works only while the default +# branch is UNPROTECTED — GITHUB_TOKEN cannot bypass branch protection. If you +# protect the branch later, switch back to a GitHub App token on a bypass list. + +on: + discussion_comment: + types: [created] + +# Serialize writes to VOUCHED.td so concurrent vouches don't clobber. +concurrency: + group: vouch-manage + cancel-in-progress: false + +permissions: + contents: write # commit VOUCHED.td + discussions: write # read the comment / acknowledge + +jobs: + manage: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: mitchellh/vouch/action/manage-by-discussion@v1 + with: + discussion-number: ${{ github.event.discussion.number }} + comment-node-id: ${{ github.event.comment.node_id }} + vouch-keyword: "!vouch" + denounce-keyword: "!denounce" + unvouch-keyword: "!unvouch" + roles: admin,maintain,write + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/vouch-pr.yml b/.github/workflows/vouch-pr.yml new file mode 100644 index 000000000..6afa86cdc --- /dev/null +++ b/.github/workflows/vouch-pr.yml @@ -0,0 +1,51 @@ +name: Vouch (PR gate) + +# Auto-close PRs from unvouched or denounced users. Issues are left alone +# (robomp triages those). Runs under `pull_request_target` so the token can +# act on fork PRs; this job does NO checkout and runs NO PR code — it only +# reads .github/VOUCHED.td from the base repo and calls the GitHub API. + +on: + pull_request_target: + types: [opened, reopened, ready_for_review] + +permissions: + contents: read # read VOUCHED.td from the base branch + pull-requests: write # close + comment + issues: write # add the `vouched` label (labels use the Issues API) + +concurrency: + group: vouch-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + steps: + - id: vouch + uses: mitchellh/vouch/action/check-pr@v1 + with: + pr-number: ${{ github.event.pull_request.number }} + auto-close: true + require-vouch: true # block unvouched, not only denounced + # vouched-file: .github/VOUCHED.td (default) + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Survivors of the gate (vouched, or auto-allowed collaborators/bots) get + # a FRESH `vouched` label on every (re)open / ready-for-review. robomp + # reviews ONLY on that label event (ROBOMP_PR_REVIEW_TRIGGER=vouched_label), + # so review is always triggered by a just-validated PR, never a stale label. + - name: Label vouched PRs for robomp review + if: ${{ steps.vouch.outputs.status == 'vouched' || steps.vouch.outputs.status == 'allowed' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + PR: ${{ github.event.pull_request.number }} + run: | + gh label create vouched --repo "$REPO" --color 2da44e --description "Passed the vouch gate" --force + # remove+add so a fresh `labeled` event fires even when the label + # persisted across close/reopen (re-adding an existing label emits no + # event). The check above just re-validated, so trust is never stale. + gh pr edit "$PR" --repo "$REPO" --remove-label vouched || true + gh pr edit "$PR" --repo "$REPO" --add-label vouched diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..66f25b399 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,54 @@ +# Contributing to oh-my-pi + +Thanks for your interest in contributing. This project uses a lightweight +**vouch** system to decide who can open pull requests. Please read this before +opening a PR. + +## TL;DR + +- **Issues are open to everyone.** File bugs, feature requests, and questions + freely — they are triaged automatically. +- **Pull requests require a vouch.** A PR whose author is not vouched (or is + denounced) is **closed automatically**. If you are not yet vouched, do **not** + open a PR to get noticed — it will be closed on sight. Start a Discussion and + ask to be vouched first (see below). + +## Who can open PRs + +A pull request is accepted when its author is any of: + +- a repository collaborator (write access or above), or a bot; or +- listed — without a leading `-` — in [`.github/VOUCHED.td`](.github/VOUCHED.td). + +Anyone **denounced** (prefixed with `-` in that file) is always blocked. + +## Getting vouched + +1. Open a [Discussion](../../discussions) (or comment on an existing one) + describing what you'd like to contribute. +2. A maintainer vouches you by commenting **`!vouch`** (vouches the discussion + author) or **`!vouch @your-handle`** on that discussion. +3. Once you appear in `.github/VOUCHED.td`, open your PR — it stays open and is + reviewed. + +Maintainers may also `!denounce [@user]` and `!unvouch [@user]`. Only +collaborators with admin/maintain/write can run these commands. + +## What happens to your PR + +| You are… | Result | +| --- | --- | +| Vouched (or a collaborator) | PR stays open → automated review → human review | +| Not vouched | PR closed with a comment — get vouched, then reopen or open a new PR | +| Denounced | PR closed | + +Pushing more commits to an open, vouched PR is fine — it remains vouched. + +## The VOUCHED.td file + +[`.github/VOUCHED.td`](.github/VOUCHED.td) is the source of truth: one handle per +line, sorted alphabetically, optionally `platform:handle`, with `-` marking a +denouncement and an optional reason after the handle. The format follows +[mitchellh/vouch](https://github.com/mitchellh/vouch); the denouncement list is +intentionally public so other projects can reuse our prior knowledge of bad +actors. diff --git a/Cargo.lock b/Cargo.lock index fe4ab753a..d5ad6170e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2314,7 +2314,7 @@ dependencies = [ [[package]] name = "pi-ast" -version = "16.0.9" +version = "16.1.1" dependencies = [ "anyhow", "ast-grep-core", @@ -2384,7 +2384,7 @@ dependencies = [ [[package]] name = "pi-iso" -version = "16.0.9" +version = "16.1.1" dependencies = [ "async-trait", "libc", @@ -2396,7 +2396,7 @@ dependencies = [ [[package]] name = "pi-natives" -version = "16.0.9" +version = "16.1.1" dependencies = [ "anyhow", "arboard", @@ -2444,7 +2444,7 @@ dependencies = [ [[package]] name = "pi-shell" -version = "16.0.9" +version = "16.1.1" dependencies = [ "anyhow", "brush-builtins", diff --git a/Cargo.toml b/Cargo.toml index f5db25b92..ed20c2f43 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ exclude = ["crates/brush-core-vendored", "crates/brush-builtins-vendored"] resolver = "3" [workspace.package] -version = "16.0.9" +version = "16.1.1" edition = "2024" license = "MIT" authors = ["Can Boluk"] diff --git a/Dockerfile b/Dockerfile index da8583a13..2f7f8ffb3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,6 +49,7 @@ WORKDIR /pi # matched path under /pi/ (requires syntax 1.7-labs). COPY --parents \ package.json bun.lock bunfig.toml \ + patches/*.patch \ tsconfig.base.json tsconfig.json \ Cargo.toml Cargo.lock rust-toolchain.toml \ packages/*/package.json \ @@ -169,6 +170,7 @@ WORKDIR /pi # only re-runs when a package.json / lockfile changes. COPY --parents \ package.json bun.lock bunfig.toml \ + patches/*.patch \ tsconfig.base.json tsconfig.json \ packages/*/package.json \ packages/tsconfig.workspace.json \ diff --git a/Dockerfile.robomp b/Dockerfile.robomp index f79fca4b9..b6444dee2 100644 --- a/Dockerfile.robomp +++ b/Dockerfile.robomp @@ -28,6 +28,9 @@ WORKDIR /work # --filter robomp-web` to hydrate just the dashboard's node_modules. COPY package.json bun.lock ./ COPY python/robomp/web/package.json ./python/robomp/web/package.json +# Root package.json declares patchedDependencies, so the patch file must be +# present before any root-level `bun install` (even a filtered one). +COPY patches/*.patch ./patches/ RUN bun install --filter robomp-web COPY --exclude=node_modules --exclude=dist python/robomp/web/ ./python/robomp/web/ RUN bun --cwd=python/robomp/web run build diff --git a/README.md b/README.md index 823c14741..c129a182e 100644 --- a/README.md +++ b/README.md @@ -529,6 +529,15 @@ For architecture and contribution guidelines, see [packages/coding-agent/DEVELOP | **[brush-core-vendored](crates/brush-core-vendored)** | Vendored fork of [brush-shell](https://github.com/reubeno/brush) for embedded bash execution | | **[brush-builtins-vendored](crates/brush-builtins-vendored)** | Vendored bash builtins (cd, echo, test, printf, read, export, etc.) | +## Contributing + +Issues are open to everyone. **Pull requests require a vouch** — PRs from +unvouched or denounced authors are closed automatically. If you're not yet +vouched, open a [Discussion](https://github.com/can1357/oh-my-pi/discussions) +and ask a maintainer to `!vouch` you rather than opening a PR (which would be +closed on sight). See **[CONTRIBUTING.md](CONTRIBUTING.md)** and +[`.github/VOUCHED.td`](.github/VOUCHED.td) for the full policy. + --- ## License diff --git a/bun.lock b/bun.lock index 20aacdb03..bfd4f0b71 100644 --- a/bun.lock +++ b/bun.lock @@ -21,12 +21,13 @@ }, "packages/agent": { "name": "@oh-my-pi/pi-agent-core", - "version": "16.0.9", + "version": "16.1.1", "dependencies": { "@oh-my-pi/pi-ai": "catalog:", "@oh-my-pi/pi-catalog": "catalog:", "@oh-my-pi/pi-natives": "catalog:", "@oh-my-pi/pi-utils": "catalog:", + "@oh-my-pi/pi-wire": "catalog:", "@oh-my-pi/snapcompact": "catalog:", "@opentelemetry/api": "catalog:", }, @@ -38,11 +39,12 @@ }, "packages/ai": { "name": "@oh-my-pi/pi-ai", - "version": "16.0.9", + "version": "16.1.1", "dependencies": { "@bufbuild/protobuf": "catalog:", "@oh-my-pi/pi-catalog": "catalog:", "@oh-my-pi/pi-utils": "catalog:", + "@oh-my-pi/pi-wire": "catalog:", "arktype": "catalog:", "partial-json": "catalog:", "zod": "catalog:", @@ -53,7 +55,7 @@ }, "packages/catalog": { "name": "@oh-my-pi/pi-catalog", - "version": "16.0.9", + "version": "16.1.1", "dependencies": { "@bufbuild/protobuf": "catalog:", "@oh-my-pi/pi-utils": "catalog:", @@ -67,7 +69,7 @@ }, "packages/coding-agent": { "name": "@oh-my-pi/pi-coding-agent", - "version": "16.0.9", + "version": "16.1.1", "bin": { "omp": "src/cli.ts", }, @@ -135,7 +137,7 @@ }, "packages/hashline": { "name": "@oh-my-pi/hashline", - "version": "16.0.9", + "version": "16.1.1", "dependencies": { "diff": "catalog:", "lru-cache": "catalog:", @@ -146,7 +148,7 @@ }, "packages/mnemopi": { "name": "@oh-my-pi/pi-mnemopi", - "version": "16.0.9", + "version": "16.1.1", "bin": { "mnemopi": "src/cli.ts", }, @@ -172,7 +174,7 @@ }, "packages/natives": { "name": "@oh-my-pi/pi-natives", - "version": "16.0.9", + "version": "16.1.1", "devDependencies": { "@napi-rs/cli": "catalog:", "@types/bun": "catalog:", @@ -180,11 +182,12 @@ }, "packages/snapcompact": { "name": "@oh-my-pi/snapcompact", - "version": "16.0.9", + "version": "16.1.1", "dependencies": { "@oh-my-pi/pi-ai": "catalog:", "@oh-my-pi/pi-natives": "catalog:", "@oh-my-pi/pi-utils": "catalog:", + "@oh-my-pi/pi-wire": "catalog:", }, "devDependencies": { "@types/bun": "catalog:", @@ -192,7 +195,7 @@ }, "packages/stats": { "name": "@oh-my-pi/omp-stats", - "version": "16.0.9", + "version": "16.1.1", "bin": { "omp-stats": "./src/index.ts", }, @@ -218,7 +221,7 @@ }, "packages/swarm-extension": { "name": "@oh-my-pi/swarm-extension", - "version": "16.0.9", + "version": "16.1.1", "bin": { "omp-swarm": "src/cli.ts", }, @@ -234,7 +237,7 @@ }, "packages/tui": { "name": "@oh-my-pi/pi-tui", - "version": "16.0.9", + "version": "16.1.1", "dependencies": { "@oh-my-pi/pi-natives": "catalog:", "@oh-my-pi/pi-utils": "catalog:", @@ -275,7 +278,7 @@ }, "packages/utils": { "name": "@oh-my-pi/pi-utils", - "version": "16.0.9", + "version": "16.1.1", "dependencies": { "@oh-my-pi/pi-natives": "catalog:", "handlebars": "catalog:", @@ -288,7 +291,7 @@ }, "packages/wire": { "name": "@oh-my-pi/pi-wire", - "version": "16.0.9", + "version": "16.1.1", "devDependencies": { "@types/bun": "catalog:", }, @@ -324,18 +327,18 @@ "@huggingface/transformers": "^4.2.0", "@mozilla/readability": "^0.6.0", "@napi-rs/cli": "3.7.0", - "@oh-my-pi/hashline": "16.0.9", - "@oh-my-pi/omp-stats": "16.0.9", - "@oh-my-pi/pi-agent-core": "16.0.9", - "@oh-my-pi/pi-ai": "16.0.9", - "@oh-my-pi/pi-catalog": "16.0.9", - "@oh-my-pi/pi-coding-agent": "16.0.9", - "@oh-my-pi/pi-mnemopi": "16.0.9", - "@oh-my-pi/pi-natives": "16.0.9", - "@oh-my-pi/pi-tui": "16.0.9", - "@oh-my-pi/pi-utils": "16.0.9", - "@oh-my-pi/pi-wire": "16.0.9", - "@oh-my-pi/snapcompact": "16.0.9", + "@oh-my-pi/hashline": "16.1.1", + "@oh-my-pi/omp-stats": "16.1.1", + "@oh-my-pi/pi-agent-core": "16.1.1", + "@oh-my-pi/pi-ai": "16.1.1", + "@oh-my-pi/pi-catalog": "16.1.1", + "@oh-my-pi/pi-coding-agent": "16.1.1", + "@oh-my-pi/pi-mnemopi": "16.1.1", + "@oh-my-pi/pi-natives": "16.1.1", + "@oh-my-pi/pi-tui": "16.1.1", + "@oh-my-pi/pi-utils": "16.1.1", + "@oh-my-pi/pi-wire": "16.1.1", + "@oh-my-pi/snapcompact": "16.1.1", "@opentelemetry/api": "^1.9.1", "@opentelemetry/context-async-hooks": "^2.7.1", "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0", diff --git a/crates/pi-natives/src/lib.rs b/crates/pi-natives/src/lib.rs index 9444855e7..ec7c632b7 100644 --- a/crates/pi-natives/src/lib.rs +++ b/crates/pi-natives/src/lib.rs @@ -172,7 +172,7 @@ fn create_windows_napi_tokio_runtime() -> Option { /// 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 = "__piNativesV16_0_9")] +#[napi(js_name = "__piNativesV16_1_1")] pub const fn pi_natives_version_sentinel() {} /// Native module entry point: install crash diagnostics before any tool can diff --git a/crates/pi-shell/src/minimizer/engine.rs b/crates/pi-shell/src/minimizer/engine.rs index d228d8d6c..f7033ac53 100644 --- a/crates/pi-shell/src/minimizer/engine.rs +++ b/crates/pi-shell/src/minimizer/engine.rs @@ -1097,10 +1097,15 @@ strip_lines_matching = [".*"] } let out = apply("rails db:migrate", &input, 0, &cfg); assert!(out.changed); - // The def's max_lines=40 produces ONE truncation marker. - // filter_rake's head_tail marker ("lines omitted") must NOT appear. - assert!(!out.text.contains("lines omitted"), "double truncation detected: {:#?}", out.text); - assert!(out.text.contains("lines truncated")); + // Only the def's max_lines=40 cap should fire; filter_rake's head_tail must + // NOT also condense. Both now emit `[…Nln elided…]`, so a single marker + // proves no double truncation (two would mean both stages fired). + assert_eq!( + out.text.matches("ln elided…]").count(), + 1, + "double truncation detected: {:#?}", + out.text + ); } #[test] diff --git a/crates/pi-shell/src/minimizer/filters/binary_tools.rs b/crates/pi-shell/src/minimizer/filters/binary_tools.rs index b0c1e60e1..5154ec180 100644 --- a/crates/pi-shell/src/minimizer/filters/binary_tools.rs +++ b/crates/pi-shell/src/minimizer/filters/binary_tools.rs @@ -73,7 +73,7 @@ mod tests { // 50 head + 20 tail + 1 marker = 71 lines let line_count = out.text.lines().count(); assert_eq!(line_count, HEAD_LINES + TAIL_LINES + 1, "got {line_count} lines: {out:?}"); - assert!(out.text.contains("lines omitted")); + assert!(out.text.contains("ln elided…]")); // Head anchor preserved. assert!(out.text.starts_with("00000000: 00000000")); } @@ -95,7 +95,7 @@ mod tests { let context = ctx("strings", "strings /bin/ls", &cfg); let out = filter(&context, &input, 0); assert!(out.changed); - assert!(out.text.contains("lines omitted")); + assert!(out.text.contains("ln elided…]")); } #[test] @@ -105,7 +105,7 @@ mod tests { let context = ctx("od", "od -c /bin/ls", &cfg); let out = filter(&context, &input, 0); assert!(out.changed); - assert!(out.text.contains("lines omitted")); + assert!(out.text.contains("ln elided…]")); } #[test] diff --git a/crates/pi-shell/src/minimizer/filters/bun.rs b/crates/pi-shell/src/minimizer/filters/bun.rs index af86b5710..d1f1e6d59 100644 --- a/crates/pi-shell/src/minimizer/filters/bun.rs +++ b/crates/pi-shell/src/minimizer/filters/bun.rs @@ -253,9 +253,9 @@ fn compact_bun_check_output(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) } let omitted = nonzero_exits.len() + diagnostics.len(); if omitted > 40 { - out.push_str("… "); + out.push_str("[…"); out.push_str(&(omitted - 40).to_string()); - out.push_str(" diagnostic lines omitted\n"); + out.push_str(" diagnostic lines elided…]\n"); } Some(out) } diff --git a/crates/pi-shell/src/minimizer/filters/cloud.rs b/crates/pi-shell/src/minimizer/filters/cloud.rs index 5495585b3..527f381fc 100644 --- a/crates/pi-shell/src/minimizer/filters/cloud.rs +++ b/crates/pi-shell/src/minimizer/filters/cloud.rs @@ -474,7 +474,7 @@ fn compact_aws_generic(root: &Value) -> Option { let mut out = compact_named_rows(&columns.iter().map(String::as_str).collect::>(), &values); if rows.len() > MAX_AWS_ROWS { - let _ = writeln!(out, "... +{} more {name}", rows.len() - MAX_AWS_ROWS); + let _ = writeln!(out, "[…{} {name} elided…]", rows.len() - MAX_AWS_ROWS); } return Some(out); } @@ -559,7 +559,7 @@ fn compact_named_rows(headers: &[&str], rows: &[Vec]) -> String { out.push('\n'); } if rows.len() > MAX_AWS_ROWS { - let _ = writeln!(out, "... +{} more rows", rows.len() - MAX_AWS_ROWS); + let _ = writeln!(out, "[…{} rows elided…]", rows.len() - MAX_AWS_ROWS); } out } @@ -761,9 +761,7 @@ fn compact_aws_dynamodb_items(items: &[&serde_json::Map]) -> Stri out.push('\n'); } if items.len() > 40 { - out.push_str("… "); - out.push_str(&(items.len() - 40).to_string()); - out.push_str(" item(s) omitted …\n"); + let _ = writeln!(out, "[…{} items elided…]", items.len() - 40); } let _ = writeln!(out, "{} item(s)", items.len()); out @@ -1010,7 +1008,7 @@ fn compact_line(line: &str, max_chars: usize) -> String { let edge = max_chars / 2; let start: String = chars.iter().take(edge).collect(); let end: String = chars.iter().skip(chars.len() - edge).collect(); - format!("{start} … {} chars omitted … {end}", chars.len() - edge * 2) + format!("{start}[…{}ch elided…]{end}", chars.len() - edge * 2) } fn looks_like_table(input: &str) -> bool { @@ -1073,7 +1071,7 @@ fn compact_delimited_table(input: &str, max_rows: usize) -> String { } } if data_rows > max_rows { - out.push(format!("… {} more rows", data_rows - max_rows)); + out.push(format!("[…{} rows elided…]", data_rows - max_rows)); } join_lines(out) } @@ -1114,7 +1112,7 @@ fn compact_psql_table(input: &str) -> String { } if data_rows > MAX_PSQL_ROWS { - out.push(format!("... +{} more rows", data_rows - MAX_PSQL_ROWS)); + out.push(format!("[…{} rows elided…]", data_rows - MAX_PSQL_ROWS)); } out.extend(row_count_lines); join_lines(out) @@ -1152,7 +1150,7 @@ fn compact_psql_expanded(input: &str) -> String { } flush_record(&mut out, &mut current, records); if records > MAX_PSQL_ROWS { - out.push(format!("... +{} more records", records - MAX_PSQL_ROWS)); + out.push(format!("[…{} records elided…]", records - MAX_PSQL_ROWS)); } out.extend(row_count_lines); join_lines(out) diff --git a/crates/pi-shell/src/minimizer/filters/docker.rs b/crates/pi-shell/src/minimizer/filters/docker.rs index bc27d7c70..d6e81d2bd 100644 --- a/crates/pi-shell/src/minimizer/filters/docker.rs +++ b/crates/pi-shell/src/minimizer/filters/docker.rs @@ -745,9 +745,9 @@ fn compact_table(input: &str, visible_rows: usize) -> String { out.push_str(line.trim_end()); out.push('\n'); } - out.push_str("… "); + out.push_str("[…"); out.push_str(&(lines.len() - 1 - visible_rows).to_string()); - out.push_str(" more rows\n"); + out.push_str(" rows elided…]\n"); out } @@ -908,7 +908,7 @@ mod tests { let out = filter(&compose_ctx, &input, 0).text; assert!(out.contains("20 rows")); assert!(out.contains("svc-0")); - assert!(out.contains("… 8 more rows")); + assert!(out.contains("[…8 rows elided…]")); } #[test] @@ -988,7 +988,7 @@ mod tests { assert!(out.contains("api-1 | request 0 complete")); assert!(out.contains("api-1 | WARN cache miss")); assert!(out.contains("worker | failed to process job")); - assert!(out.contains("omitted")); + assert!(out.contains("elided")); } #[test] @@ -1000,7 +1000,7 @@ mod tests { } let out = compact_table(&input, 10); assert!(out.contains("25 rows")); - assert!(out.contains("… 15 more rows")); + assert!(out.contains("[…15 rows elided…]")); } fn ctx<'a>( @@ -1071,7 +1071,7 @@ mod tests { assert!(out.contains("25 rows"), "docker --format table output should still compact: {out}"); assert!( - out.contains("… 13 more rows"), + out.contains("[…13 rows elided…]"), "docker --format table should keep table omission: {out}" ); } @@ -1390,7 +1390,7 @@ mod tests { } let out = filter(&kubectl_ctx, &input, 0).text; assert!( - out.contains("lines omitted") || out.contains("lines truncated"), + out.contains("ln elided…]"), "kubectl apply output should be head/tail condensed: {out}" ); } diff --git a/crates/pi-shell/src/minimizer/filters/dotnet.rs b/crates/pi-shell/src/minimizer/filters/dotnet.rs index 51ef76248..0838532e3 100644 --- a/crates/pi-shell/src/minimizer/filters/dotnet.rs +++ b/crates/pi-shell/src/minimizer/filters/dotnet.rs @@ -184,9 +184,9 @@ fn compact_format_json(input: &str) -> Option { out.push('\n'); } if rows.len() > 40 { - out.push_str("… "); + out.push_str("[…"); out.push_str(&(rows.len() - 40).to_string()); - out.push_str(" more diagnostics\n"); + out.push_str(" diagnostics elided…]\n"); } Some(out) } diff --git a/crates/pi-shell/src/minimizer/filters/gh.rs b/crates/pi-shell/src/minimizer/filters/gh.rs index 7ca39e0b3..914f16c27 100644 --- a/crates/pi-shell/src/minimizer/filters/gh.rs +++ b/crates/pi-shell/src/minimizer/filters/gh.rs @@ -319,7 +319,7 @@ mod tests { // First failures stay verbatim with their actionable URLs. assert!(out.text.contains("X\tcheck0\t1s\thttps://ci.test/0")); // The output is capped, not 501 lines of raw rows. - assert!(out.text.contains("lines omitted")); + assert!(out.text.contains("ln elided…]")); assert!(out.text.lines().count() < 60); assert!(!out.text.contains("https://ci.test/499")); } diff --git a/crates/pi-shell/src/minimizer/filters/git.rs b/crates/pi-shell/src/minimizer/filters/git.rs index 3320433fd..251f65d8c 100644 --- a/crates/pi-shell/src/minimizer/filters/git.rs +++ b/crates/pi-shell/src/minimizer/filters/git.rs @@ -209,7 +209,7 @@ fn compact_diff_listing(input: &str, mode: DiffListingMode) -> String { out.push_str(entry); out.push('\n'); } - let _ = writeln!(out, "… {} files omitted …", entries.len() - DIFF_LISTING_LIMIT); + let _ = writeln!(out, "[…{} files elided…]", entries.len() - DIFF_LISTING_LIMIT); out } @@ -512,9 +512,7 @@ fn format_status_summary(summary: &StatusSummary) -> String { out.push('\n'); } if summary.paths.len() > 40 { - out.push_str("… "); - out.push_str(&(summary.paths.len() - 40).to_string()); - out.push_str(" paths omitted\n"); + let _ = writeln!(out, "[…{} paths elided…]", summary.paths.len() - 40); } out } @@ -531,9 +529,7 @@ fn condense_log(input: &str, head: usize, tail: usize) -> String { for entry in entries.iter().take(head) { push_log_entry(&mut out, entry); } - out.push_str("… "); - out.push_str(&(entries.len() - head - tail).to_string()); - out.push_str(" commits omitted …\n"); + let _ = writeln!(out, "[…{} commits elided…]", entries.len() - head - tail); for entry in entries.iter().skip(entries.len() - tail) { push_log_entry(&mut out, entry); } @@ -566,8 +562,8 @@ struct LogEntry { subject: String, body: Vec, /// Body lines dropped past the per-commit body cap, surfaced as an explicit - /// `[+N lines omitted]` marker instead of being silently lost. - omitted: usize, + /// `[…Nln elided…]` marker instead of being silently lost. + elided: usize, } /// Soft cap on rendered subject/body line width. Long commit subjects and body @@ -587,10 +583,8 @@ fn push_log_entry(out: &mut String, entry: &LogEntry) { out.push_str(&primitives::truncate_line(line, LOG_LINE_WIDTH)); out.push('\n'); } - if entry.omitted > 0 { - out.push_str(" [+"); - out.push_str(&entry.omitted.to_string()); - out.push_str(" lines omitted]\n"); + if entry.elided > 0 { + let _ = writeln!(out, " […{}ln elided…]", entry.elided); } } @@ -611,7 +605,7 @@ fn parse_log_entries(input: &str) -> Vec { hash: short_hash(hash), subject: subject.to_string(), body: Vec::new(), - omitted: 0, + elided: 0, }); continue; } @@ -627,12 +621,12 @@ fn parse_log_entries(input: &str) -> Vec { entry.subject = trimmed.to_string(); } else if !is_git_trailer(trimmed) { // Real (non-trailer) body lines past the 3-line cap are tallied so - // `push_log_entry` can emit an explicit `[+N lines omitted]` marker + // `push_log_entry` can emit an explicit `[…Nln elided…]` marker // rather than dropping them silently. if entry.body.len() < 3 { entry.body.push(trimmed.to_string()); } else { - entry.omitted += 1; + entry.elided += 1; } } } @@ -930,9 +924,7 @@ pub(crate) fn compact_diff_output(input: &str) -> String { out.push('\n'); } if files.len() > 20 { - out.push_str("… "); - out.push_str(&(files.len() - 20).to_string()); - out.push_str(" files omitted from stat\n"); + let _ = writeln!(out, "[…{} files from stat elided…]", files.len() - 20); } out.push_str(&format_file_count(files.len())); out.push_str(" changed, "); @@ -956,21 +948,15 @@ pub(crate) fn compact_diff_output(input: &str) -> String { out.push('\n'); } if hunk.lines.len() > 6 { - out.push_str(" … "); - out.push_str(&(hunk.lines.len() - 6).to_string()); - out.push_str(" changed lines omitted\n"); + let _ = writeln!(out, " […{} changed lines elided…]", hunk.lines.len() - 6); } } if file.hunks.len() > 8 { - out.push_str(" … "); - out.push_str(&(file.hunks.len() - 8).to_string()); - out.push_str(" hunks omitted\n"); + let _ = writeln!(out, " […{} hunks elided…]", file.hunks.len() - 8); } } if files.len() > 12 { - out.push_str("\n… "); - out.push_str(&(files.len() - 12).to_string()); - out.push_str(" files omitted from changes\n"); + let _ = writeln!(out, "\n[…{} files from changes elided…]", files.len() - 12); } out } @@ -1311,7 +1297,7 @@ fn condense_diff_stat(input: &str) -> String { out.push('\n'); } if entries.len() > 20 { - let _ = writeln!(out, "… {} files omitted …", entries.len() - 20); + let _ = writeln!(out, "[…{} files elided…]", entries.len() - 20); } out } @@ -1528,7 +1514,7 @@ fn condense_worktree(input: &str) -> String { /// /// Listing-shaped lines (` []`, plus the `(bare)` and /// `(detached HEAD)` variants) get a leading `$HOME` abbreviated to `~` and are -/// capped with an omitted-count marker. Any non-listing output (`add`'s +/// capped with an elided-count marker. Any non-listing output (`add`'s /// "Preparing worktree…"/"HEAD is now at …" confirmations, errors) is left to /// `condense_noisy_output`/passthrough so its meaning is preserved. fn condense_worktree_with_home(input: &str, home: &str) -> String { @@ -1554,9 +1540,7 @@ fn condense_worktree_with_home(input: &str, home: &str) -> String { out.push('\n'); } if entries.len() > WORKTREE_LIMIT { - out.push_str("… "); - out.push_str(&(entries.len() - WORKTREE_LIMIT).to_string()); - out.push_str(" worktrees omitted …\n"); + let _ = writeln!(out, "[…{} worktrees elided…]", entries.len() - WORKTREE_LIMIT); } out } @@ -1920,7 +1904,7 @@ mod tests { input.push('\n'); } let out = filter(&ctx, &input, 0); - assert!(out.text.contains("… 22 commits omitted …")); + assert!(out.text.contains("[…22 commits elided…]")); assert!(out.text.contains("abcdef1 message 0")); assert!(!out.text.contains("message 47")); assert!(out.text.contains("abcdef1 message 69")); @@ -1983,7 +1967,7 @@ mod tests { } #[test] - fn log_body_over_cap_shows_omitted_marker() { + fn log_body_over_cap_shows_elided_marker() { let cfg = MinimizerConfig { enabled: true, ..Default::default() }; let ctx = test_ctx(Some("log"), "git log", &cfg); // Subject + 5 body lines: 3 kept, 2 past the cap surfaced as a marker. @@ -1995,7 +1979,7 @@ mod tests { assert!(out.text.contains("body line one")); assert!(out.text.contains("body line three")); assert!(!out.text.contains("body line four"), "{:?}", out.text); - assert!(out.text.contains("[+2 lines omitted]"), "{:?}", out.text); + assert!(out.text.contains("[…2ln elided…]"), "{:?}", out.text); } #[test] @@ -2062,7 +2046,7 @@ mod tests { assert!(out.text.contains("src/file-0.rs\n")); assert!(out.text.contains("src/file-19.rs\n")); assert!(!out.text.contains("src/file-20.rs\n")); - assert!(out.text.contains("… 6 files omitted …")); + assert!(out.text.contains("[…6 files elided…]")); } #[test] @@ -2091,7 +2075,7 @@ mod tests { assert!(out.text.contains("R100\told-0.rs\tnew-0.rs\n")); assert!(out.text.contains("M\tpath-1.rs\n")); assert!(!out.text.contains("path-20.rs\n")); - assert!(out.text.contains("… 4 files omitted …")); + assert!(out.text.contains("[…4 files elided…]")); } #[test] @@ -2139,7 +2123,7 @@ mod tests { assert!(out.text.contains("1\t0\tsrc/file-0.rs\n")); assert!(out.text.contains("20\t5\tsrc/file-19.rs\n")); assert!(!out.text.contains("src/file-20.rs\n")); - assert!(out.text.contains("… 2 files omitted …")); + assert!(out.text.contains("[…2 files elided…]")); } #[test] @@ -2695,13 +2679,13 @@ hint: See the 'Note about fast-forwards' in 'git push --help' for details. assert!(out.text.contains("fatal:"), "error header must survive: {:?}", out.text); assert!(out.text.contains("badref"), "offending ref must survive: {:?}", out.text); - assert!(!out.text.contains("commits omitted"), "must not fabricate commit listing on error"); + assert!(!out.text.contains("commits elided"), "must not fabricate commit listing on error"); } #[test] fn log_oneline_short_run_emits_all_entries() { // A short log that fits within head+tail should emit all entries - // without any "omitted" line, and each entry should carry the + // without any "elided" line, and each entry should carry the // 7-char short hash followed by the subject. let cfg = MinimizerConfig { enabled: true, ..Default::default() }; let ctx = test_ctx(Some("log"), "git log -5", &cfg); @@ -2712,7 +2696,7 @@ commit abcdef1234567890\nAuthor: A \nDate: today\n feat: first\ncomm let out = filter(&ctx, input, 0); - assert!(!out.text.contains("commits omitted")); + assert!(!out.text.contains("commits elided")); assert!(out.text.contains("abcdef1 feat: first"), "{:?}", out.text); assert!(out.text.contains("1111111 fix: second"), "{:?}", out.text); assert!(out.text.contains("2222222 chore: third"), "{:?}", out.text); @@ -2780,8 +2764,8 @@ error: could not apply abc1234... fix: something\nhint: Resolve all conflicts ma assert!(out.contains("(bare)")); assert!(out.contains("(detached HEAD)")); assert!(!out.contains("/home/alice"), "no absolute $HOME paths should survive: {out:?}"); - // 22 entries → capped at 20 with a 2-omitted marker. - assert!(out.contains("… 2 worktrees omitted …"), "{out:?}"); + // 22 entries → capped at 20 with a 2-elided marker. + assert!(out.contains("[…2 worktrees elided…]"), "{out:?}"); assert_eq!(out.lines().filter(|l| l.starts_with('~')).count(), 20); } @@ -2799,7 +2783,7 @@ error: could not apply abc1234... fix: something\nhint: Resolve all conflicts ma #[test] fn worktree_porcelain_passthrough() { // `git worktree list --porcelain` emits machine-readable records; - // the minimizer must not insert "… N lines omitted …" markers. + // the minimizer must not insert "[…Nln elided…]" markers. let cfg = MinimizerConfig { enabled: true, ..Default::default() }; let ctx = test_ctx(Some("worktree"), "git worktree list --porcelain", &cfg); let input = "worktree /home/user/project\nHEAD abc1234def\nbranch \ @@ -2825,7 +2809,7 @@ error: could not apply abc1234... fix: something\nhint: Resolve all conflicts ma fn worktree_non_porcelain_still_condensed() { // Normal (non-porcelain) listing should still go through the condenser. // Build 22 listing-shaped entries so the cap (20) is exceeded; the condenser - // would insert an "… N worktrees omitted …" marker that a passthrough would + // would insert an "[…N worktrees elided…]" marker that a passthrough would // never emit. let cfg = MinimizerConfig { enabled: true, ..Default::default() }; let ctx = test_ctx(Some("worktree"), "git worktree list", &cfg); @@ -2835,8 +2819,8 @@ error: could not apply abc1234... fix: something\nhint: Resolve all conflicts ma } let out = filter(&ctx, &input, 0); assert!( - out.text.contains("worktrees omitted"), - "non-porcelain output over the cap must show omitted marker; got: {:?}", + out.text.contains("worktrees elided"), + "non-porcelain output over the cap must show elided marker; got: {:?}", out.text ); } diff --git a/crates/pi-shell/src/minimizer/filters/glab.rs b/crates/pi-shell/src/minimizer/filters/glab.rs index 7ca413aaa..6b8bcb730 100644 --- a/crates/pi-shell/src/minimizer/filters/glab.rs +++ b/crates/pi-shell/src/minimizer/filters/glab.rs @@ -211,11 +211,11 @@ fn filter_release_list(input: &str) -> Option { // Append omission marker when there are more releases than shown. let omitted = total.map_or(0, |t| t.saturating_sub(count)); if omitted > 0 { - let _ = writeln!(filtered, "… {omitted} releases omitted …"); + let _ = writeln!(filtered, "[…{omitted} releases elided…]"); } else if has_more { // Total not parsed from preamble but a 21st row was observed; signal // truncation. - filtered.push_str("… releases omitted (showing first 20) …\n"); + filtered.push_str("[…releases elided…]\n"); } Some(filtered) @@ -661,8 +661,8 @@ section_end:1711234600:build_script[0K let out = filter(&ctx, &input, 0); assert!(out.changed); - // Output must signal that releases were omitted. - assert!(out.text.contains("omitted"), "expected omission marker, got: {}", out.text); + // Output must signal that releases were elided. + assert!(out.text.contains("elided"), "expected omission marker, got: {}", out.text); // First release present, 21st not shown verbatim in the list. assert!(out.text.contains("Release 1")); assert!(!out.text.contains("Release 21 [")); diff --git a/crates/pi-shell/src/minimizer/filters/go.rs b/crates/pi-shell/src/minimizer/filters/go.rs index e1e893cec..05b32bf5b 100644 --- a/crates/pi-shell/src/minimizer/filters/go.rs +++ b/crates/pi-shell/src/minimizer/filters/go.rs @@ -341,9 +341,9 @@ fn summarize_golangci_json(line: &str) -> Option { out.push_str(")\n"); } if issues.len() > 40 { - out.push_str("… "); + out.push_str("[…"); out.push_str(&(issues.len() - 40).to_string()); - out.push_str(" more issues\n"); + out.push_str(" issues elided…]\n"); } Some(out) } @@ -506,6 +506,22 @@ mod tests { let out = filter_golangci_lint(input); assert!(out.contains("golangci-lint: 1 issues")); assert!(out.contains("main.go:7:2: unreachable code (govet)")); + + // Match up-to-40 limits, testing elison formatting + let mut many_issues = r#"{"Issues":["#.to_string(); + for i in 0..42 { + if i > 0 { + many_issues.push(','); + } + let _ = write!( + many_issues, + r#"{{"FromLinter":"govet","Text":"err {i}","Pos":{{"Filename":"main.go","Line":{i},"Column":2}}}}"# + ); + } + many_issues.push_str("]}"); + let out_many = filter_golangci_lint(&many_issues); + assert!(out_many.contains("golangci-lint: 42 issues")); + assert!(out_many.contains("[…2 issues elided…]")); } #[test] diff --git a/crates/pi-shell/src/minimizer/filters/gt.rs b/crates/pi-shell/src/minimizer/filters/gt.rs index e5d6c5baf..3c76e995c 100644 --- a/crates/pi-shell/src/minimizer/filters/gt.rs +++ b/crates/pi-shell/src/minimizer/filters/gt.rs @@ -91,9 +91,7 @@ fn compact_log(input: &str) -> String { } if omitted_entries > 0 { - out.push_str("… "); - out.push_str(&omitted_entries.to_string()); - out.push_str(" entries omitted …\n"); + let _ = writeln!(out, "[…{omitted_entries} entries elided…]"); } primitives::head_tail_lines(&out, 80, 24) @@ -172,13 +170,13 @@ fn dense_sync_summary(input: &str, exit_code: i32) -> Option { // head_tail_lines bound that compact_noisy_command (the fallback) applies. // Without a cap every name lands on one unbounded line, defeating the // minimizer's bounding guarantee. Show the first DELETED_NAME_CAP names and - // summarize the rest as `+N more` (the count above stays exact). + // summarize the rest as `[…N names elided…]` (the count above stays exact). const DELETED_NAME_CAP: usize = 20; let shown = deleted_names.len().min(DELETED_NAME_CAP); let names = deleted_names[..shown].join(", "); let _ = write!(summary, " ({names}"); if deleted_names.len() > DELETED_NAME_CAP { - let _ = write!(summary, ", +{} more", deleted_names.len() - DELETED_NAME_CAP); + let _ = write!(summary, " […{} names elided…]", deleted_names.len() - DELETED_NAME_CAP); } summary.push(')'); } @@ -344,7 +342,7 @@ mod tests { assert!(out.changed); assert!(out.text.contains("abc1230")); - assert!(out.text.contains("entries omitted")); + assert!(out.text.contains("entries elided")); assert!(!out.text.contains("user@example.com")); } @@ -439,7 +437,8 @@ mod tests { fn sync_dense_summary_caps_deleted_name_list() { // A long-lived stack cleanup can delete hundreds of merged branches at // once. The dense summary must stay bounded: cap the inline name list and - // summarize the remainder as `+N more`, never emit one unbounded line. + // summarize the remainder as `[…N names elided…]`, never emit one unbounded + // line. let cfg = MinimizerConfig { enabled: true, ..Default::default() }; let ctx = test_ctx(Some("sync"), &cfg); let mut input = String::from("Synced with remote\n"); @@ -452,9 +451,9 @@ mod tests { assert!(out.changed); // Exact deleted count is preserved. assert!(out.text.contains("ok sync: 1 synced, 500 deleted")); - // First names stay visible; the rest collapse to a `+N more` marker. + // First names stay visible; the rest collapse to a `[…N names elided…]` marker. assert!(out.text.contains("feat/merged-0")); - assert!(out.text.contains("+480 more")); + assert!(out.text.contains("[…480 names elided…]")); // Bounded: a single short line, not 500 names concatenated. assert!(!out.text.contains("feat/merged-499")); let longest = out.text.lines().map(str::len).max().unwrap_or(0); diff --git a/crates/pi-shell/src/minimizer/filters/js_tools.rs b/crates/pi-shell/src/minimizer/filters/js_tools.rs index 01828f959..60e88f074 100644 --- a/crates/pi-shell/src/minimizer/filters/js_tools.rs +++ b/crates/pi-shell/src/minimizer/filters/js_tools.rs @@ -219,9 +219,9 @@ fn filter_prettier(input: &str, exit_code: i32) -> String { push_line(&mut out, error); } if errors.len() > 20 { - out.push_str("... +"); + out.push_str("[…"); out.push_str(&(errors.len() - 20).to_string()); - out.push_str(" more errors\n"); + out.push_str(" errors elided…]\n"); } return out; } @@ -391,9 +391,9 @@ fn push_file_list(out: &mut String, files: &[String], limit: usize) { push_line(out, file); } if files.len() > limit { - out.push_str("... +"); + out.push_str("[…"); out.push_str(&(files.len() - limit).to_string()); - out.push_str(" more files\n"); + out.push_str(" files elided…]\n"); } } @@ -404,6 +404,8 @@ fn push_line(out: &mut String, line: &str) { #[cfg(test)] mod tests { + use std::fmt::Write as _; + use super::*; use crate::minimizer::MinimizerConfig; @@ -532,4 +534,29 @@ mod tests { assert!(out.contains("P3009")); assert!(!out.contains("Datasource")); } + + #[test] + fn prettier_errors_truncation_is_canonical() { + let mut input = String::new(); + for i in 1..=25 { + let _ = writeln!(input, "error in file{i}.js: line {i}"); + } + let out = filter_prettier(&input, 1); + assert!(out.contains("[…5 errors elided…]\n")); + assert!(!out.contains("more errors")); + assert!(!out.contains("... +")); + } + + #[test] + fn file_list_truncation_is_canonical() { + let mut files = Vec::new(); + for i in 1..=35 { + files.push(format!("src/file{i}.tsx")); + } + let mut out = String::new(); + push_file_list(&mut out, &files, 30); + assert!(out.contains("[…5 files elided…]\n")); + assert!(!out.contains("more files")); + assert!(!out.contains("... +")); + } } diff --git a/crates/pi-shell/src/minimizer/filters/jvm.rs b/crates/pi-shell/src/minimizer/filters/jvm.rs index 954c8b443..deb7e9645 100644 --- a/crates/pi-shell/src/minimizer/filters/jvm.rs +++ b/crates/pi-shell/src/minimizer/filters/jvm.rs @@ -606,7 +606,7 @@ impl<'a> SurefireBlock<'a> { /// `[ERROR] Failures:` summary block cap. Maven emits a summary at the end of a /// failing test run; on builds with hundreds of failures this can be large. Cap -/// entries at [`max_mvn_failing_classes`] and emit `\n… +N more failures\n` +/// entries at [`max_mvn_failing_classes`] and emit `\n[…N failures elided…]\n` /// immediately before the `Tests run:` aggregate when entries were dropped. struct FailuresSummaryCap { cap: usize, @@ -648,14 +648,14 @@ impl FailuresSummaryCap { } } - /// Pre-emit the `… +N more failures` tail when the aggregate + /// Pre-emit the `[…N failures elided…]` tail when the aggregate /// `[ERROR] Tests run:` line is about to be written, then close the summary. fn handle_aggregate(&mut self, line: &str, out: &mut String) { if !self.in_summary || !AGG.is_match(line) { return; } if self.dropped > 0 { - let _ = write!(out, "\n… +{} more failures\n", self.dropped); + let _ = write!(out, "\n[…{} failures elided…]\n", self.dropped); } self.in_summary = false; self.emitted = 0; @@ -665,7 +665,7 @@ impl FailuresSummaryCap { /// End-of-stream tail emission for cases where the AGG line never arrives. fn finish(&self, out: &mut String) { if self.in_summary && self.dropped > 0 { - let _ = write!(out, "\n… +{} more failures\n", self.dropped); + let _ = write!(out, "\n[…{} failures elided…]\n", self.dropped); } } } @@ -744,7 +744,7 @@ fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { block.finish(&mut out); summary.finish(&mut out); if dropped_failing > 0 { - let _ = write!(out, "\n… +{dropped_failing} more failing test classes\n"); + let _ = write!(out, "\n[…{dropped_failing} failing test classes elided…]\n"); } out } @@ -903,7 +903,7 @@ fn filter_package_with_cap(raw: &str, cap: usize) -> String { block.finish(&mut out); summary.finish(&mut out); if dropped_failing > 0 { - let _ = write!(out, "\n… +{dropped_failing} more failing test classes\n"); + let _ = write!(out, "\n[…{dropped_failing} failing test classes elided…]\n"); } out } @@ -1602,7 +1602,7 @@ fn filter_gradle_dependencies(input: &str) -> String { let _ = writeln!(result, " {dep}"); } if deps.len() > cap { - let _ = writeln!(result, " ... +{} more", deps.len() - cap); + let _ = writeln!(result, " […{} dependencies elided…]", deps.len() - cap); } } @@ -1994,7 +1994,7 @@ mod tests { "capped class second per-test block dropped (re-arm inherits drop); got:\n{o}" ); assert!( - o.contains("… +1 more failing test classes"), + o.contains("[…1 failing test classes elided…]"), "tail counts one class, not one per failure; got:\n{o}" ); } @@ -2214,7 +2214,7 @@ mod tests { "Fail{n} exception dropped; got:\n{o}" ); } - assert!(o.contains("… +2 more failing test classes"), "tail emitted; got:\n{o}"); + assert!(o.contains("[…2 failing test classes elided…]"), "tail emitted; got:\n{o}"); } #[test] @@ -2235,7 +2235,7 @@ mod tests { "Fail{n} dropped under cap=0; got:\n{o}" ); } - assert!(o.contains("+5 more failing test classes"), "tail counts all 5; got:\n{o}"); + assert!(o.contains("[…5 failing test classes elided…]"), "tail counts all 5; got:\n{o}"); } #[test] @@ -2256,7 +2256,7 @@ mod tests { for n in 4..=5 { assert!(!o.contains(&format!("ClassA.test{n}:25")), "entry {n} dropped; got:\n{o}"); } - let tail_idx = o.find("… +2 more failures").expect("tail must appear"); + let tail_idx = o.find("[…2 failures elided…]").expect("tail must appear"); let agg_idx = o .find("[ERROR] Tests run: 100") .expect("aggregate must appear"); diff --git a/crates/pi-shell/src/minimizer/filters/lint.rs b/crates/pi-shell/src/minimizer/filters/lint.rs index f89ac0f54..38d804534 100644 --- a/crates/pi-shell/src/minimizer/filters/lint.rs +++ b/crates/pi-shell/src/minimizer/filters/lint.rs @@ -540,9 +540,9 @@ pub fn group_diagnostics(input: &str) -> String { out.push('\n'); } if entries.len() > 12 { - out.push_str(" … "); + out.push_str(" […"); out.push_str(&(entries.len() - 12).to_string()); - out.push_str(" more\n"); + out.push_str(" diagnostics elided…]\n"); } } @@ -551,9 +551,9 @@ pub fn group_diagnostics(input: &str) -> String { out.push('\n'); } if ungrouped.len() > 40 { - out.push_str("… "); + out.push_str("[…"); out.push_str(&(ungrouped.len() - 40).to_string()); - out.push_str(" ungrouped lines omitted\n"); + out.push_str(" ungrouped lines elided…]\n"); } out } @@ -701,7 +701,7 @@ mod tests { } let out = group_diagnostics(&input); assert!(out.contains("src/main.rs (20 diagnostics)")); - assert!(out.contains("… 8 more")); + assert!(out.contains(" […8 diagnostics elided…]")); } #[test] diff --git a/crates/pi-shell/src/minimizer/filters/listing.rs b/crates/pi-shell/src/minimizer/filters/listing.rs index a130a7dd6..f3f50fbda 100644 --- a/crates/pi-shell/src/minimizer/filters/listing.rs +++ b/crates/pi-shell/src/minimizer/filters/listing.rs @@ -173,16 +173,16 @@ fn compact_grep_grouped( out.push('\n'); } if matches.len() > 4 { - out.push_str(" … "); + out.push_str(" […"); out.push_str(&(matches.len() - 4).to_string()); - out.push_str(" more in file\n"); + out.push_str(" matches in file elided…]\n"); } } let omitted_files = grouped.len().saturating_sub(shown_files); let omitted_matches = match_count.saturating_sub(shown_matches); if omitted_files > 0 || omitted_matches > 0 { - out.push_str("\n… "); + out.push_str("\n[…"); out.push_str(&omitted_matches.to_string()); out.push_str(" matches"); if omitted_files > 0 { @@ -190,7 +190,7 @@ fn compact_grep_grouped( out.push_str(&omitted_files.to_string()); out.push_str(" files"); } - out.push_str(" omitted\n"); + out.push_str(" elided…]\n"); } for line in ungrouped { out.push_str(line); @@ -365,14 +365,14 @@ fn compact_find_output_inner(input: &str, paths: &[&str]) -> String { push_wrapped_names(&mut out, names, 4, 24); } if grouped.len() > 16 { - out.push_str("\n… "); + out.push_str("\n[…"); out.push_str(&(grouped.len() - 16).to_string()); - out.push_str(" dirs omitted\n"); + out.push_str(" dirs elided…]\n"); } if skipped_noise > 0 { - out.push_str("… "); + out.push_str("[…"); out.push_str(&skipped_noise.to_string()); - out.push_str(" noisy paths omitted\n"); + out.push_str(" noisy paths elided…]\n"); } out } @@ -407,9 +407,9 @@ fn push_wrapped_names(out: &mut String, names: &[String], per_line: usize, max_n } out.push('\n'); if names.len() > max_names { - out.push_str(" … "); + out.push_str(" […"); out.push_str(&(names.len() - max_names).to_string()); - out.push_str(" more\n"); + out.push_str(" names elided…]\n"); } } @@ -453,9 +453,9 @@ fn compact_ls_output(input: &str) -> Option { } let shown = dir_count.min(12) + file_count.min(36); if entries.len() > shown { - out.push_str("… "); + out.push_str("[…"); out.push_str(&(entries.len() - shown).to_string()); - out.push_str(" entries omitted\n"); + out.push_str(" entries elided…]\n"); } out.push('\n'); out.push_str(&file_count.to_string()); @@ -622,9 +622,9 @@ fn summarize_cargo_toml(input: &str) -> Option { out.push('\n'); } if dependencies.len() > 15 { - out.push_str(" … "); + out.push_str(" […"); out.push_str(&(dependencies.len() - 15).to_string()); - out.push_str(" more\n"); + out.push_str(" dependencies elided…]\n"); } Some(out) } @@ -705,9 +705,9 @@ fn summarize_package_json(input: &str) -> Option { out.push('\n'); } if deps.len() > 15 { - out.push_str(" … "); + out.push_str(" […"); out.push_str(&(deps.len() - 15).to_string()); - out.push_str(" more\n"); + out.push_str(" dependencies elided…]\n"); } Some(out) } @@ -752,9 +752,9 @@ fn summarize_go_mod(input: &str) -> Option { out.push('\n'); } if deps.len() > 15 { - out.push_str(" … "); + out.push_str(" […"); out.push_str(&(deps.len() - 15).to_string()); - out.push_str(" more\n"); + out.push_str(" dependencies elided…]\n"); } Some(out) } @@ -1265,7 +1265,7 @@ mod tests { let out = filter(&ctx, &input, 0); assert!(out.text.starts_with("grep: 20 matches in 20 files")); assert!(out.text.contains("17: pub fn run(...) -> Result<()> {")); - assert!(out.text.contains("matches in 8 files omitted")); + assert!(out.text.contains("matches in 8 files elided…]")); } #[test] @@ -1447,7 +1447,7 @@ mod tests { let out = filter(&ctx, &input, 0); assert!(out.text.starts_with("find: 31 paths in 3 dirs")); assert!(out.text.contains("src/module0/ file0.rs file1.rs")); - assert!(out.text.contains("1 noisy paths omitted")); + assert!(out.text.contains("[…1 noisy paths elided…]")); assert!(!out.text.contains("target/debug")); } @@ -1488,7 +1488,7 @@ mod tests { out.text .contains("Filesystem 1K-blocks Used Available Use% Mounted on") ); - assert!(out.text.contains("… 13 lines omitted …")); + assert!(out.text.contains("[…13ln elided…]")); assert!(out.text.contains("/dev/disk35")); } diff --git a/crates/pi-shell/src/minimizer/filters/pkg.rs b/crates/pi-shell/src/minimizer/filters/pkg.rs index 386081175..44f1e0440 100644 --- a/crates/pi-shell/src/minimizer/filters/pkg.rs +++ b/crates/pi-shell/src/minimizer/filters/pkg.rs @@ -251,7 +251,7 @@ fn compact_package_tree_output(input: &str) -> String { out.push_str(line); out.push('\n'); } - let _ = writeln!(out, "… {} package entries omitted …", lines.len() - PACKAGE_TREE_HEAD_LINES); + let _ = writeln!(out, "[…{} package entries elided…]", lines.len() - PACKAGE_TREE_HEAD_LINES); out } @@ -292,7 +292,7 @@ fn summarize_package_rows(rows: Vec) -> Option { out.push('\n'); } if rows.len() > PACKAGE_TREE_HEAD_LINES { - let _ = writeln!(out, "… {} package entries omitted …", rows.len() - PACKAGE_TREE_HEAD_LINES); + let _ = writeln!(out, "[…{} package entries elided…]", rows.len() - PACKAGE_TREE_HEAD_LINES); } Some(out) } @@ -793,7 +793,7 @@ mod tests { assert!(out.text.contains("├── dep000@1.0.0")); assert!(out.text.contains("├── dep078@1.0.0")); assert!(!out.text.contains("├── dep089@1.0.0")); - assert!(out.text.contains("… 11 package entries omitted …")); + assert!(out.text.contains("[…11 package entries elided…]")); } #[test] @@ -808,7 +808,7 @@ mod tests { let out = filter(&context, &input, 0); assert!(out.text.starts_with("package tree/list: 91 entries\n")); assert!(out.text.contains("dep000")); - assert!(out.text.contains("… 11 package entries omitted …")); + assert!(out.text.contains("[…11 package entries elided…]")); } #[test] @@ -825,7 +825,7 @@ mod tests { assert!(out.text.starts_with("package tree/list: 92 entries\n")); assert!(out.text.contains("react 19.0.0")); assert!(out.text.contains("└─ dependent000")); - assert!(out.text.contains("… 12 package entries omitted …")); + assert!(out.text.contains("[…12 package entries elided…]")); } #[test] @@ -889,7 +889,7 @@ mod tests { assert!(out.text.contains("pkg000 1.0.0")); assert!(out.text.contains("pkg078 1.0.78")); assert!(!out.text.contains("pkg089 1.0.89")); - assert!(out.text.contains("… 11 package entries omitted …")); + assert!(out.text.contains("[…11 package entries elided…]")); } #[test] @@ -905,7 +905,7 @@ mod tests { assert!(out.text.starts_with("package tree/list: 91 entries\n")); assert!(out.text.contains("project v1.0.0")); assert!(out.text.contains("pkg000")); - assert!(out.text.contains("… 11 package entries omitted …")); + assert!(out.text.contains("[…11 package entries elided…]")); } #[test] @@ -920,7 +920,7 @@ mod tests { let out = filter(&context, &input, 0); assert!(out.text.starts_with("package tree/list: 91 entries\n")); assert!(out.text.contains("requests 2.32.0")); - assert!(out.text.contains("… 11 package entries omitted …")); + assert!(out.text.contains("[…11 package entries elided…]")); } #[test] @@ -951,7 +951,7 @@ mod tests { let out = filter(&context, &input, 0); assert!(out.text.starts_with("package tree/list: 91 entries\n")); assert!(out.text.contains("pkg000==1.0.0")); - assert!(out.text.contains("… 11 package entries omitted …")); + assert!(out.text.contains("[…11 package entries elided…]")); } #[test] @@ -966,7 +966,7 @@ mod tests { let out = filter(&context, &input, 0); assert!(out.text.starts_with("package tree/list: 91 entries\n")); assert!(out.text.contains("dep000==2.0.0")); - assert!(out.text.contains("… 11 package entries omitted …")); + assert!(out.text.contains("[…11 package entries elided…]")); } #[test] @@ -996,7 +996,7 @@ mod tests { let out = filter(&context, &input, 0); assert!(out.text.starts_with("package tree/list: 92 entries\n")); assert!(out.text.contains("pkg000")); - assert!(out.text.contains("… 12 package entries omitted …")); + assert!(out.text.contains("[…12 package entries elided…]")); } #[test] diff --git a/crates/pi-shell/src/minimizer/filters/python.rs b/crates/pi-shell/src/minimizer/filters/python.rs index 1635c9242..c4ce279e8 100644 --- a/crates/pi-shell/src/minimizer/filters/python.rs +++ b/crates/pi-shell/src/minimizer/filters/python.rs @@ -20,9 +20,9 @@ use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; /// sections). Mirrors RTK's `MAX_PYTEST_FAILURES` (== `CAP_WARNINGS` == 10), /// re-derived for the minimizer's streaming, line-based renderer: once this /// many traceback blocks have been emitted, further blocks are suppressed and -/// a single `+N more failures` overflow marker stands in for the remainder. -/// The compact `FAILED …` short-summary one-liners are NOT capped here — they -/// name every failed test cheaply and stay intact. +/// a single `[…N failures elided…]` overflow marker stands in for the +/// remainder. The compact `FAILED …` short-summary one-liners are NOT capped +/// here — they name every failed test cheaply and stay intact. /// /// The `=== ERRORS ===` section (collection / fixture-setup errors) is capped /// by a SEPARATE counter (see `filter_pytest`): pytest renders ERRORS *before* @@ -172,15 +172,15 @@ fn filter_pytest(input: &str, exit_code: i32) -> String { // overflow markers so the compact output reads top-to-bottom. let error_overflow = error_blocks.saturating_sub(MAX_PYTEST_FAILURES); if error_overflow > 0 { - out.push_str("… +"); + out.push_str("[…"); out.push_str(&error_overflow.to_string()); - out.push_str(" more errors\n"); + out.push_str(" errors elided…]\n"); } let overflow = failure_blocks.saturating_sub(MAX_PYTEST_FAILURES); if overflow > 0 { - out.push_str("… +"); + out.push_str("[…"); out.push_str(&overflow.to_string()); - out.push_str(" more failures\n"); + out.push_str(" failures elided…]\n"); } if has_content(&out) { @@ -575,7 +575,7 @@ mod tests { assert!(out.contains("test_case_0"), "got: {out}"); assert!(out.contains("test_case_9"), "got: {out}"); // The overflow marker accounts for the remaining 2 verbose blocks. - assert!(out.contains("… +2 more failures"), "got: {out}"); + assert!(out.contains("[…2 failures elided…]"), "got: {out}"); // The traceback bodies for the capped blocks are gone, but the compact // short-summary one-liners for every test survive uncapped. let assert_false_lines = out.matches("E assert False").count(); @@ -664,17 +664,17 @@ mod tests { let assert_lines = out.matches("E assert 0 == 100").count(); assert_eq!(assert_lines, 5, "all 5 assertion bodies must survive: {out}"); // 5 < cap, so there is NO failure overflow stealing real tracebacks. - assert!(!out.contains("more failures"), "no failures should be capped: {out}"); + assert!(!out.contains("failures elided"), "no failures should be capped: {out}"); // Collection errors are still surfaced (under their own cap of 10). assert!(out.contains("ERROR collecting tests/test_imp_0.py"), "got: {out}"); - assert!(!out.contains("more errors"), "9 errors < cap, no error overflow: {out}"); + assert!(!out.contains("errors elided"), "9 errors < cap, no error overflow: {out}"); assert!(out.contains("pytest: 5 failed, 9 errors"), "got: {out}"); } #[test] fn pytest_caps_error_blocks_independently_of_failures() { // 12 collection-error banners (ERRORS section) exceed the cap and yield - // their OWN `+N more errors` marker, while the 2 real failure tracebacks + // their OWN `[…N errors elided…]` marker, while the 2 real failure tracebacks // in the FAILURES section are untouched (their counter is separate). let mut input = String::from( "============================= test session starts \ @@ -706,9 +706,9 @@ mod tests { let out = filter_pytest(&input, 1); // Error overflow marker fires for the 2 capped collection banners… - assert!(out.contains("… +2 more errors"), "got: {out}"); + assert!(out.contains("[…2 errors elided…]"), "got: {out}"); // …but NOT a failure overflow — both real tracebacks render in full. - assert!(!out.contains("more failures"), "failures must be uncapped here: {out}"); + assert!(!out.contains("failures elided"), "failures must be uncapped here: {out}"); assert!(out.contains("test_real_0"), "got: {out}"); assert!(out.contains("test_real_1"), "got: {out}"); let assert_false = out.matches("E assert False").count(); diff --git a/crates/pi-shell/src/minimizer/filters/ruby.rs b/crates/pi-shell/src/minimizer/filters/ruby.rs index 9e5fe8bdd..6f2026e8f 100644 --- a/crates/pi-shell/src/minimizer/filters/ruby.rs +++ b/crates/pi-shell/src/minimizer/filters/ruby.rs @@ -75,7 +75,7 @@ fn filter_rspec(input: &str, exit_code: i32) -> String { let mut out = String::new(); // Each rendered failure block is buffered separately so the total can be - // capped at MAX_RENDERED_FAILURES with a `+N more failures` marker. The + // capped at MAX_RENDERED_FAILURES with a `[…N failures elided…]` marker. The // buffered blocks are rendered into `out` the moment the `Failures:` section // ends — at the summary line or the `Failed examples:` boundary — so real // rspec ordering (`Failures:` details, then summary, then `Failed examples:`) @@ -139,8 +139,8 @@ fn filter_rspec(input: &str, exit_code: i32) -> String { } /// Render the buffered `Failures:` detail blocks into `out`, capped at -/// `MAX_RENDERED_FAILURES` with a `+N more failures` marker. Runs at most once -/// — guarded by `rendered` — so the section is emitted exactly where the +/// `MAX_RENDERED_FAILURES` with a `[…N failures elided…]` marker. Runs at most +/// once — guarded by `rendered` — so the section is emitted exactly where the /// `Failures:` block ends (the summary line or `Failed examples:` boundary), /// preserving real rspec section ordering. fn render_rspec_blocks(out: &mut String, blocks: &mut Vec, rendered: &mut bool) { @@ -157,14 +157,14 @@ fn render_rspec_blocks(out: &mut String, blocks: &mut Vec, rendered: &mu out.push_str(block); } if total > MAX_RENDERED_FAILURES { - push_line(out, &format!("+{} more failures", total - MAX_RENDERED_FAILURES)); + push_line(out, &format!("[…{} failures elided…]", total - MAX_RENDERED_FAILURES)); } blocks.clear(); } /// Cap on rendered failure blocks in non-JSON rspec output. rtk uses the same /// limit (`MAX_RSPEC_FAILURES = 5`) — failure blocks carry full context, so a -/// handful is enough before collapsing to a `+N more failures` marker. +/// handful is enough before collapsing to a `[…N failures elided…]` marker. const MAX_RENDERED_FAILURES: usize = 5; fn flush_rspec_block(blocks: &mut Vec, current: &mut String) { @@ -896,7 +896,7 @@ mod tests { assert!(out.contains("Example number 1 fails")); assert!(out.contains("Example number 5 fails")); assert!(!out.contains("Example number 6 fails")); - assert!(out.contains("+2 more failures")); + assert!(out.contains("[…2 failures elided…]")); assert!(out.contains("7 examples, 7 failures")); } diff --git a/crates/pi-shell/src/minimizer/filters/rust_tools.rs b/crates/pi-shell/src/minimizer/filters/rust_tools.rs index 498fb5184..906ec13fd 100644 --- a/crates/pi-shell/src/minimizer/filters/rust_tools.rs +++ b/crates/pi-shell/src/minimizer/filters/rust_tools.rs @@ -79,7 +79,7 @@ fn condense_rustfmt(input: &str, exit_code: i32) -> String { out.push('\n'); } if total > 3 { - let _ = writeln!(out, " … {} more", total - 3); + let _ = writeln!(out, " […{} files elided…]", total - 3); } out } @@ -125,7 +125,7 @@ mod tests { assert!(out.changed); assert!(out.text.contains("50 files reformatted")); assert!(out.text.contains("src/file_0.rs")); - assert!(out.text.contains("… 47 more")); + assert!(out.text.contains("[…47 files elided…]")); // Diff bodies must be elided. assert!(!out.text.contains("old line")); // Savings ratio ≥ 0.7 @@ -154,7 +154,7 @@ mod tests { let out = filter(&context, &input, 1); assert!(out.changed); // head_tail_lines(input, 80, 40) keeps 120 lines + marker. - assert!(out.text.contains("lines omitted")); + assert!(out.text.contains("ln elided…]")); } #[test] diff --git a/crates/pi-shell/src/minimizer/filters/system.rs b/crates/pi-shell/src/minimizer/filters/system.rs index c2ffb1c3f..f1e4f19a6 100644 --- a/crates/pi-shell/src/minimizer/filters/system.rs +++ b/crates/pi-shell/src/minimizer/filters/system.rs @@ -1,6 +1,6 @@ //! Conservative text filters for system-style commands. -use std::collections::HashMap; +use std::{collections::HashMap, fmt::Write as _}; use super::git; use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; @@ -358,9 +358,7 @@ fn render_counted_lines(lines: &[LogLine], head: usize, tail: usize) -> String { for line in lines.iter().take(head) { push_counted_line(&mut out, &line.original, line.count); } - out.push_str("… "); - out.push_str(&(lines.len() - head - tail).to_string()); - out.push_str(" unique lines omitted …\n"); + let _ = writeln!(out, "[…{} unique lines elided…]", lines.len() - head - tail); for line in lines.iter().skip(lines.len() - tail) { push_counted_line(&mut out, &line.original, line.count); } @@ -750,9 +748,7 @@ fn compact_format_output(input: &str) -> String { out.push('\n'); } if files.len() > 50 { - out.push_str("… "); - out.push_str(&(files.len() - 50).to_string()); - out.push_str(" more files\n"); + let _ = writeln!(out, "[…{} files elided…]", files.len() - 50); } } if errors.is_empty() && summary.is_empty() && files.is_empty() { @@ -1073,7 +1069,7 @@ Time 0.42s assert!(out.text.contains("errors:")); assert!(out.text.contains("failed to parse src/bad.py")); assert!(out.text.contains("files:")); - assert!(out.text.contains("more files")); + assert!(out.text.contains("[…50 files elided…]")); } #[test] diff --git a/crates/pi-shell/src/minimizer/plan.rs b/crates/pi-shell/src/minimizer/plan.rs index c7e15dce3..1cab90049 100644 --- a/crates/pi-shell/src/minimizer/plan.rs +++ b/crates/pi-shell/src/minimizer/plan.rs @@ -28,7 +28,7 @@ use brush_parser::{ ParserOptions, SourceInfo, ast::{ AndOr, Command, CommandPrefixOrSuffixItem, CompoundListItem, IoFileRedirectTarget, - IoRedirect, Pipeline, Program, SeparatorOperator, Word, + IoRedirect, Pipeline, Program, SeparatorOperator, SimpleCommand, Word, }, }; @@ -66,21 +66,24 @@ pub enum CommandPlan { /// Parse `command` with `brush-parser` and classify its structure. #[must_use] pub fn analyze(command: &str) -> CommandPlan { - let trimmed = command.trim(); - if trimmed.is_empty() { + if command.trim().is_empty() { return CommandPlan::Unsupported; } + let Some(program) = parse(command) else { + return CommandPlan::Unsupported; + }; + classify(&program) +} +/// Parse `command` with the same `brush-parser` configuration the vendored +/// runtime uses, discarding error detail. Returns `None` on any syntax error +/// or unsupported construct. +fn parse(command: &str) -> Option { let options = ParserOptions::default(); let source_info = SourceInfo::default(); let reader = std::io::Cursor::new(command.as_bytes()); let mut parser = brush_parser::Parser::new(reader, &options, &source_info); - - let Ok(program) = parser.parse_program() else { - return CommandPlan::Unsupported; - }; - - classify(&program) + parser.parse_program().ok() } fn classify(program: &Program) -> CommandPlan { @@ -216,51 +219,115 @@ fn io_redirect_is_safe(io: &IoRedirect) -> bool { } } +/// True when every part of a simple command is safe to reconstruct through +/// `brush`'s `Display` impl: no command/process substitutions and no here-doc +/// in the command word, prefix, or suffix (those re-emit in forms that diverge +/// from the source). The full reconstruction is still re-parse-verified by +/// [`reconstruction_reparses_to_same_shape`] before any segment is executed. +fn simple_command_is_safe(simple: &SimpleCommand) -> bool { + if let Some(prefix) = simple.prefix.as_ref() + && prefix + .0 + .iter() + .any(|item| !command_prefix_or_suffix_item_is_safe(item)) + { + return false; + } + if let Some(suffix) = simple.suffix.as_ref() + && suffix + .0 + .iter() + .any(|item| !command_prefix_or_suffix_item_is_safe(item)) + { + return false; + } + if let Some(word) = simple.word_or_name.as_ref() + && word_has_command_substitution(word) + { + return false; + } + true +} + fn simple_segment(pipeline: &Pipeline) -> Option<(String, String)> { if pipeline.timed.is_some() || pipeline.bang || pipeline.seq.is_empty() { return None; } - // For multi-stage pipes inside a chain segment, identify the segment by its - // first stage's program. The downstream per-segment minimizer::apply will - // detect the pipeline at runtime via plan::CommandPlan::Piped and pass it - // through unchanged — so a piped segment is safely captured but never - // rewritten. This keeps the chain decomposable when even one inner stage - // uses a pipe (e.g. `ls | head -10 && git status`). - let first = pipeline.seq.first()?; - match first { - Command::Simple(simple) => { - if simple.prefix.as_ref().is_some_and(|prefix| { - prefix - .0 - .iter() - .any(|item| !command_prefix_or_suffix_item_is_safe(item)) - }) { - return None; - } - if simple.suffix.as_ref().is_some_and(|suffix| { - suffix - .0 - .iter() - .any(|item| !command_prefix_or_suffix_item_is_safe(item)) - }) { - return None; - } - - let program_word = simple.word_or_name.as_ref()?; - if word_has_command_substitution(program_word) { - return None; - } - let program = program_word.to_string(); - if program.trim().is_empty() { - return None; - } - Some((pipeline.to_string(), program)) - }, - // Compound shell syntax (if / for / while / subshell / { ... }) is - // not something the minimizer should touch. - Command::Compound(..) | Command::Function(_) | Command::ExtendedTest(..) => None, + // Every stage must be a Display-safe simple command. Compound stages + // (`if` / `for` / `while` / subshells / `{ … }`) and unsafe words/redirects + // (here-docs, substitutions) do not round-trip through `Display`. Validating + // only `seq.first()` once let a compound later stage through — e.g. + // `git log … | while read x; do … done` — and the reconstructed segment then + // failed to execute with "syntax error at end of input". + for command in &pipeline.seq { + let Command::Simple(simple) = command else { + return None; + }; + if !simple_command_is_safe(simple) { + return None; + } } + + // Identify the segment by its first stage's program word. Multi-stage pipes + // are captured but never rewritten (runtime detects `CommandPlan::Piped`), + // keeping the chain decomposable when an inner stage pipes (e.g. + // `ls | head -10 && git status`). + let Command::Simple(first) = pipeline.seq.first()? else { + return None; + }; + let program_word = first.word_or_name.as_ref()?; + let program = program_word.to_string(); + if program.trim().is_empty() { + return None; + } + + // The chain runner re-executes this reconstructed string verbatim. brush's + // `Display` is not a guaranteed inverse of its parser, so re-parse the + // reconstruction and require the same pipeline shape before committing to + // segmentation. Any divergence (a lossy compound terminator today, a future + // `Display` change tomorrow) falls back to the unsegmented whole-command + // path instead of failing at execution. + let command = pipeline.to_string(); + if !reconstruction_reparses_to_same_shape(&command, pipeline.seq.len()) { + return None; + } + Some((command, program)) +} + +/// Confirm a reconstructed segment string re-parses to the *same shape* it was +/// built from: exactly one sequential top-level pipeline with `expected_stages` +/// commands, no `&&`/`||`/`;`/`&` continuation, and no `!`/`time` modifier. +/// +/// This is a syntax/shape guard, not a proof of full semantic equivalence. It +/// guarantees the chain runner never executes a reconstruction that fails to +/// parse or that `Display` reshaped into different top-level structure. brush's +/// `Display` is not a guaranteed inverse of its parser — compound terminators +/// (`while … done`) and quoted here-doc close tags re-emit in forms that fail +/// to re-parse — so a divergent segment drops back to the unsegmented +/// whole-command path instead of blowing up at execution with +/// "pi-natives:command: syntax error". The per-stage `simple_command_is_safe` +/// whitelist already excludes constructs whose `Display` is value-lossy +/// (substitutions, here-docs); words carry raw source text and round-trip +/// verbatim. +fn reconstruction_reparses_to_same_shape(reconstructed: &str, expected_stages: usize) -> bool { + let Some(program) = parse(reconstructed) else { + return false; + }; + let mut items = program.complete_commands.iter().flat_map(|cl| cl.0.iter()); + let Some(CompoundListItem(and_or, separator)) = items.next() else { + return false; + }; + // A second top-level item, a trailing `&` (Async), or an `&&`/`||` + // continuation all mean `Display` reshaped the command. + if items.next().is_some() + || !and_or.additional.is_empty() + || matches!(separator, SeparatorOperator::Async) + { + return false; + } + let pipeline = &and_or.first; + !pipeline.bang && pipeline.timed.is_none() && pipeline.seq.len() == expected_stages } fn classify_pipeline(pipeline: &Pipeline) -> Option { @@ -453,4 +520,50 @@ mod tests { assert_eq!(analyze(""), CommandPlan::Unsupported); assert_eq!(analyze(" "), CommandPlan::Unsupported); } + + #[test] + fn compound_later_pipeline_stage_is_not_segmented() { + // Regression: a pipeline whose *later* stage is a compound command + // (`while`/`for`/`if`/subshell) must never be segmented. The chain runner + // re-executes `pipeline.to_string()`, and brush's `Display` drops the + // compound terminator, so the reconstruction failed to re-parse and blew + // up at execution with "syntax error at end of input". Validating only + // `seq.first()` (a simple `git`/`seq`) let it slip through. + assert_not_chain( + "echo start && git log --oneline | while read h; do echo \"$h\"; done | head -3", + ); + assert_not_chain("seq 3 | while read n; do echo \"$n\"; done && echo done"); + assert_not_chain("printf x && ls | for f in a b; do echo $f; done"); + // The reported shape classifies as Compound, so it runs whole and + // unsegmented via the single path (no reconstruction, no minimization). + assert_eq!( + analyze("echo a && seq 2 | while read n; do echo $n; done"), + CommandPlan::Compound, + ); + } + + #[test] + fn chain_segments_reparse_cleanly() { + // Every command string the chain runner will execute must itself re-parse + // to a single pipeline — the contract enforced by + // `reconstruction_reparses_to_same_shape`. A segment whose `Display` + // reconstruction diverges is dropped rather than emitted. + for command in [ + "git diff --stat && git diff --name-only", + "ls -lh *.txt | head -5 && git status --short", + "grep -c foo bar 2>/dev/null && echo done", + "FOO=1 git status ; bun test", + ] { + let Some(segments) = chain_of(analyze(command)) else { + panic!("{command:?} should classify as Chain"); + }; + for segment in &segments { + assert!( + parse(&segment.command).is_some(), + "segment {:?} from {command:?} did not re-parse", + segment.command, + ); + } + } + } } diff --git a/crates/pi-shell/src/minimizer/primitives.rs b/crates/pi-shell/src/minimizer/primitives.rs index 6e494b4ed..4dfa2f074 100644 --- a/crates/pi-shell/src/minimizer/primitives.rs +++ b/crates/pi-shell/src/minimizer/primitives.rs @@ -93,9 +93,9 @@ pub fn head_tail_lines(input: &str, head: usize, tail: usize) -> String { out.push_str(line); out.push('\n'); } - out.push_str("… "); + out.push_str("[…"); out.push_str(&omitted.to_string()); - out.push_str(" lines omitted …\n"); + out.push_str("ln elided…]\n"); for line in lines.iter().skip(lines.len() - tail) { out.push_str(line); out.push('\n'); @@ -292,9 +292,9 @@ pub fn head_lines_only(input: &str, head: usize) -> String { out.push_str(line); out.push('\n'); } - out.push_str("… "); + out.push_str("[…"); out.push_str(&omitted.to_string()); - out.push_str(" lines omitted …\n"); + out.push_str("ln elided…]\n"); out } @@ -307,9 +307,9 @@ pub fn tail_lines_only(input: &str, tail: usize) -> String { } let omitted = lines.len() - tail; let mut out = String::new(); - out.push_str("… "); + out.push_str("[…"); out.push_str(&omitted.to_string()); - out.push_str(" lines omitted …\n"); + out.push_str("ln elided…]\n"); for line in lines.iter().skip(omitted) { out.push_str(line); out.push('\n'); @@ -330,9 +330,9 @@ pub fn max_lines(input: &str, max: usize) -> String { out.push_str(line); out.push('\n'); } - out.push_str("… "); + out.push_str("[…"); out.push_str(&dropped.to_string()); - out.push_str(" lines truncated …\n"); + out.push_str("ln elided…]\n"); out } @@ -380,7 +380,7 @@ mod tests { #[test] fn head_tail_marks_omitted_lines() { let out = head_tail_lines("1\n2\n3\n4\n5\n", 2, 1); - assert_eq!(out, "1\n2\n… 2 lines omitted …\n5\n"); + assert_eq!(out, "1\n2\n[…2ln elided…]\n5\n"); } #[test] @@ -397,7 +397,7 @@ mod tests { .collect::>() .join("\n"); let out = head_tail_cap(&input, CapClass::List); - assert!(out.contains("lines omitted")); + assert!(out.contains("ln elided…]")); assert!(out.lines().count() <= CapClass::List.lines() + 1); } diff --git a/crates/pi-shell/src/shell.rs b/crates/pi-shell/src/shell.rs index 44d9e3bad..9ac8a7ff8 100644 --- a/crates/pi-shell/src/shell.rs +++ b/crates/pi-shell/src/shell.rs @@ -2237,6 +2237,67 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }] assert!(!output.contains("unterminated")); } + /// Regression: a `&&` / `;` chain whose later pipeline stage is a compound + /// command (`while … done`) must execute instead of failing with + /// "pi-natives:command: syntax error at end of input". The segmented chain + /// runner rebuilt each segment via the brush AST `Display` impl, but only + /// validated the *first* pipeline stage — so a compound later stage was + /// reconstructed without its terminator and re-run as invalid shell. Such a + /// command now bails out of segmentation and runs whole via the single path. + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread")] + async fn compound_stage_in_chain_runs_via_single_path() { + let root = unique_temp_dir("compound-chain"); + let minimizer = printf_minimizer(&root.join("minimizer.toml"), None); + let (result, output) = run_command_capture( + "printf 'start\\n' && seq 5 | while read n; do echo \"n=$n\"; done | head -2", + None, + Some(minimizer), + CancelToken::default(), + ) + .await; + let _ = std::fs::remove_dir_all(&root); + assert_eq!(result.exit_code, Some(0)); + assert_eq!(output, "start\nn=1\nn=2\n"); + assert!(!output.contains("syntax error")); + // Ran whole (unsegmented), so nothing was minimized. + assert!(result.minimized.is_none()); + } + + /// A segment that carries a file redirect is still segmented, and the brush + /// `Display` reconstruction the runner executes must round-trip through + /// brush's own parser **without losing the redirect**. `echo hidden + /// >/dev/null` suppresses its own stdout: if the reconstruction dropped the + /// redirect, `hidden` would leak into the captured output. Proves the + /// reconstruction path is semantically sound for the redirect-bearing + /// shapes the per-stage whitelist accepts (not just syntactically + /// parseable). + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread")] + async fn segmented_chain_with_redirect_executes_correctly() { + let root = unique_temp_dir("redirect-chain"); + let minimizer = printf_minimizer(&root.join("minimizer.toml"), None); + let (result, output) = run_command_capture( + "echo hidden >/dev/null && printf 'hello\\n'", + None, + Some(minimizer), + CancelToken::default(), + ) + .await; + let _ = std::fs::remove_dir_all(&root); + assert_eq!(result.exit_code, Some(0)); + // The redirect survived reconstruction: segment 1's stdout went to + // /dev/null, so only segment 2's output is captured. + assert!(!output.contains("hidden"), "redirect must suppress segment-1 stdout"); + assert_eq!(output, "hello\n"); + let minimized = result + .minimized + .expect("redirect chain should be minimized"); + assert_eq!(minimized.original_text, "hello\n"); + assert_eq!(minimized.text, "HI\n"); + assert!(!output.contains("syntax error")); + } + #[cfg(unix)] #[tokio::test(flavor = "multi_thread")] async fn segmented_chain_exceeding_aggregate_capture_cap_stays_raw() { diff --git a/docs/advisor-watchdog.md b/docs/advisor-watchdog.md index 0ed4768bf..d64d10eab 100644 --- a/docs/advisor-watchdog.md +++ b/docs/advisor-watchdog.md @@ -9,6 +9,7 @@ The advisor is not a second executor. It cannot edit files, run commands, approv - [`src/advisor/runtime.ts`](../packages/coding-agent/src/advisor/runtime.ts) - [`src/advisor/advise-tool.ts`](../packages/coding-agent/src/advisor/advise-tool.ts) - [`src/advisor/watchdog.ts`](../packages/coding-agent/src/advisor/watchdog.ts) +- [`src/advisor/transcript-recorder.ts`](../packages/coding-agent/src/advisor/transcript-recorder.ts) - [`src/prompts/advisor/system.md`](../packages/coding-agent/src/prompts/advisor/system.md) - [`src/prompts/advisor/advise-tool.md`](../packages/coding-agent/src/prompts/advisor/advise-tool.md) - [`src/session/agent-session.ts`](../packages/coding-agent/src/session/agent-session.ts) @@ -203,4 +204,22 @@ The advisor has its own append-only context. Before each advisor prompt, `AgentS 2. if promotion cannot fit enough context, compact the advisor's own message history 3. if compaction has no candidates or still cannot fit, re-prime from the current bounded primary transcript -The advisor transcript is in-memory for the session. It is retained while the session runs so `/advisor dump` can inspect it, but advisor state is not a replacement for the primary persisted transcript. +The advisor's live context is in-memory and append-only; it is retained while the session runs so `/advisor dump` can inspect it, and is independently promoted/compacted/re-primed (above). It is not a replacement for the primary persisted transcript. + +## Transcript persistence and observability + +The advisor is a passive reviewer with its own model usage, so — like a task subagent — every finalized advisor turn is appended to a JSONL inside the owning session's artifacts dir: + +- main session: `/__advisor.jsonl` +- subagent advisor (`advisor.subagents: true`): `//__advisor.jsonl` + +The path is derived from the session file (not the artifacts dir, which subagents share with their parent), so each advisor writes a distinct file. The reserved `__advisor` stem cannot collide with a task subagent's `.jsonl` (task id allocation reserves it). + +Why a file: + +- **Usage attribution.** `omp stats` scans each session folder recursively, so advisor assistant turns (with their usage/cost) are attributed to the same project/session like any other subagent. Advisor "session update" prompts are persisted as `synthetic`, agent-attributed user messages so they never inflate user-message metrics. +- **Observability.** The Agent Hub discovers `__advisor.jsonl` on open and shows it as a read-only `advisor`-kind transcript under its owning session. + +The file follows session switches: on `/new`, resume/switch, and branch the recorder reopens at the new session's path on the next advisor turn; before a `/drop` deletes the old artifacts dir the recorder feed is detached and drained so a queued write cannot recreate the deleted file. The on-disk log is append-only and independent of the in-memory context — re-primes and compaction never truncate it. + +The advisor is never a peer. The `advisor`-kind registry ref is excluded from every agent-facing surface — the `irc` peer roster and broadcast targets, the subagent peer prompt, and the `history://` index/lookup/completions — and cannot be messaged (`irc send` and collab chat refuse it) or revived/killed from the Agent Hub or collab. It is observability only. diff --git a/docs/collab.md b/docs/collab.md index e86a709a3..ec3a50a92 100644 --- a/docs/collab.md +++ b/docs/collab.md @@ -20,6 +20,8 @@ Collab session started! The browser line is click-to-join (an OSC 8 hyperlink to the full `https://` deep link): the relay serves the web guest client at `/`, and the room id + key ride in the URL fragment. From another omp (any directory, any machine), either form works: +Running `/collab` or `/collab view` starts or displays the active hosting session, rendering both the terminal/browser join links and their corresponding QR codes. + ``` /join my.omp.sh/#mgAYTZwEnpRQtca0CTgn-Q.gdJU… ``` @@ -30,9 +32,9 @@ The guest's previous session is restored on `/leave` (or when the host stops). | Command | Effect | |---|---| -| `/collab` | Start sharing (or re-print the link when already hosting) | +| `/collab` | Start sharing full-control (or re-print the link/QR when already hosting) | | `/collab ` | Start sharing through a specific relay (`relay.example.com`, `ws://localhost:7475`) | -| `/collab view` | Print a read-only (view-only) link (starts sharing first if needed) | +| `/collab view` | Start sharing read-only (or re-print the link/QR when already hosting) | | `/collab status` | Show link + participants | | `/collab stop` | Stop sharing | | `/join ` | Join a shared session as a guest | @@ -40,19 +42,29 @@ The guest's previous session is restored on `/leave` (or when the host stops). ## Link format +Accepted by `/join ` and `omp join ""`: + ``` -https://host[:port]/# → browser deep link (printed by /collab; /join accepts it too) -. → default relay (my.omp.sh) -host[:port]/r/. → custom relay, wss:// inferred -ws://localhost:7475/r/. → plain ws, allowed for localhost only +. → default relay (wss://my.omp.sh) +# → legacy bare form +host[:port]/r/. → custom relay, wss:// inferred +host[:port]/r/# → legacy direct relay form +https://host[:port]/r/. → direct relay URL, normalized to wss:// +wss://host[:port]/r/. → direct websocket relay URL +ws://localhost:7475/r/. → direct plain ws, localhost only +https://host[:port]/# → browser deep link when web UI and relay share a host +https://web-host[:port][/]/# → browser UI wrapper with relay link in the fragment +https://web.example/collab/#relay.example.com/r/. → web UI and relay on different hosts ``` -The trailing `.` part is the room secret, base64url-encoded, in one of two strengths: +`` / `` are parsed recursively as any accepted link above. For `http(s)` browser wrappers with a parseable fragment, the fragment wins before the HTTP host/path are treated as a relay. This lets `https://web.example/collab/#relay.example.com/r/.` open the web UI at `web.example` while joining `wss://relay.example.com/r/`. If the fragment is not a complete collab link, parsing falls back to the legacy direct relay form, so `https://relay.example.com/r/#` still means relay `relay.example.com`. + +The trailing `.` or `#` part is the room secret, base64url-encoded, in one of two strengths: - **Full link** — 48 bytes: the 32-byte AES-256-GCM room key followed by a 16-byte write token. Grants prompting, interrupting, and subagent control. - **View-only link** — the bare 32-byte key, no write token. Grants live read access only. Pre-token links parse as view-only. -The room secret is dot-joined rather than `#`-joined: RFC 3986 forbids a raw `#` inside a URL fragment, so strict URL stacks (macOS Foundation behind terminal click-to-open) percent-encode a second `#` to `%23` and break the link. Parsers leniently accept the legacy `#` form and the mangled `%23` form. In the browser deep link, everything after the `#` — room id and key — is a URL fragment: it never appears in any HTTP request, and neither secret is ever sent to the relay. +The room secret is dot-joined in newly generated links because RFC 3986 forbids a raw `#` inside a URL fragment; parsers still accept legacy `#` forms and `%23`-mangled legacy deep links. ## End-to-end encryption @@ -85,11 +97,14 @@ Known v1 limit for guests: a turn already streaming when you join becomes visibl `packages/collab-web` is a standalone browser client for the same links — no omp install needed on the guest side. The relay serves it at `/`, which is what makes the `/collab` deep link click-to-join: `https:///#` loads the client and auto-connects from the fragment. It renders the live transcript (streaming text, thinking, tool cards), a subagent panel with on-demand transcripts, and a composer with the same guest powers (prompt, interrupt, hub actions). Run `bun run dev` in the package for a local instance, `bun run mock-host` for an offline scripted host to develop against, and `bun run build` to emit a static `dist/` deployable anywhere (HTTPS required for WebCrypto). The client never talks to anything but the relay, and the key stays in the URL fragment. +Set `collab.webUrl` when the browser UI is hosted separately from the websocket relay. When empty, `/collab` derives `http(s)://host[:port]` from `collab.relayUrl`; explicit web UI URLs must use `https://` except for `http://localhost` development origins. The generated browser URL still carries the relay-specific collab link in the fragment. + ## Settings | Setting | Default | Meaning | |---|---|---| | `collab.relayUrl` | `wss://my.omp.sh` | Relay used by `/collab` when no relay is passed inline | +| `collab.webUrl` | empty | Browser UI URL for `/collab` links; empty derives from relay; explicit `http://` is allowed only for localhost | | `collab.displayName` | OS username | Name shown to other participants | | `share.serverUrl` | `https://my.omp.sh/s` | Share viewer/upload base used by `/share` (links are `/#`) | | `share.redactSecrets` | `true` | Run the secret obfuscator over `/share` snapshots before upload | diff --git a/docs/compaction.md b/docs/compaction.md index 84a0cb0c5..84adb67f6 100644 --- a/docs/compaction.md +++ b/docs/compaction.md @@ -134,8 +134,8 @@ The automatic paths are intentionally different: - The discarded history is serialized, whitespace-collapsed, and printed onto model-aware PNG frames (frame width fixed per shape; frame height hugs the rows actually printed) using bundled public-domain pixel fonts. The shape — and frame size — resolve from the **model id** when the model line was measured: Claude reads X.org `8x13` glyphs on an 11px advance (extra letter-spacing, black ink — `11on16-bw`; high-res lines — Opus 4.7+, Fable, Mythos — get 1932px frames under Anthropic's 4,784 visual-token cap, older lines stay at 1568px), Gemini reads `8x13` glyphs on a 22px pitch (extra leading, black ink — `8on22-bw` at 2048px, since Gemini 3.x bills a fixed 1,120-token budget per image at any pixel size), GPT/Codex read the same `8on22-bw` shape at 1568px (patch billing is area-proportional, so larger frames cannot improve chars per token), and Kimi/GLM read `8x13` glyphs on a 16px pitch (`8on16-bw` at 1568px — kimi's processor downscales past 1792px). A Claude routed through Vertex or OpenRouter keeps its Claude shape. Unmeasured models fall back to their wire API family (Anthropic-family/unknown → `11on16-bw`, Google → `8on22-bw`, OpenAI-compatible → `8on22-bw`); billing (per-family patch/budget formulas, OpenAI's `detail: "original"` hint) always follows the API carrying the request, computed for the resolved frame size. The `snapcompact.shape` setting (default `auto`) forces one of the research-eval variants instead: square grids (`8x8r`/`8x8u`/`6x6u`/`5x8` × sentence-hue/black ink) or the per-model eval winners (`6x12-dim`, `8x13-bw`, `8on16-bw`, `8on22-bw`, `11on16-bw`, and the two-column word-wrapped `doc-8on16-bw`/`-sent`/`-sent-dim`, where `dim` prints stopwords in gray). A forced variant keeps its geometry but is re-priced for the target provider's image billing. The same setting governs inline system-prompt/tool-result imaging (`snapcompact.systemPrompt`, `snapcompact.toolResults`). - Serialization keeps the archive conversation-dense: tool results are truncated head+tail (default 2,000 chars at a 0.6 head ratio), tool-call argument values are capped per value (500) and per call (2,000), and tool output is printed in dim gray ink so conversation reads louder than tool noise. All budgets and the dimming are configurable via `SerializeOptions` (`toolResultMaxChars`, `toolArgMaxChars`, `toolCallMaxChars`, `truncateHeadRatio`, `dimToolResults`). -- Frames persist under `CompactionEntry.preserveData.snapcompact` and are re-attached to the `compactionSummary` message as image blocks on every context rebuild; the entry's `summary` is a deterministic reading guide (grid geometry, role tags, truncation notes) plus the usual file-operation lists. -- Later compactions carry earlier frames forward. The frame budget is provider-aware (`providerFrameBudget`): the per-provider image cap clamped to 8 (`MAX_FRAMES`) — OpenRouter hard-caps requests at 8 images and silently drops the excess, unknown providers get a safe floor of 5. Beyond the budget the archive fades from the middle out: the earliest frame (session head — the original request, or the filmed summary of older history) is pinned, and the oldest *unpinned* frames are evicted. Pages of the *current* compaction that no longer fit are never rendered or dropped — the newest unframed slice survives verbatim as a text tail on the summary (`Archive.textTail`, capped at two frame capacities with middle elision) and is folded back into frames by the next compaction. If the previous compaction was text-based, its summary is printed at the head of the frame archive as `[Summary of earlier history]`. +- The snapcompact archive persists under `CompactionEntry.preserveData.snapcompact` as bounded source text plus rendered frames. On each context rebuild it is reconstructed into ordered compaction blocks: plain text at the oldest edge, an imaged middle, then plain text at the newest edge. The entry's `summary` is just the short resume lead-in plus the usual file-operation list. +- Later compactions re-render from that bounded source text (`Archive.text`), not by carrying old PNGs forward blindly. `maxFrames` now defaults to `MAX_FRAMES_DEFAULT` (80) and acts only as an upper limit; when the imaged middle is large it foveates internally (HQ/LQ/HQ), while both chronological edges stay verbatim text. - No model, API key, or network is involved, so snapcompact is also safe for overflow recovery. It requires a vision-capable current model (`model.input` includes `"image"`); otherwise the run falls back to context-full and emits a warning notice (auto and manual paths). Manual `/compact` honors the strategy unless custom instructions are given (those imply a directed LLM summary). - Rationale: the shape table comes from the snapcompact 200k-token evals in `packages/snapcompact`, where bitmap frames preserved QA recall at lower billed-token cost than raw text for vision-capable models. @@ -263,7 +263,7 @@ Cumulative behavior: - In split turns, includes turn-prefix file ops too. - `details.readFiles` excludes files also modified; `details.modifiedFiles` carries the rest (persisted shape is unchanged). -Summary text gets one `` tag appended via prompt template: a grouped, prefix-folded directory tree (find-tool shape) with a per-file access marker — `(Read)` for read-only files, `(Write)` for modified files never read, `(RW)` for modified files also present in the cumulative read set. Capped at 20 files with an `… (N more files omitted)` line. +The file list is a grouped, prefix-folded directory tree (find-tool shape) with a per-file access marker — `(Read)` for read-only files, `(Write)` for modified files never read, `(RW)` for modified files also present in the cumulative read set. Capped at 20 files with an `[…N files elided…]` line. LLM-summary strategies append it as a `` tag (via `upsertFileOperations`); snapcompact renders it inside its summary template as a `FILES` section instead. ```xml diff --git a/docs/resolve-tool-runtime.md b/docs/resolve-tool-runtime.md index 298dc9953..d7a692863 100644 --- a/docs/resolve-tool-runtime.md +++ b/docs/resolve-tool-runtime.md @@ -1,6 +1,6 @@ # Resolve tool runtime internals -This document explains how preview/apply workflows are modeled in coding-agent and how built-in or custom tools can participate via the tool-choice queue and `pushPendingAction`. +This document explains how preview/apply workflows are modeled in coding-agent and how built-in or custom tools can participate via the pending-invoker registry and `pushPendingAction`. (Pending previews live in a separate non-forcing registry inside `ToolChoiceQueue`; only genuine hard forces use the consuming directive queue.) ## Scope and key files @@ -24,20 +24,32 @@ If no pending action exists, `resolve(action="apply")` fails with: `resolve(action="discard")` with no pending action succeeds instead, returning `Nothing to discard; no pending action remains.` — the desired end-state (no staged change) already holds. -## Pending actions use the tool-choice queue +## Pending previews use a non-forcing soft tool requirement -Preview producers call `queueResolveHandler(...)`, which pushes a one-shot forced `resolve` directive onto the session tool-choice queue and adds a `resolve-reminder` steering message. +Preview producers call `queueResolveHandler(...)`, which registers a non-forcing +pending invoker on the session (a stack keyed by a unique +`pending-action::` id — never clobbered by label). It does NOT force +`tool_choice` and does NOT inject a steering reminder. + +While a preview is pending, the session's `getToolChoice` callback +(`nextToolChoiceDirective`) returns a `SoftToolRequirement` (`toolName: "resolve"`) +carrying the resolve reminder, as a non-consuming peek. The agent runtime owns the +lifecycle: it injects the reminder once, runs with `tool_choice` unchanged, and +escalates to a one-turn forced `resolve` choice ONLY if the model fails to call +`resolve` that turn (skipping any detour tool batch first). A model that resolves +on the reminder pays no message-cache invalidation — the previous design forced +`tool_choice` on every preview, busting the provider message cache twice per cycle. Runtime behavior: -- the queued handler owns the pending `apply`/`reject` callbacks, -- `resolve` looks up the current queue invoker with `session.peekQueueInvoker()`, -- if the model rejects the forced tool choice, the queue directive is requeued, -- `resolve` does not maintain a separate pending-action stack. +- the pending invoker owns the `apply`/`reject` callbacks, +- `resolve` dispatches via `peekQueueInvoker() ?? peekPendingInvoker() ?? peekStandingResolveHandler()`, +- a genuine hard forced tool choice (queued via `nextToolChoice`) preempts the soft requirement, +- if an apply callback throws, the helper re-registers the same pending invoker (same id) so the preview can still be discarded or retried. -`resolve` also checks a standing resolve handler after the queue invoker; this is used by long-lived approval flows that are not ordinary preview tool calls. +`resolve` also checks a standing resolve handler after the invokers; this is used by long-lived approval flows that are not ordinary preview tool calls. -Multiple pending previews therefore follow the active tool-choice queue ordering, not an independent pending-action store. If an apply callback throws, the queued helper re-pushes the same resolve directive and reminder so the preview can still be discarded or retried. +Multiple pending previews stack as unique-keyed invokers and resolve independently (head-first), not through forced tool-choice ordering. ## Built-in producer example (`ast_edit`) @@ -130,4 +142,4 @@ When `queueResolveHandler(...)` registers a preview, the agent runtime forces a - Keep `label` concise and specific; it is shown in resolve renderer output. - Ensure `apply(reason)` is deterministic and idempotent enough for one-shot execution; `reason` is informational and should not change behavior. - Implement `reject(reason)` when the discard needs cleanup (temp state, locks, notifications); omit it for stateless previews where the default message suffices. -- If your tool can stage multiple previews, remember they are mediated by the tool-choice queue rather than a separate pending-action stack. +- If your tool can stage multiple previews, remember they stack as unique-keyed pending invokers (resolved head-first), not a forced tool-choice sequence and not a separate `pushPendingAction` stack. diff --git a/docs/theme.md b/docs/theme.md index 18fb4aebb..d26ba8039 100644 --- a/docs/theme.md +++ b/docs/theme.md @@ -95,6 +95,16 @@ Runtime precedence: Invalid override keys are ignored and logged (`logger.debug`). +#### Box-drawing borders + +All outlined chrome — tool-result frames, overlays, code fences, the editor, the welcome banner — draws with the `boxRound.*` tokens: rounded corners (`╭╮╰╯`) plus tee/cross junctions (`├┤┬┴┼`, which have no rounded Unicode form, so they are sourced from the `boxSharp.*` tokens). Markdown tables are the sole exception and keep the fully sharp `boxSharp.*` set (`┌┐└┘`). + +Override behavior follows from that split: + +- `boxRound.{topLeft,topRight,bottomLeft,bottomRight,horizontal,vertical}` restyle every border's corners and edges. +- `boxSharp.{cross,teeDown,teeUp,teeRight,teeLeft}` restyle dividers/junctions everywhere (rounded frames and tables alike). +- `boxSharp.{topLeft,topRight,bottomLeft,bottomRight}` now affect markdown table corners only. + ## Built-in vs custom theme sources Theme lookup order (`loadThemeJson`): diff --git a/docs/tools/ast-edit.md b/docs/tools/ast-edit.md index d2b417816..c027aeaeb 100644 --- a/docs/tools/ast-edit.md +++ b/docs/tools/ast-edit.md @@ -61,7 +61,7 @@ Shared AST pattern grammar and language catalog: see [`ast_grep`](./ast-grep.md# - compiles every rewrite pattern for that language, - parses each file, skips files with syntax-error trees, collects `replace_by(...)` edits for every match, enforces replacement and file caps, and returns textual before/after slices plus source ranges. 7. The TS wrapper deduplicates and caps parse errors, groups changes by file, and renders preview diff lines. -8. If preview found replacements and `applied` is false, `queueResolveHandler(...)` registers a forced `resolve` action and injects a `resolve-reminder` steering message. +8. If preview found replacements and `applied` is false, `queueResolveHandler(...)` registers a non-forcing pending `resolve` invoker. While it is pending the session surfaces a `SoftToolRequirement` carrying the resolve reminder; the agent runtime injects the reminder and forces `resolve` only if the model declines that turn (no per-preview `tool_choice` cache bust). 9. On `resolve(action: "apply")`, the queued callback reruns the same rewrite set with `dryRun: false`, recomputes counts, and returns an error result if the live result no longer matches the preview (`stalePreview`). The current implementation compares replacement totals and per-file counts after the rerun; if the new run has already written different counts, the result is marked error. 10. On a non-stale apply, the callback returns `Applied N replacements in M files.` (in hashline mode followed by fresh `[path#tag]` snapshot headers re-recorded from the post-apply content); on discard, `resolve` returns a discard message without mutating files. @@ -79,8 +79,8 @@ Shared AST pattern grammar and language catalog: see [`ast_grep`](./ast-grep.md# - Preview reads files and scans directories. - Apply rewrites files in place with `std::fs::write(...)`, but only when the computed output differs from the original source. - Session state (transcript, memory, jobs, checkpoints, registries) - - Queues a one-shot forced `resolve` tool choice through `queueResolveHandler(...)`. - - Adds a `resolve-reminder` steering message. + - Registers a non-forcing pending `resolve` invoker through `queueResolveHandler(...)`. + - Surfaces a `SoftToolRequirement` (with the resolve reminder) while pending; the agent runtime forces `resolve` only on non-compliance — no steering message and no per-preview forced tool choice. - User-visible prompts / interactive UI - Direct `ast_edit` results are previews. - Follow-up apply/discard is exposed through the hidden `resolve` tool. diff --git a/docs/tools/read.md b/docs/tools/read.md index 045411856..4199bf7b0 100644 --- a/docs/tools/read.md +++ b/docs/tools/read.md @@ -97,7 +97,7 @@ URL selectors are parsed separately in `packages/coding-agent/src/tools/fetch.ts ### Local text files - No selector: if summarization is enabled and the file is small enough, `#trySummarize()` calls `summarizeCode()`. - Guards: file size `<= 2 MiB` (`MAX_SUMMARY_BYTES`), line count `<= 20_000` (`MAX_SUMMARY_LINES`). - - Summary output keeps selected declarations and replaces elided spans with `...` or merged brace-pair lines containing `..`. When at least one span is elided, the text content ends with a footer like `[NN lines elided; re-read needed ranges, e.g. :5-16,40-80]` using concrete ranges from the actual elisions. + - Summary output keeps selected declarations and replaces elided spans with `…` or merged brace-pair lines containing `{ … }`. When at least one span is elided, the text content ends with a footer like `[…NNln elided; re-read needed ranges, e.g. :5-16,40-80]` using concrete ranges from the actual elisions. - When an elided block sits between matching brace lines, `#renderSummary()` may merge them into one anchored line rather than emitting separate opener/closer lines. - Explicit selector or summarization miss: streamed text read. - Default open-ended limit is `min(session setting read.defaultLimit, DEFAULT_MAX_LINES)`. diff --git a/docs/tools/resolve.md b/docs/tools/resolve.md index 6f733ae68..1027fa43a 100644 --- a/docs/tools/resolve.md +++ b/docs/tools/resolve.md @@ -34,9 +34,8 @@ ## Flow 1. Preview-producing code can call `queueResolveHandler(...)` with a label, source tool name, `apply(reason, extra?)` callback, and optional `reject(reason, extra?)` callback. 2. Modes can also register a standing resolve handler through `session.setStandingResolveHandler(...)`; `resolve.execute()` consults it only when no queued invoker is active. -3. `queueResolveHandler(...)` asks the session for a forced `resolve` tool choice and pushes it into the tool-choice queue with `pushOnce(...)`. -4. The queued entry is marked `now: true`; if the model rejects that forced tool choice, `onRejected` returns `requeue`, so the reminder comes back. -5. `queueResolveHandler(...)` also injects a `resolve-reminder` steering message: +3. `queueResolveHandler(...)` registers a non-forcing pending invoker on the session's tool-choice queue under a unique `pending-action::` id. It does NOT force a tool choice and does NOT steer a reminder. +4. While a preview is pending, the session's `getToolChoice` (`nextToolChoiceDirective`) returns a `SoftToolRequirement` (`toolName: "resolve"`) carrying the resolve reminder — a non-consuming peek. The agent runtime injects the reminder once and forces `tool_choice: resolve` for one turn only if the model declines (see `docs/resolve-tool-runtime.md`). The reminder text is: ```text @@ -44,27 +43,27 @@ This is a preview. Call the `resolve` tool to apply or discard these changes. ``` -6. When `resolve.execute()` runs, it wraps the call in `untilAborted(...)` and fetches `session.peekQueueInvoker?.() ?? session.peekStandingResolveHandler?.()`. -7. If no invoker exists, `apply` throws `ToolError("No pending action to resolve. Nothing to apply or discard.")`; `discard` instead returns a success payload `Nothing to discard; no pending action remains.` because the desired end-state (no staged change) already holds. -8. Otherwise it invokes the current handler with the full params object. -9. `runResolveInvocation(...)` builds base details from `action`, `reason`, `extra`, `sourceToolName`, and `label`. -10. For `apply`, it calls the producer's `apply(reason, extra)` callback. -11. If `apply` throws, `runResolveInvocation(...)` calls `onApplyError` when present. The queued preview integration uses this to re-push the resolve directive and steering reminder so the action remains pending. Non-`ToolError` exceptions are wrapped as `ToolError("Apply failed: ")`. -12. For `discard`, it calls `reject(reason, extra)` when provided. If no reject callback exists or it returns `undefined`, `resolve` fabricates the default discard message. -13. Before returning callback results, it merges resolve metadata into `result.details` so renderer/UI code can show the action, label, and originating tool. +5. When `resolve.execute()` runs, it wraps the call in `untilAborted(...)` and dispatches via `session.peekQueueInvoker?.() ?? session.peekPendingInvoker?.() ?? session.peekStandingResolveHandler?.()`. +6. If no invoker exists, `apply` throws `ToolError("No pending action to resolve. Nothing to apply or discard.")`; `discard` instead returns a success payload `Nothing to discard; no pending action remains.` because the desired end-state (no staged change) already holds. +7. Otherwise it invokes the current handler with the full params object. +8. `runResolveInvocation(...)` builds base details from `action`, `reason`, `extra`, `sourceToolName`, and `label`. +9. For `apply`, it calls the producer's `apply(reason, extra)` callback. +10. If `apply` throws, `runResolveInvocation(...)` calls `onApplyError` when present. The pending-preview integration uses this to re-register the same pending invoker (same id) so the action remains pending for discard or retry. Non-`ToolError` exceptions are wrapped as `ToolError("Apply failed: ")`. +11. For `discard`, it calls `reject(reason, extra)` when provided. If no reject callback exists or it returns `undefined`, `resolve` fabricates the default discard message. +12. Before returning callback results, it merges resolve metadata into `result.details` so renderer/UI code can show the action, label, and originating tool. ## Modes / Variants - `apply`: runs the pending action's `apply(reason, extra?)` callback and returns its content. - `discard` with reject callback: runs `reject(reason, extra?)` and returns that callback's content when non-`undefined`. - `discard` without reject callback, or with a reject callback returning `undefined`: returns the built-in `Discarded: ...` text payload. - `discard` with no pending action at all: returns `Nothing to discard; no pending action remains.` as a success result. -- Queued handler: one in-flight tool-choice queue invoker, used by preview producers such as `ast_edit`. +- Pending invoker: a non-forcing preview invoker in the pending-invoker registry (separate from the consuming directive queue), used by preview producers such as `ast_edit`. - Standing handler: long-lived mode-owned handler, used as a fallback when no queue invoker is active. ## Side Effects - Session state - - Consumes or invokes the current pending action through the session tool-choice queue or standing handler; `resolve` does not maintain its own stack. - - Adds a `resolve-reminder` steering message when a queued preview is registered. + - Consumes or invokes the current pending action through the pending invoker, tool-choice queue, or standing handler; `resolve` does not maintain its own stack. + - Does not steer a reminder or force a tool choice for previews — the reminder rides a non-forcing `SoftToolRequirement` and the agent runtime forces `resolve` only on non-compliance. - On queued apply failure, requeues the same pending action before rethrowing so the model can discard or retry instead of losing the pending preview. - User-visible prompts / interactive UI - The visible effect depends on the preview-producing tool and the resolve renderer. @@ -74,8 +73,8 @@ This is a preview. Call the `resolve` tool to apply or discard these changes. ## Limits & Caps - Hidden tool: `ResolveTool.hidden = true`, and normal requested-tool filtering removes `resolve`; `createTools(...)` adds it separately as a hidden tool. -- Exactly one active queue invoker is consulted per call via `session.peekQueueInvoker()`; if none exists, one standing handler may be consulted via `session.peekStandingResolveHandler()`. -- There is no independent queue depth cap in this tool; ordering follows the shared tool-choice queue and mode-owned standing handler lifecycle. +- Per call, `resolve` consults the in-flight hard-directive queue invoker (`session.peekQueueInvoker()`), then the non-forcing pending-preview invoker (`session.peekPendingInvoker()`), then a standing handler (`session.peekStandingResolveHandler()`). +- There is no independent depth cap in this tool; pending previews stack as unique-keyed invokers (resolved head-first), separate from the consuming directive queue and the mode-owned standing handler lifecycle. ## Errors - `apply` with no pending action or standing handler: throws `ToolError("No pending action to resolve. Nothing to apply or discard.")`. `discard` in the same situation succeeds with `Nothing to discard; no pending action remains.` instead of erroring. diff --git a/docs/tui-core-renderer.md b/docs/tui-core-renderer.md index 9290a9796..10fe737ce 100644 --- a/docs/tui-core-renderer.md +++ b/docs/tui-core-renderer.md @@ -262,7 +262,7 @@ width models in measure-vs-slice produced crashes. - Fast path: printable ASCII is one cell per code unit. - Anything past the ASCII prefix measures through `Bun.stringWidth` (CSI/OSC - stripped to zero); tabs are added back at `getDefaultTabWidth()` columns. + stripped to zero); tabs are added back at the fixed `DEFAULT_TAB_WIDTH` columns. - OSC 66 sized spans are added back as `scale × (explicit w ?? payload width)` — `Bun.stringWidth` would otherwise strip the whole span to zero. diff --git a/package.json b/package.json index a61eab707..2f479a478 100644 --- a/package.json +++ b/package.json @@ -24,18 +24,18 @@ "@huggingface/transformers": "^4.2.0", "@mozilla/readability": "^0.6.0", "@napi-rs/cli": "3.7.0", - "@oh-my-pi/hashline": "16.0.9", - "@oh-my-pi/omp-stats": "16.0.9", - "@oh-my-pi/pi-agent-core": "16.0.9", - "@oh-my-pi/pi-ai": "16.0.9", - "@oh-my-pi/pi-catalog": "16.0.9", - "@oh-my-pi/pi-coding-agent": "16.0.9", - "@oh-my-pi/pi-mnemopi": "16.0.9", - "@oh-my-pi/pi-natives": "16.0.9", - "@oh-my-pi/pi-tui": "16.0.9", - "@oh-my-pi/pi-utils": "16.0.9", - "@oh-my-pi/pi-wire": "16.0.9", - "@oh-my-pi/snapcompact": "16.0.9", + "@oh-my-pi/hashline": "16.1.1", + "@oh-my-pi/omp-stats": "16.1.1", + "@oh-my-pi/pi-agent-core": "16.1.1", + "@oh-my-pi/pi-ai": "16.1.1", + "@oh-my-pi/pi-catalog": "16.1.1", + "@oh-my-pi/pi-coding-agent": "16.1.1", + "@oh-my-pi/pi-mnemopi": "16.1.1", + "@oh-my-pi/pi-natives": "16.1.1", + "@oh-my-pi/pi-tui": "16.1.1", + "@oh-my-pi/pi-utils": "16.1.1", + "@oh-my-pi/pi-wire": "16.1.1", + "@oh-my-pi/snapcompact": "16.1.1", "@opentelemetry/api": "^1.9.1", "@opentelemetry/context-async-hooks": "^2.7.1", "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 136a38b7b..7cabda096 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,23 @@ ## [Unreleased] +## [16.1.0] - 2026-06-19 + +### Added + +- Added `SoftToolRequirement` support to `getToolChoice`: a host can require a tool by returning a soft requirement instead of a hard `ToolChoice`. The loop injects the supplied reminder once (leaving `tool_choice` on auto), and escalates to a one-turn forced choice — skipping any detour tool batch — only if the model fails to call the required tool, avoiding the provider message-cache invalidation of forcing every turn. +- Added `pruneToolDescriptions` option to reduce token usage by stripping tool descriptions from provider-bound specs + +### Fixed + +- Improved token estimation accuracy for compaction summaries containing multi-block content + +## [16.0.11] - 2026-06-19 + +### Changed + +- Updated the display format for truncated file operation summaries + ## [16.0.8] - 2026-06-18 ### Fixed diff --git a/packages/agent/package.json b/packages/agent/package.json index 053189c71..2264b5b99 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-agent-core", - "version": "16.0.9", + "version": "16.1.1", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "homepage": "https://omp.sh", "author": "Can Boluk", @@ -39,6 +39,7 @@ "@oh-my-pi/pi-catalog": "catalog:", "@oh-my-pi/pi-natives": "catalog:", "@oh-my-pi/pi-utils": "catalog:", + "@oh-my-pi/pi-wire": "catalog:", "@oh-my-pi/snapcompact": "catalog:", "@opentelemetry/api": "catalog:" }, diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 9c962de0a..c9325ea45 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -14,8 +14,11 @@ import { resolveApiKeyOnce, seedApiKeyResolver, streamSimple, + stripSchemaDescriptions, + type ToolChoice, type ToolResultMessage, type TSchema, + toolWireSchema, validateToolArguments, zodToWireSchema, } from "@oh-my-pi/pi-ai"; @@ -38,6 +41,7 @@ import { } from "@oh-my-pi/pi-ai/utils/harmony-leak"; import { preferredDialect } from "@oh-my-pi/pi-catalog/identity"; import { sanitizeText } from "@oh-my-pi/pi-utils"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector"; import { type AgentTelemetry, @@ -65,6 +69,7 @@ import type { AsideMessage, StreamFn, } from "./types"; +import { isSoftToolRequirement } from "./types"; import { yieldIfDue } from "./utils/yield"; /** Stop-details marker for a provider error after assistant content/tool args already streamed. */ @@ -81,6 +86,27 @@ const ABORTED: unique symbol = Symbol("agent-loop-aborted"); */ const MAX_PAUSED_TURN_CONTINUATIONS = 8; +/** + * Cap on consecutive forced escalations for a single soft tool requirement. + * A forced `toolChoice` guarantees the call, so this is purely defensive: if a + * model somehow never satisfies the requirement, give up forcing rather than + * spin the loop. Reset whenever the requirement id changes or clears. + */ +const MAX_SOFT_TOOL_ESCALATIONS = 3; + +/** + * Whether a hard `toolChoice` for a turn conflicts with a pending soft tool + * requirement — i.e. forbids tools (`"none"`) or forces a *different* specific + * tool. `"auto"`/`"required"`/`"any"` and a same-tool force still let the model + * satisfy the requirement, so they do not conflict and the soft gate stays active. + */ +function hardToolChoiceBlocks(choice: ToolChoice | undefined, requiredTool: string): boolean { + if (choice === undefined) return false; + if (typeof choice === "string") return choice === "none"; + const name = choice.type === "tool" ? choice.name : "function" in choice ? choice.function.name : choice.name; + return name !== requiredTool; +} + /** * Cadence (ms) for polling queued steering while an `interruptible` tool is in * flight, so a steer cuts the wait short instead of sitting idle until the @@ -517,9 +543,13 @@ function normalizeMessagesForProvider( }); } -export const INTENT_FIELD = "_i"; +const INTENT_FIELD_DESCRIPTION = "concise intent"; -function injectIntentIntoSchema(schema: unknown, mode: "require" | "optional" = "require"): unknown { +function injectIntentIntoSchema( + schema: unknown, + mode: "require" | "optional" = "require", + describeIntent = true, +): unknown { if (!schema || typeof schema !== "object" || Array.isArray(schema)) return schema; const schemaRecord = schema as Record; const propertiesValue = schemaRecord.properties; @@ -545,10 +575,9 @@ function injectIntentIntoSchema(schema: unknown, mode: "require" | "optional" = return { ...schemaRecord, properties: { - [INTENT_FIELD]: { - type: "string", - description: "Concise intent in present participle form (2-6 words) strictly on a single line, no newlines", - }, + [INTENT_FIELD]: describeIntent + ? { type: "string", description: INTENT_FIELD_DESCRIPTION } + : { type: "string" }, ...properties, }, ...(mode === "require" ? { required: [...required, INTENT_FIELD] } : {}), @@ -559,26 +588,36 @@ export function normalizeTools( tools: AgentContext["tools"], injectIntent: boolean, exampleDialect?: Dialect, + pruneDescriptions = false, ): Context["tools"] { injectIntent = injectIntent && Bun.env.PI_NO_INTENT !== "1"; return tools?.map(t => { const intentMode = resolveIntentMode(t.intent); + const doInjectIntent = injectIntent && intentMode !== "omit"; + // When the full catalog is rendered into the system prompt, ship the tool + // specs without their descriptions (top-level + nested schema annotations) + // so they are not duplicated on the wire. Strip the STABLE wire schema (the + // memoized `stripSchemaDescriptions` result is reused across requests), then + // re-inject `_i` (without its hint, which `describeIntent: false` omits) so + // intent tracing keeps the field while no descriptions ride the wire. + if (pruneDescriptions) { + let parameters = stripSchemaDescriptions(toolWireSchema(t)) as TSchema; + if (doInjectIntent) parameters = injectIntentIntoSchema(parameters, intentMode, false) as TSchema; + return { ...t, parameters, description: "" }; + } let parameters: TSchema = t.parameters; - if (injectIntent && intentMode !== "omit") { + if (doInjectIntent) { if (isZodSchema(parameters)) { - const wired = zodToWireSchema(parameters); - parameters = injectIntentIntoSchema(wired, intentMode) as TSchema; + parameters = injectIntentIntoSchema(zodToWireSchema(parameters), intentMode) as TSchema; } else if (isArkSchema(parameters)) { - const wired = arkToWireSchema(parameters); - parameters = injectIntentIntoSchema(wired, intentMode) as TSchema; + parameters = injectIntentIntoSchema(arkToWireSchema(parameters), intentMode) as TSchema; } else { parameters = injectIntentIntoSchema(parameters, intentMode) as TSchema; } } const description = t.description ?? ""; - const injectExampleIntent = injectIntent && intentMode !== "omit"; const examplesBlock = exampleDialect - ? renderToolExamples({ ...t, parameters }, exampleDialect, injectExampleIntent ? INTENT_FIELD : undefined) + ? renderToolExamples({ ...t, parameters }, exampleDialect, doInjectIntent ? INTENT_FIELD : undefined) : ""; const finalDescription = examplesBlock ? `${description}\n\n${examplesBlock}` : description; return { ...t, parameters, description: finalDescription }; @@ -714,6 +753,19 @@ async function runLoopBody( let harmonyTruncateResumeCount = 0; let pausedTurnContinuations = 0; + // Soft tool requirement lifecycle (reminder → escalate; see SoftToolRequirement). + // `forcedToolChoice` carries a one-turn escalation into the next model call. It + // overrides the static toolChoice but NEVER the host's hard getToolChoice(). + let softRequirementId: string | undefined; + let forcedToolChoice: ToolChoice | undefined; + let softEscalations = 0; + // Resolved once per logical turn at the fetch site below and reused across + // Harmony-leak re-samples (which re-enter the same turn) so the consuming + // getToolChoice is never advanced twice; the flag resets at the message boundary. + let hostToolChoice: ToolChoice | undefined; + let softRequiredTool: string | undefined; + let directiveResolvedForTurn = false; + // Outer loop: continues when queued follow-up messages arrive after agent would stop while (true) { let hasMoreToolCalls = true; @@ -749,6 +801,41 @@ async function runLoopBody( await config.syncContextBeforeModelCall(currentContext); } + // Resolve the per-turn tool-choice directive ONCE per logical turn. The + // host hard-choice path (getToolChoice → nextToolChoice) is CONSUMING — it + // advances a generator on every call — so Harmony-leak retries, which + // re-sample the same turn via `continue` without a turn_end, must reuse the + // values fetched on the first attempt rather than double-advancing it. + // Fetched here (after pending-message flush + context sync, immediately + // before the call) so a throw in between cannot wedge an in-flight + // directive. A hard ToolChoice is applied verbatim; a SoftToolRequirement + // triggers the remind-then-escalate lifecycle: inject its reminder inline + // once per new id (toolChoice stays auto), and the gate below escalates to + // a forced choice only if the model declines. The host wrapper already + // dropped a soft requirement whose tool is inactive. + if (!directiveResolvedForTurn) { + const directive = signal?.aborted ? undefined : config.getToolChoice?.(); + const softReq = isSoftToolRequirement(directive) ? directive : undefined; + hostToolChoice = directive === undefined || isSoftToolRequirement(directive) ? undefined : directive; + softRequiredTool = softReq?.toolName; + if (softReq !== undefined) { + if (softReq.id !== softRequirementId) { + softRequirementId = softReq.id; + softEscalations = 0; + for (const reminder of softReq.reminder) { + stream.push({ type: "message_start", message: reminder }); + stream.push({ type: "message_end", message: reminder }); + currentContext.messages.push(reminder); + newMessages.push(reminder); + } + } + } else { + softRequirementId = undefined; + softEscalations = 0; + } + directiveResolvedForTurn = true; + } + // Stream assistant response let recovered: HarmonyRecoveredToolCall | undefined; let message: AssistantMessage; @@ -763,6 +850,8 @@ async function runLoopBody( stepCounter, streamFn, harmonyRetryAttempt, + hostToolChoice, + forcedToolChoice, ); harmonyRetryAttempt = 0; harmonyTruncateResumeCount = 0; @@ -779,6 +868,10 @@ async function runLoopBody( recovered = err.recovered; message = recovered.message; await emitHarmonyAudit(config, err, "truncate_resume", harmonyRetryAttempt); + // A recovered message completes the turn, so the abort-retry counter + // resets like the normal success path (the truncate-resume counter + // keeps accumulating for its cross-turn cap). + harmonyRetryAttempt = 0; } else { if (harmonyRetryAttempt >= 2) { await emitHarmonyAudit(config, err, "escalated", harmonyRetryAttempt); @@ -799,6 +892,14 @@ async function runLoopBody( } newMessages.push(message); + // The escalation choice (if any) applied to the call above; clear it so + // only the single escalation turn carries the forced choice. + forcedToolChoice = undefined; + + // A fresh logical turn re-resolves the directive next iteration; a Harmony + // retry `continue`s before this line and keeps the cached value. + directiveResolvedForTurn = false; + if (message.stopReason === "error" || message.stopReason === "aborted") { // Create placeholder tool results for any tool calls in the aborted message // This maintains the tool_use/tool_result pairing that the API requires @@ -851,8 +952,51 @@ async function runLoopBody( hasMoreToolCalls = false; } + // A turn is compliant ONLY when it calls the required tool and nothing + // else — mirroring the forced-tool_choice turn, which can emit only that + // tool. A required+detour batch is treated as non-compliant so detour + // tools never run side effects while the requirement is still pending. + const calledOnlyRequiredTool = + softRequiredTool !== undefined && + toolCalls.length > 0 && + toolCalls.every(toolCall => toolCall.name === softRequiredTool); + const softGateActive = + softRequiredTool !== undefined && !hardToolChoiceBlocks(config.toolChoice, softRequiredTool); + const softNonCompliant = softGateActive && !calledOnlyRequiredTool; + const toolResults: ToolResultMessage[] = []; - if (hasMoreToolCalls) { + if (softNonCompliant && softRequiredTool !== undefined) { + if (softEscalations >= MAX_SOFT_TOOL_ESCALATIONS) { + throw new Error( + `Soft tool requirement '${softRequiredTool}' was not satisfied after ${MAX_SOFT_TOOL_ESCALATIONS} forced turns; aborting to avoid an unbounded force loop.`, + ); + } + // A soft-required tool is pending but the model called something else + // (or yielded). Do NOT execute the detour — pair each call with a + // skipped result and force the required tool next turn. This is the + // only turn that changes toolChoice; a model that complies with the + // reminder pays no message-cache invalidation. Re-engage so the loop + // never yields while the requirement is unmet. + for (const toolCall of toolCalls) { + const result = createAbortedToolResult( + toolCall, + stream, + "skipped", + `Not executed: call the \`${softRequiredTool}\` tool to resolve the pending action before using other tools.`, + ); + currentContext.messages.push(result); + newMessages.push(result); + toolResults.push(result); + recordSkippedTool(telemetry, { + toolCallId: toolCall.id, + toolName: toolCall.name, + status: "skipped", + }); + } + forcedToolChoice = { type: "tool", name: softRequiredTool }; + softEscalations++; + hasMoreToolCalls = true; + } else if (hasMoreToolCalls) { const executionResult = await executeToolCalls( currentContext, message, @@ -1000,6 +1144,8 @@ async function streamAssistantResponse( stepCounter: StepCounter, streamFn?: StreamFn, harmonyRetryAttempt = 0, + hostToolChoice?: ToolChoice, + forcedToolChoice?: ToolChoice, ): Promise { // Apply context transform if configured (AgentMessage[] → AgentMessage[]) let messages = context.messages; @@ -1013,6 +1159,9 @@ async function streamAssistantResponse( const ownedDialect: Dialect | undefined = config.dialect ?? resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT); const exampleDialect = ownedDialect ?? preferredDialect(config.model.id); + // Owned/in-band dialects carry the catalog in the prompt as text and send no + // native `tools`, so description pruning only applies to native tool calling. + const pruneToolDescriptions = !!config.pruneToolDescriptions && !ownedDialect; // Build LLM context — append-only mode caches system prompt + tools // AND keeps an append-only message log so prior-turn bytes are stable. let llmContext: Context; @@ -1021,12 +1170,13 @@ async function streamAssistantResponse( llmContext = config.appendOnlyContext.build(context, { intentTracing: !!config.intentTracing, exampleDialect, + pruneToolDescriptions, }); } else { llmContext = { systemPrompt: context.systemPrompt, messages: normalizedMessages, - tools: normalizeTools(context.tools, !!config.intentTracing, exampleDialect), + tools: normalizeTools(context.tools, !!config.intentTracing, exampleDialect, pruneToolDescriptions), }; } if (config.transformProviderContext) { @@ -1049,7 +1199,6 @@ async function streamAssistantResponse( const streamFunction = streamFn || streamSimple; - const dynamicToolChoice = config.getToolChoice?.(); const dynamicReasoning = config.getReasoning?.(); const dynamicDisableReasoning = config.getDisableReasoning?.(); const harmonyMitigationEnabled = isHarmonyLeakMitigationTarget(config.model); @@ -1084,7 +1233,7 @@ async function streamAssistantResponse( const effectiveTemperature = harmonyRetryAttempt > 0 && config.temperature !== undefined ? config.temperature + 0.05 : config.temperature; // Owned tool calling sends no native tools, so any tool_choice would error. - const effectiveToolChoice = ownedDialect ? undefined : (dynamicToolChoice ?? config.toolChoice); + const effectiveToolChoice = ownedDialect ? undefined : (hostToolChoice ?? forcedToolChoice ?? config.toolChoice); const effectiveReasoning = dynamicReasoning ?? config.reasoning; const effectiveDisableReasoning = dynamicDisableReasoning ?? config.disableReasoning; diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 66a51c4e9..22e872ff0 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -39,7 +39,9 @@ import type { AsideMessage, StreamFn, ToolCallContext, + ToolChoiceDirective, } from "./types"; +import { isSoftToolRequirement } from "./types"; import { EventLoopKeepalive } from "./utils/yield"; /** @@ -223,6 +225,12 @@ export interface AgentOptions { /** Enable intent tracing schema injection/stripping in the harness. */ intentTracing?: boolean; + /** + * Strip tool descriptions from provider-bound tool specs (top-level + nested + * schema annotations). Use when the full catalog is rendered into the system + * prompt so descriptions are not duplicated on the wire. Native tool calling only. + */ + pruneToolDescriptions?: boolean; /** Owned tool-calling dialect. Undefined keeps provider-native tool calling. */ dialect?: Dialect; /** @@ -232,8 +240,8 @@ export interface AgentOptions { * the loop's {@link AgentLoopConfig.abortOnFabricatedToolResult}. */ abortOnFabricatedToolResult?: boolean; - /** Dynamic tool choice override, resolved per LLM call. */ - getToolChoice?: () => ToolChoice | undefined; + /** Dynamic tool-choice directive (hard {@link ToolChoice} or {@link SoftToolRequirement}), resolved once per turn. */ + getToolChoice?: () => ToolChoiceDirective | undefined; /** * Cursor exec handlers for local tool execution. @@ -336,9 +344,10 @@ export class Agent { #preferWebsockets?: boolean; #transformToolCallArguments?: (args: Record, toolName: string) => Record; #intentTracing: boolean; + #pruneToolDescriptions: boolean; #dialect?: Dialect; #abortOnFabricatedToolResult?: boolean; - #getToolChoice?: () => ToolChoice | undefined; + #getToolChoice?: () => ToolChoiceDirective | undefined; #onPayload?: SimpleStreamOptions["onPayload"]; #onResponse?: SimpleStreamOptions["onResponse"]; #onSseEvent?: SimpleStreamOptions["onSseEvent"]; @@ -407,6 +416,7 @@ export class Agent { this.#preferWebsockets = opts.preferWebsockets; this.#transformToolCallArguments = opts.transformToolCallArguments; this.#intentTracing = opts.intentTracing === true; + this.#pruneToolDescriptions = opts.pruneToolDescriptions === true; this.#dialect = opts.dialect; this.#abortOnFabricatedToolResult = opts.abortOnFabricatedToolResult; this.#getToolChoice = opts.getToolChoice; @@ -1009,10 +1019,13 @@ export class Agent { } : undefined; - const getToolChoice = () => { - const queuedToolChoice = this.#getToolChoice?.(); - if (queuedToolChoice !== undefined) { - return refreshToolChoiceForActiveTools(queuedToolChoice, this.#state.tools); + const getToolChoice = (): ToolChoiceDirective | undefined => { + const queued = this.#getToolChoice?.(); + if (queued !== undefined) { + if (isSoftToolRequirement(queued)) { + return (this.#state.tools ?? []).some(tool => tool.name === queued.toolName) ? queued : undefined; + } + return refreshToolChoiceForActiveTools(queued, this.#state.tools); } return refreshToolChoiceForActiveTools(options?.toolChoice, this.#state.tools); }; @@ -1059,6 +1072,7 @@ export class Agent { cursorOnToolResult, transformToolCallArguments: this.#transformToolCallArguments, intentTracing: this.#intentTracing, + pruneToolDescriptions: this.#pruneToolDescriptions, dialect: this.#dialect, abortOnFabricatedToolResult: this.#abortOnFabricatedToolResult, appendOnlyContext: this.#appendOnlyContext, diff --git a/packages/agent/src/append-only-context.ts b/packages/agent/src/append-only-context.ts index 50e45b1d2..b074cb8c8 100644 --- a/packages/agent/src/append-only-context.ts +++ b/packages/agent/src/append-only-context.ts @@ -35,6 +35,8 @@ export interface BuildOptions { /** Inject the `_i` intent field into tool schemas (must match agent-loop's normalizeTools). */ intentTracing: boolean; exampleDialect?: Dialect; + /** Strip tool descriptions from the provider-bound specs (must match normalizeTools). */ + pruneToolDescriptions?: boolean; } /** @@ -270,7 +272,8 @@ export class AppendOnlyContextManager { function takeSnapshot(context: AgentContext, options: BuildOptions): StablePrefixSnapshot { const systemPrompt = [...context.systemPrompt]; - const tools = normalizeTools(context.tools, options.intentTracing, options.exampleDialect) ?? []; + const tools = + normalizeTools(context.tools, options.intentTracing, options.exampleDialect, options.pruneToolDescriptions) ?? []; return { systemPrompt, tools, @@ -291,6 +294,7 @@ function computeFingerprint(systemPrompt: string[], tools: Tool[], options: Buil })), i: options.intentTracing, ex: options.exampleDialect, + pd: options.pruneToolDescriptions, }); let hash = 0; for (let i = 0; i < payload.length; i++) { diff --git a/packages/agent/src/compaction/compaction.ts b/packages/agent/src/compaction/compaction.ts index 3be7f751b..99366e0f6 100644 --- a/packages/agent/src/compaction/compaction.ts +++ b/packages/agent/src/compaction/compaction.ts @@ -326,9 +326,16 @@ export function estimateTokens(message: AgentMessage): number { case "branchSummary": case "compactionSummary": { fragments.push(message.summary); - if (message.role === "compactionSummary" && message.images) { - // Snapcompact frames render at ≥1568px; providers bill the downscaled cap. - extra += message.images.length * snapcompact.FRAME_TOKEN_ESTIMATE; + if (message.role === "compactionSummary") { + if (message.blocks) { + for (const block of message.blocks) { + if (block.type === "text") fragments.push(block.text); + else extra += snapcompact.FRAME_TOKEN_ESTIMATE; + } + } else if (message.images) { + // Snapcompact frames render at ≥1568px; providers bill the downscaled cap. + extra += message.images.length * snapcompact.FRAME_TOKEN_ESTIMATE; + } } break; } diff --git a/packages/agent/src/compaction/messages.ts b/packages/agent/src/compaction/messages.ts index 149996e79..a23887ce9 100644 --- a/packages/agent/src/compaction/messages.ts +++ b/packages/agent/src/compaction/messages.ts @@ -51,7 +51,11 @@ export interface CompactionSummaryMessage { shortSummary?: string; tokensBefore: number; providerPayload?: ProviderPayload; - /** Snapcompact frames archived by this compaction; appended as image blocks after the summary text. */ + /** Runtime-only ordered archive blocks for snapcompact: old text region, + * imaged middle, then new text region. When present, `summary` is already + * the final lead-in text (no legacy wrapper applied). */ + blocks?: (TextContent | ImageContent)[]; + /** Snapcompact image blocks, kept for display counts / legacy consumers. */ images?: ImageContent[]; timestamp: number; } @@ -101,14 +105,19 @@ export function createCompactionSummaryMessage( shortSummary?: string, providerPayload?: ProviderPayload, images?: ImageContent[], + blocks?: (TextContent | ImageContent)[], ): CompactionSummaryMessage { + const imageBlocks = + blocks?.filter((block): block is ImageContent => block.type === "image") ?? + (images && images.length > 0 ? images : undefined); return { role: "compactionSummary", summary, shortSummary, tokensBefore, providerPayload, - images: images && images.length > 0 ? images : undefined, + blocks: blocks && blocks.length > 0 ? blocks : undefined, + images: imageBlocks && imageBlocks.length > 0 ? imageBlocks : undefined, timestamp: new Date(timestamp).getTime(), }; } @@ -182,13 +191,16 @@ export function convertMessageToLlm(message: AgentMessage): Message | undefined case "compactionSummary": return { role: "user", - content: [ - { - type: "text" as const, - text: renderCompactionSummaryContext(message.summary), - }, - ...(message.images ?? []), - ], + content: + message.blocks !== undefined + ? [{ type: "text" as const, text: message.summary }, ...message.blocks] + : [ + { + type: "text" as const, + text: renderCompactionSummaryContext(message.summary), + }, + ...(message.images ?? []), + ], attribution: "agent", providerPayload: message.providerPayload, timestamp: message.timestamp, diff --git a/packages/agent/src/compaction/pruning.ts b/packages/agent/src/compaction/pruning.ts index 58c3dccca..1ba9a7ac0 100644 --- a/packages/agent/src/compaction/pruning.ts +++ b/packages/agent/src/compaction/pruning.ts @@ -30,6 +30,24 @@ export interface PruneConfig { supersedeKey?: SupersedeKeyFn; /** Useless-flagged results bypass the protect window (see {@link USELESS_NOTICE}). Default true. */ pruneUseless?: boolean; + /** + * Compaction boundary: the `firstKeptEntryId` of the latest compaction on + * the branch. Entries at indices BEFORE this id are summarized away and never + * sent to the model, so mutating them only churns persisted history without + * shrinking the prompt — they are skipped. Undefined = no compaction (the + * whole branch is sent). + */ + keepBoundaryId?: string; + /** + * Prompt-cache guard. When set, a tool result whose all-message suffix + * (tokens of every message after it) EXCEEDS this is part of the warm, + * already-sent cache prefix: mutating it forces the provider to re-write the + * whole suffix (cacheWrite premium). Such results — including superseded and + * useless ones, which otherwise bypass {@link protectTokens} — are left for + * compaction/shake (which rebuild the cache anyway) to reclaim. Undefined = + * no cache guard (legacy: superseded/useless prune at any depth). + */ + cacheWarmSuffixTokens?: number; } export const DEFAULT_PRUNE_CONFIG: PruneConfig = { @@ -66,10 +84,22 @@ export interface SupersedePruneConfig { pruneUseless?: boolean; /** Prune a candidate now when all messages after it total at most this many estimated tokens. Default 8 000. */ suffixTokenLimit?: number; - /** Prune all candidates when the last message is at least this old (prompt cache is cold anyway). Default 30 min. */ + /** + * Prune all candidates when the last message is at least this old: the + * provider prompt cache is then cold, so re-writing it is free. MUST exceed + * the cache retention (Anthropic "long" = 1h) or a still-warm prefix is busted + * by the flush. Default 30 min — callers on long retention override it. + */ idleFlushMs?: number; /** Clock override for tests. */ now?: number; + /** + * Compaction boundary (`firstKeptEntryId` of the latest compaction). Entries + * before it are summarized away and never sent, so they are skipped in every + * path — including the idle flush — to avoid pointless history churn. + * Undefined = no compaction (the whole branch is sent). + */ + keepBoundaryId?: string; /** Tool-result protection matchers (same contract as {@link PruneConfig.protectedTools}). */ protectedTools: ProtectedToolMatcher[]; } @@ -103,6 +133,35 @@ function estimatePrunedSavings(tokens: number, notice: string): number { return Math.max(0, tokens - noticeTokens); } +/** + * For each entry index, the estimated token total of all *message* entries + * strictly after it — how much prompt-cache content the provider must re-write + * (cacheWrite premium) if that entry is mutated in place. Used to keep prune + * mutations inside the cheap-to-recache tail. + */ +function computeMessageSuffixTokens(entries: readonly SessionEntry[]): number[] { + const suffix = new Array(entries.length); + let accumulated = 0; + for (let i = entries.length - 1; i >= 0; i--) { + suffix[i] = accumulated; + const entry = entries[i]; + if (entry.type === "message") accumulated += estimateTokens(entry.message as AgentMessage); + } + return suffix; +} + +/** + * Resolve the array index of the compaction boundary (`keepBoundaryId`). Entries + * before this index are summarized away by the latest compaction and never sent, + * so prune passes must not mutate them. Returns 0 when there is no boundary (no + * compaction → whole branch is sent) or the id is absent from `entries`. + */ +function resolveBoundaryIndex(entries: readonly SessionEntry[], keepBoundaryId: string | undefined): number { + if (keepBoundaryId === undefined) return 0; + const index = entries.findIndex(entry => entry.id === keepBoundaryId); + return index < 0 ? 0 : index; +} + interface SupersedeCandidate { entry: SessionMessageEntry; message: ToolResultMessage; @@ -183,7 +242,8 @@ function collectUselessResults( * flagged contextually useless. Cheap, incremental, and prompt-cache-aware: a * candidate is pruned now only when the suffix after it is small (tail case — * the read→edit→read loop) or when the context has been idle long enough that - * the provider cache is cold anyway (then ALL candidates flush). + * the provider cache is cold anyway (then all still-sent candidates flush). + * Never mutates entries before `keepBoundaryId` (summarized away — not sent). */ export function pruneSupersededToolResults(entries: SessionEntry[], config: SupersedePruneConfig): PruneResult { const toolCallsById = collectToolCallsById(entries); @@ -209,20 +269,24 @@ export function pruneSupersededToolResults(entries: SessionEntry[], config: Supe const idle = lastMessageTimestamp !== undefined && now - lastMessageTimestamp >= (config.idleFlushMs ?? DEFAULT_IDLE_FLUSH_MS); + const boundaryIndex = resolveBoundaryIndex(entries, config.keepBoundaryId); + let toPrune: SupersedeCandidate[]; if (idle) { - toPrune = candidates; + // Provider cache is cold (idle exceeds the retention TTL), so re-writing + // the sent region costs nothing. Entries before the compaction boundary + // are summarized away and never sent — skip them to avoid pointless churn. + toPrune = candidates.filter(candidate => candidate.index >= boundaryIndex); } else { const suffixTokenLimit = config.suffixTokenLimit ?? DEFAULT_SUFFIX_TOKEN_LIMIT; // suffixTokens[i] = estimated tokens of all messages strictly after entry i. - const suffixTokens = new Array(entries.length); - let accumulated = 0; - for (let i = entries.length - 1; i >= 0; i--) { - suffixTokens[i] = accumulated; - const entry = entries[i]; - if (entry.type === "message") accumulated += estimateTokens(entry.message as AgentMessage); - } - toPrune = candidates.filter(candidate => suffixTokens[candidate.index] <= suffixTokenLimit); + // Mutating a candidate re-writes its suffix in the warm cache, so prune only + // when that suffix is small (cheap-to-recache tail) and the candidate sits + // at/after the compaction boundary. + const suffixTokens = computeMessageSuffixTokens(entries); + toPrune = candidates.filter( + candidate => candidate.index >= boundaryIndex && suffixTokens[candidate.index] <= suffixTokenLimit, + ); } if (toPrune.length === 0) return { prunedCount: 0, tokensSaved: 0 }; @@ -262,6 +326,11 @@ export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig = ) : undefined; + const boundaryIndex = resolveBoundaryIndex(entries, config.keepBoundaryId); + const cacheWarmSuffixTokens = config.cacheWarmSuffixTokens; + // All-message suffix per index, only when the cache guard is armed. + const messageSuffix = cacheWarmSuffixTokens === undefined ? undefined : computeMessageSuffixTokens(entries); + for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; const message = getToolResultMessage(entry); @@ -275,10 +344,23 @@ export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig = continue; } - // Superseded and useless results are pruned first: they bypass the - // protect window (a stale copy of re-read content — or a result the - // tool itself flagged as carrying no information — is dead weight at - // any age). + // Prompt-cache guard: a result whose all-message suffix exceeds the + // warm-cache window sits in the already-sent cached prefix — mutating it + // re-writes the whole suffix (cacheWrite premium). Entries before the + // compaction boundary are summarized away (never sent). Both are skipped + // before any prune decision, so superseded/useless cannot reach a deep, + // still-cached copy; compaction/shake reclaim those when they rebuild. + const inWarmPrefix = + messageSuffix !== undefined && cacheWarmSuffixTokens !== undefined && messageSuffix[i] > cacheWarmSuffixTokens; + if (inWarmPrefix || i < boundaryIndex) { + accumulatedTokens += tokens; + continue; + } + + // Superseded and useless results bypass the age-based protect window + // (a stale re-read copy, or a result the tool flagged as uninformative, + // is dead weight at any age) — but only within the cache-warm tail: the + // guard above already excluded deeper, still-cached copies. const superseded = supersededMessages?.has(message) ?? false; const useless = uselessMessages?.has(message) ?? false; const tooSmall = tokens < MIN_PRUNE_TOKENS; diff --git a/packages/agent/src/compaction/shake.ts b/packages/agent/src/compaction/shake.ts index f4535318d..7e0f2c5ed 100644 --- a/packages/agent/src/compaction/shake.ts +++ b/packages/agent/src/compaction/shake.ts @@ -31,6 +31,14 @@ export interface ShakeConfig { protectedTools: ProtectedToolMatcher[]; /** Minimum token size for a fenced/XML block to be eligible. */ fenceMinTokens: number; + /** + * Compaction boundary (`firstKeptEntryId` of the latest compaction). Entries + * before it are summarized away and never sent, so they are skipped — shaking + * them only churns persisted history. Undefined = no compaction (whole branch + * is sent). Note: shake still elides the warm cached prefix at/after the + * boundary — that is its job as a compaction-class reducer. + */ + keepBoundaryId?: string; } /** Auto-shake config: protects the live tail, conservative thresholds. */ @@ -289,9 +297,20 @@ export function collectShakeRegions(entries: SessionEntry[], config: ShakeConfig const toolCallsById = collectToolCallsById(entries); + // Entries before the compaction boundary are summarized away and never sent — + // shaking them only churns persisted history (no prompt/cache effect). + const boundaryIndex = + config.keepBoundaryId === undefined + ? 0 + : Math.max( + 0, + entries.findIndex(entry => entry.id === config.keepBoundaryId), + ); + const regions: ShakeRegion[] = []; for (let i = 0; i < n; i++) { const entry = entries[i]; + if (i < boundaryIndex) continue; const toolResult = getToolResultMessage(entry); // Useless-flagged results carry no information once consumed; they are // eligible even inside the protect-recent window. diff --git a/packages/agent/src/compaction/utils.ts b/packages/agent/src/compaction/utils.ts index e897c7bca..ddd743344 100644 --- a/packages/agent/src/compaction/utils.ts +++ b/packages/agent/src/compaction/utils.ts @@ -173,7 +173,7 @@ export function formatFileOperations( const all = [...mode.keys()].sort(); let files = formatGroupedPaths(all.slice(0, FILE_OPERATION_SUMMARY_LIMIT), path => ` (${mode.get(path)})`); if (all.length > FILE_OPERATION_SUMMARY_LIMIT) { - files += `\n… (${all.length - FILE_OPERATION_SUMMARY_LIMIT} more files omitted)`; + files += `\n[…${all.length - FILE_OPERATION_SUMMARY_LIMIT} files elided…]`; } return prompt.render(fileOperationsTemplate, { files }); } diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index e178ebc1d..c55d9998b 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -36,6 +36,43 @@ export type StreamFn = ( */ export type AsideMessage = AgentMessage | (() => AgentMessage | null); +/** + * A soft tool requirement: the host wants `toolName` called before the loop + * runs other tools or yields, but WITHOUT paying the forced-`toolChoice` cost + * up front (changing `tool_choice` invalidates the provider message cache). + * Returned from {@link AgentLoopConfig.getToolChoice} in place of a hard + * {@link ToolChoice}: the loop injects `reminder` once when a new `id` becomes + * active, runs with `toolChoice` unchanged, and escalates to a one-turn forced + * choice only if the model fails to call `toolName`. Auto-clears when the host + * stops returning it or `toolName` is no longer an active tool. + */ +export interface SoftToolRequirement { + /** Discriminates a soft requirement from a hard {@link ToolChoice}. */ + soft: true; + /** + * Stable id of the *current* requirement. The loop injects `reminder` when + * this id first becomes active and again whenever it changes (e.g. one + * stacked preview resolves and the next becomes the head), but never + * re-injects for an unchanged id across turns. + */ + id: string; + /** Tool that must be called before the loop runs other tools or yields. */ + toolName: string; + /** Host-owned reminder messages, injected once per `id` activation. */ + reminder: AgentMessage[]; +} + +/** + * A per-turn tool-choice directive: either a hard provider {@link ToolChoice} + * (applied verbatim) or a {@link SoftToolRequirement} (remind-then-escalate). + */ +export type ToolChoiceDirective = ToolChoice | SoftToolRequirement; + +/** True when a {@link ToolChoiceDirective} is a soft requirement, not a hard choice. */ +export function isSoftToolRequirement(directive: ToolChoiceDirective | undefined): directive is SoftToolRequirement { + return typeof directive === "object" && directive !== null && (directive as SoftToolRequirement).soft === true; +} + /** * Configuration for the agent loop. */ @@ -203,6 +240,12 @@ export interface AgentLoopConfig extends SimpleStreamOptions { * then strips from arguments before executing tools. */ intentTracing?: boolean; + /** + * Strip tool descriptions (top-level + nested schema annotations) from the + * provider-bound tool specs. Use when the full catalog is rendered into the + * system prompt instead, so descriptions are not duplicated on the wire. + */ + pruneToolDescriptions?: boolean; /** * Owned tool calling dialect. * @@ -246,10 +289,14 @@ export interface AgentLoopConfig extends SimpleStreamOptions { onHarmonyLeak?: (event: HarmonyAuditEvent) => void | Promise; /** - * Dynamic tool choice override, resolved per LLM call. - * When set and returns a value, overrides the static `toolChoice`. + * Dynamic tool-choice directive, resolved once per turn. Returns a hard + * {@link ToolChoice} (applied verbatim, overriding the static `toolChoice`), + * a {@link SoftToolRequirement} (the loop reminds-then-escalates instead of + * forcing `tool_choice` immediately, so a model that complies with the + * reminder pays no message-cache invalidation), or `undefined` to fall back + * to the static `toolChoice`. */ - getToolChoice?: () => ToolChoice | undefined; + getToolChoice?: () => ToolChoiceDirective | undefined; /** * Dynamic reasoning effort override, resolved per LLM call. diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index 04700934b..1543c121d 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { agentLoop, agentLoopContinue, agentLoopDetailed, INTENT_FIELD } from "@oh-my-pi/pi-agent-core/agent-loop"; +import { agentLoop, agentLoopContinue, agentLoopDetailed } from "@oh-my-pi/pi-agent-core/agent-loop"; import type { AgentContext, AgentEvent, @@ -12,6 +12,7 @@ import type { import type { AssistantMessage, AssistantMessageEvent, Message, ToolResultMessage } from "@oh-my-pi/pi-ai"; import { createMockModel, type MockResponse } from "@oh-my-pi/pi-ai/providers/mock"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import { type } from "arktype"; import { createAssistantMessage, createUserMessage } from "./helpers"; diff --git a/packages/agent/test/append-only-context.test.ts b/packages/agent/test/append-only-context.test.ts index 402b6aea8..e4825e675 100644 --- a/packages/agent/test/append-only-context.test.ts +++ b/packages/agent/test/append-only-context.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test"; import { AppendOnlyContextManager, AppendOnlyLog, StablePrefix } from "@oh-my-pi/pi-agent-core/append-only-context"; import type { AgentContext, AgentTool } from "@oh-my-pi/pi-agent-core/types"; import type { Message, Tool, ToolExample } from "@oh-my-pi/pi-ai"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import { type } from "arktype"; // --------------------------------------------------------------------------- @@ -603,8 +604,8 @@ describe("intent injection through build()", () => { const result = mgr.build(ctx, { intentTracing: true }); const params = result.tools?.[0]?.parameters as { properties?: Record; required?: string[] }; expect(params?.properties).toBeDefined(); - expect(params!.properties!._i).toBeDefined(); - expect(params!.required).toContain("_i"); + expect(params!.properties![INTENT_FIELD]).toBeDefined(); + expect(params!.required).toContain(INTENT_FIELD); }); it("materializes ArkType params and keeps `_i` first in authored order", () => { @@ -616,8 +617,8 @@ describe("intent injection through build()", () => { const params = result.tools?.[0]?.parameters as { properties?: Record; required?: string[] }; // `_i` must lead; authored order (path before content) is preserved rather // than ArkType's alphabetized-by-hash order (content, path). - expect(Object.keys(params.properties ?? {})).toEqual(["_i", "path", "content"]); - expect(params.required).toContain("_i"); + expect(Object.keys(params.properties ?? {})).toEqual([INTENT_FIELD, "path", "content"]); + expect(params.required).toContain(INTENT_FIELD); }); it("omits `_i` when intentTracing is false", () => { @@ -631,8 +632,8 @@ describe("intent injection through build()", () => { const result = mgr.build(ctx, { intentTracing: false }); const params = result.tools?.[0]?.parameters as { properties?: Record; required?: string[] }; - expect(params?.properties?._i).toBeUndefined(); - expect(params?.required ?? []).not.toContain("_i"); + expect(params?.properties?.[INTENT_FIELD]).toBeUndefined(); + expect(params?.required ?? []).not.toContain(INTENT_FIELD); }); it("intentTracing flip invalidates the fingerprint cache", () => { @@ -685,7 +686,7 @@ describe("tool examples injection through build()", () => { const result = mgr.build(ctx, { intentTracing: true, exampleDialect: "anthropic" }); const desc = result.tools?.[0]?.description ?? ""; - expect(desc).toContain(' { + return { + name: "demo", + label: "Demo", + description: "top-level tool description", + parameters: toolSchema, + async execute() { + return { content: [{ type: "text", text: "ok" }] }; + }, + }; +} + +/** Read the `properties` map off a wire JSON schema with runtime narrowing. */ +function wireProps(parameters: unknown): Record { + if (!parameters || typeof parameters !== "object" || !("properties" in parameters)) { + throw new Error("expected a wire schema with properties"); + } + const properties = parameters.properties; + if (!properties || typeof properties !== "object") throw new Error("expected a properties object"); + // Narrowed to a non-null object above; wire property maps are plain string-keyed objects. + const map = properties as Record; + return map; +} + +function hasField(parameters: unknown, field: string): boolean { + return field in wireProps(parameters); +} + +function fieldDescription(parameters: unknown, field: string): unknown { + const value = wireProps(parameters)[field]; + if (value && typeof value === "object" && "description" in value) return value.description; + return undefined; +} + +describe("normalizeTools — pruneDescriptions", () => { + it("keeps the top-level description when pruning is off", () => { + const tools = normalizeTools([makeTool()], false); + expect(tools?.[0]?.description).toBe("top-level tool description"); + }); + + it("empties the description and strips nested schema descriptions when pruning", () => { + const tools = normalizeTools([makeTool()], false, undefined, true); + const tool = tools?.[0]; + expect(tool?.description).toBe(""); + const wire = JSON.stringify(tool?.parameters); + expect(wire).not.toContain("where to read"); + expect(wire).not.toContain("inner value"); + expect(wire).not.toContain("a nested object"); + // Structure is preserved. + expect(hasField(tool?.parameters, "path")).toBe(true); + expect(hasField(tool?.parameters, "nested")).toBe(true); + }); + + it("injects the intent field WITHOUT a description when pruning", () => { + const tools = normalizeTools([makeTool()], true, undefined, true); + const params = tools?.[0]?.parameters; + expect(hasField(params, INTENT_FIELD)).toBe(true); + expect(fieldDescription(params, INTENT_FIELD)).toBeUndefined(); + // No description text rides the wire at all. + expect(JSON.stringify(params)).not.toContain("description"); + }); + + it("keeps the intent field description when not pruning", () => { + const tools = normalizeTools([makeTool()], true); + expect(typeof fieldDescription(tools?.[0]?.parameters, INTENT_FIELD)).toBe("string"); + }); +}); diff --git a/packages/agent/test/prompt-tools-loop.test.ts b/packages/agent/test/prompt-tools-loop.test.ts index db9548c36..eb636dbbf 100644 --- a/packages/agent/test/prompt-tools-loop.test.ts +++ b/packages/agent/test/prompt-tools-loop.test.ts @@ -102,6 +102,79 @@ describe("agentLoop with owned in-band tool calls", () => { expect(wireText(internalResult!)).toBe("echoed:hello world"); }); + it("prunes native tool descriptions from the wire when pruneToolDescriptions is set", async () => { + const toolSchema = type({ msg: type("string").describe("the message to echo") }); + const echoTool: AgentTool = { + name: "echo", + label: "Echo", + description: "Echo a message back", + parameters: toolSchema, + async execute(_toolCallId, params) { + return { content: [{ type: "text", text: `echoed:${params.msg}` }], details: params }; + }, + }; + const captured: Context[] = []; + const mock = createMockModel({ + responses: [ + context => { + captured.push(context); + return { content: ["done"] }; + }, + ], + }); + const context: AgentContext = { systemPrompt: ["BASE PROMPT"], messages: [], tools: [echoTool] }; + const config: AgentLoopConfig = { + model: mock.model, + convertToLlm: identityConverter, + pruneToolDescriptions: true, + }; + await agentLoop([createUserMessage("say hi")], context, config, undefined, mock.stream).result(); + + const wireTools = captured[0]?.tools; + expect(wireTools).toHaveLength(1); + expect(wireTools?.[0].name).toBe("echo"); + // Native tool calling: spec ships with no description text (top-level or nested). + expect(wireTools?.[0].description).toBe(""); + expect(JSON.stringify(wireTools?.[0].parameters)).not.toContain("the message to echo"); + }); + + it("keeps in-band tool descriptions for owned dialects even when pruneToolDescriptions is set", async () => { + const toolSchema = type({ msg: "string" }); + const echoTool: AgentTool = { + name: "echo", + label: "Echo", + description: "Echo a message back", + parameters: toolSchema, + async execute(_toolCallId, params) { + return { content: [{ type: "text", text: `echoed:${params.msg}` }], details: params }; + }, + }; + const captured: Context[] = []; + const mock = createMockModel({ + responses: [ + context => { + captured.push(context); + return { content: ["done"] }; + }, + ], + }); + const context: AgentContext = { systemPrompt: ["BASE PROMPT"], messages: [], tools: [echoTool] }; + const config: AgentLoopConfig = { + model: mock.model, + convertToLlm: identityConverter, + dialect: "glm", + pruneToolDescriptions: true, + }; + await agentLoop([createUserMessage("say hi")], context, config, undefined, mock.stream).result(); + + // Owned dialect carries the catalog in the prompt as text and sends no native + // tools, so pruning must not strip its descriptions. + expect(captured[0]?.tools).toBeUndefined(); + const promptSection = (captured[0]?.systemPrompt ?? []).join("\n"); + expect(promptSection).toContain(""); + expect(promptSection).toContain("Echo a message back"); + }); + it("executes Hermes/Qwen JSON tool calls when that dialect is selected", async () => { const echoArgs: Array<{ msg: string }> = []; const toolSchema = type({ msg: "string" }); diff --git a/packages/agent/test/soft-tool-requirement.test.ts b/packages/agent/test/soft-tool-requirement.test.ts new file mode 100644 index 000000000..c52550918 --- /dev/null +++ b/packages/agent/test/soft-tool-requirement.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from "bun:test"; +import { agentLoop } from "@oh-my-pi/pi-agent-core/agent-loop"; +import type { + AgentContext, + AgentLoopConfig, + AgentMessage, + AgentTool, + SoftToolRequirement, +} from "@oh-my-pi/pi-agent-core/types"; +import type { Message, ToolChoice } from "@oh-my-pi/pi-ai"; +import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; +import { type } from "arktype"; +import { createUserMessage } from "./helpers"; + +function identityConverter(messages: AgentMessage[]): Message[] { + return messages.filter(m => m.role === "user" || m.role === "assistant" || m.role === "toolResult") as Message[]; +} + +const emptySchema = type({}); + +/** + * Build a host that gates `resolve` as a soft requirement while a preview is + * pending. The `resolve` tool clears it, mirroring coding-agent's pending + * invoker. `getToolChoice` returns the soft requirement (a peek — never + * consuming) until the preview resolves. + */ +function makeSoftResolveHarness() { + let pendingPreview = true; + let resolveRuns = 0; + let peekRuns = 0; + const reminder = createUserMessage("Resolve the pending preview."); + + const resolveTool: AgentTool> = { + name: "resolve", + label: "Resolve", + description: "Resolve the pending action", + parameters: emptySchema, + async execute() { + resolveRuns++; + pendingPreview = false; + return { content: [{ type: "text", text: "resolved" }], details: {} }; + }, + }; + const peekTool: AgentTool> = { + name: "peek", + label: "Peek", + description: "A non-resolve detour tool", + parameters: emptySchema, + async execute() { + peekRuns++; + return { content: [{ type: "text", text: "peeked" }], details: {} }; + }, + }; + + const getToolChoice = (): SoftToolRequirement | undefined => + pendingPreview ? { soft: true, id: "preview-1", toolName: "resolve", reminder: [reminder] } : undefined; + + return { + tools: [resolveTool, peekTool], + getToolChoice, + reminder, + get resolveRuns() { + return resolveRuns; + }, + get peekRuns() { + return peekRuns; + }, + }; +} + +describe("agentLoop soft tool requirement", () => { + it("never forces tool_choice when the model complies with the reminder", async () => { + const h = makeSoftResolveHarness(); + const context: AgentContext = { systemPrompt: ["sys"], messages: [], tools: h.tools }; + const mock = createMockModel({ + responses: [ + { content: [{ type: "toolCall", id: "c1", name: "resolve", arguments: {} }] }, + { content: ["done"] }, + ], + }); + const config: AgentLoopConfig = { + model: mock.model, + convertToLlm: identityConverter, + getToolChoice: h.getToolChoice, + }; + + const stream = agentLoop([createUserMessage("go")], context, config, undefined, mock.stream); + for await (const _ of stream) { + // drain + } + const messages = await stream.result(); + + // Compliant: resolve ran, and NO turn ever carried a forced tool_choice. + expect(h.resolveRuns).toBe(1); + expect(mock.calls).toHaveLength(2); + expect(mock.calls[0]?.options?.toolChoice).toBeUndefined(); + expect(mock.calls[1]?.options?.toolChoice).toBeUndefined(); + // The reminder was injected exactly once (on the new requirement id). + expect(messages.filter(m => m === h.reminder)).toHaveLength(1); + }); + + it("skips a detour batch and force-escalates resolve on the next turn", async () => { + const h = makeSoftResolveHarness(); + const context: AgentContext = { systemPrompt: ["sys"], messages: [], tools: h.tools }; + const mock = createMockModel({ + responses: [ + // Turn 1: model ignores the reminder and calls a detour tool. + { content: [{ type: "toolCall", id: "p1", name: "peek", arguments: {} }] }, + // Turn 2: forced to resolve. + { content: [{ type: "toolCall", id: "r1", name: "resolve", arguments: {} }] }, + { content: ["done"] }, + ], + }); + const config: AgentLoopConfig = { + model: mock.model, + convertToLlm: identityConverter, + getToolChoice: h.getToolChoice, + }; + + const stream = agentLoop([createUserMessage("go")], context, config, undefined, mock.stream); + for await (const _ of stream) { + // drain + } + const messages = await stream.result(); + + // The detour tool was NOT executed (its batch was skipped), resolve ran once. + expect(h.peekRuns).toBe(0); + expect(h.resolveRuns).toBe(1); + // Turn 1 ran with auto (no bust); turn 2 was force-escalated to resolve. + expect(mock.calls[0]?.options?.toolChoice).toBeUndefined(); + expect(mock.calls[1]?.options?.toolChoice).toEqual({ type: "tool", name: "resolve" }); + // The skipped peek call still produced a paired tool result (API pairing). + const peekResult = messages.find(m => m.role === "toolResult" && m.toolCallId === "p1"); + expect(peekResult).toBeDefined(); + }); + + it("does not yield while the requirement is unmet — escalates after a bare-text turn", async () => { + const h = makeSoftResolveHarness(); + const context: AgentContext = { systemPrompt: ["sys"], messages: [], tools: h.tools }; + const mock = createMockModel({ + responses: [ + // Turn 1: model yields with text, no tool call. + { content: ["I'll get to it later."] }, + // Turn 2: forced to resolve. + { content: [{ type: "toolCall", id: "r1", name: "resolve", arguments: {} }] }, + { content: ["done"] }, + ], + }); + const config: AgentLoopConfig = { + model: mock.model, + convertToLlm: identityConverter, + getToolChoice: h.getToolChoice, + }; + + const stream = agentLoop([createUserMessage("go")], context, config, undefined, mock.stream); + for await (const _ of stream) { + // drain + } + + // The loop did not stop after the bare-text turn; it forced resolve. + expect(h.resolveRuns).toBe(1); + expect(mock.calls.length).toBeGreaterThanOrEqual(2); + expect(mock.calls[1]?.options?.toolChoice).toEqual({ type: "tool", name: "resolve" }); + }); + + it("leaves tool_choice untouched when no requirement is pending", async () => { + const context: AgentContext = { systemPrompt: ["sys"], messages: [], tools: [] }; + const mock = createMockModel({ responses: [{ content: ["hi"] }] }); + const config: AgentLoopConfig = { + model: mock.model, + convertToLlm: identityConverter, + getToolChoice: () => undefined, + }; + + const stream = agentLoop([createUserMessage("go")], context, config, undefined, mock.stream); + for await (const _ of stream) { + // drain + } + expect(mock.calls).toHaveLength(1); + expect(mock.calls[0]?.options?.toolChoice).toBeUndefined(); + }); + + it("reuses the fetched hard tool choice across a Harmony retry instead of re-consuming getToolChoice", async () => { + const context: AgentContext = { systemPrompt: ["sys"], messages: [], tools: [] }; + // Consuming source: yields the forced choice once, then nothing — mirrors + // coding-agent's ToolChoiceQueue.nextToolChoice advancing its generator. + const queue: ToolChoice[] = [{ type: "tool", name: "resolve" }]; + const leak = "Some prose. analysis to=functions.edit code 大发官网"; + const mock = createMockModel({ + provider: "openai-codex", + responses: [{ content: [leak] }, { content: ["clean retry"] }], + }); + const config: AgentLoopConfig = { + model: mock.model, + convertToLlm: identityConverter, + getToolChoice: () => queue.shift(), + }; + + const stream = agentLoop([createUserMessage("go")], context, config, undefined, mock.stream); + for await (const _ of stream) { + // drain + } + + // The leaked attempt was retried; both calls carried the SAME forced choice + // and the consuming source advanced exactly once (no double-consume). + expect(mock.calls).toHaveLength(2); + expect(mock.calls[0]?.options?.toolChoice).toEqual({ type: "tool", name: "resolve" }); + expect(mock.calls[1]?.options?.toolChoice).toEqual({ type: "tool", name: "resolve" }); + expect(queue).toHaveLength(0); + }); +}); diff --git a/packages/agent/test/supersede-prune.test.ts b/packages/agent/test/supersede-prune.test.ts index f71f63526..dcc2c2b95 100644 --- a/packages/agent/test/supersede-prune.test.ts +++ b/packages/agent/test/supersede-prune.test.ts @@ -522,3 +522,136 @@ describe("pruneToolOutputs — small-result floor", () => { expect(resultMessage(bigResult).prunedAt).toBeDefined(); }); }); + +describe("cache-stable boundary — warm prefix protection", () => { + // (a) The primary bug: in pruneToolOutputs a superseded result bypasses the + // protect window and is rewritten at any depth. With the cache guard armed it + // must be left alone when it sits in the warm, already-sent cached prefix. + test("(a) deep superseded result is rewritten WITHOUT the guard but kept WITH it", () => { + const build = (): { entries: SessionEntry[]; result1: SessionMessageEntry; result2: SessionMessageEntry } => { + const [call1, result1] = readPair("src/foo.ts", FILE_CONTENT, T0); + const big = textEntry(BIG_TEXT, T0 + 500); // pushes result1 deep into the suffix + const [call2, result2] = readPair("src/foo.ts", FILE_CONTENT, T0 + 1_000); // tail, supersedes result1 + return { entries: [call1, result1, big, call2, result2], result1, result2 }; + }; + const base = { + protectTokens: 1_000_000, // everything inside the (age) protect window + minimumSavings: 0, + protectedTools: [], + supersedeKey: readToolSupersedeKey, + }; + + // Legacy (no cacheWarmSuffixTokens): superseded result1 bypasses the window -> pruned. + const legacy = build(); + const legacyRun = pruneToolOutputs(legacy.entries, base); + expect(legacyRun.prunedCount).toBe(1); + expect(resultText(legacy.result1)).toBe(SUPERSEDED_NOTICE); + + // Guard armed: result1's all-message suffix (BIG_TEXT + call2 + result2) far + // exceeds the window, so it is part of the warm cached prefix and is kept. + const guarded = build(); + const guardedRun = pruneToolOutputs(guarded.entries, { ...base, cacheWarmSuffixTokens: 200 }); + expect(guardedRun.prunedCount).toBe(0); + expect(resultText(guarded.result1)).toBe(FILE_CONTENT); + expect(resultMessage(guarded.result1).prunedAt).toBeUndefined(); + }); + + test("(a) deep useless result is kept when the cache guard is armed", () => { + const [call1, result1] = uselessPair("search", NO_MATCH_TEXT, T0); + const big = textEntry(BIG_TEXT, T0 + 500); + const [call2, result2] = readPair("src/foo.ts", FILE_CONTENT, T0 + 1_000); + const entries: SessionEntry[] = [call1, result1, big, call2, result2]; + + const result = pruneToolOutputs(entries, { + protectTokens: 1_000_000, + minimumSavings: 0, + protectedTools: [], + pruneUseless: true, + cacheWarmSuffixTokens: 200, + }); + + expect(result.prunedCount).toBe(0); + expect(resultText(result1)).toBe(NO_MATCH_TEXT); + expect(resultMessage(result1).prunedAt).toBeUndefined(); + expect(resultText(result2)).toBe(FILE_CONTENT); + }); + + // (b) The legit case must still fire: a superseded copy in the cheap-to-recache + // tail (suffix below the window) is still reclaimed. + test("(b) tail-case superseded result still prunes with the guard armed", () => { + const [call1, result1] = readPair("src/foo.ts", FILE_CONTENT, T0); + const [call2, result2] = readPair("src/foo.ts", FILE_CONTENT, T0 + 1_000); + const entries: SessionEntry[] = [call1, result1, call2, result2]; + + const result = pruneToolOutputs(entries, { + protectTokens: 1_000_000, + minimumSavings: 0, + protectedTools: [], + supersedeKey: readToolSupersedeKey, + cacheWarmSuffixTokens: 100_000, // result1's suffix is far below this -> tail -> prunable + }); + + expect(result.prunedCount).toBe(1); + expect(resultText(result1)).toBe(SUPERSEDED_NOTICE); + expect(resultText(result2)).toBe(FILE_CONTENT); + }); + + test("(b) supersede pass still prunes the tail case with keepBoundaryId set", () => { + const [call1, result1] = readPair("src/foo.ts", FILE_CONTENT, T0); + const [call2, result2] = readPair("src/foo.ts", FILE_CONTENT, T0 + 1_000); + const entries: SessionEntry[] = [call1, result1, call2, result2]; + + const result = pruneSupersededToolResults(entries, cfg({ keepBoundaryId: call1.id, now: T0 + 1_000 })); + + expect(result.prunedCount).toBe(1); + expect(resultText(result1)).toBe(SUPERSEDED_NOTICE); + expect(resultText(result2)).toBe(FILE_CONTENT); + }); + + // (c) Entries before firstKeptEntryId are summarized away — never sent — so no + // pass may mutate them, not even the idle full-flush. + test("(c) idle flush never mutates entries before keepBoundaryId", () => { + const [call1, result1] = readPair("src/foo.ts", FILE_CONTENT, T0); // idx 0,1 — before boundary + const [call2, result2] = readPair("src/foo.ts", FILE_CONTENT, T0 + 1_000); // idx 2,3 — boundary at call2 + const [call3, result3] = readPair("src/foo.ts", FILE_CONTENT, T0 + 2_000); // idx 4,5 — latest + const big = textEntry(BIG_TEXT, T0 + 3_000); + const entries: SessionEntry[] = [call1, result1, call2, result2, call3, result3, big]; + + // Cold cache (idle > threshold) with suffixTokenLimit 0: only the idle path can fire. + const result = pruneSupersededToolResults( + entries, + cfg({ + keepBoundaryId: call2.id, + suffixTokenLimit: 0, + idleFlushMs: 30 * 60_000, + now: T0 + 3_000 + 31 * 60_000, + }), + ); + + expect(result.prunedCount).toBe(1); + expect(resultText(result1)).toBe(FILE_CONTENT); // before boundary -> untouched + expect(resultMessage(result1).prunedAt).toBeUndefined(); + expect(resultText(result2)).toBe(SUPERSEDED_NOTICE); // at/after boundary -> flushed + expect(resultText(result3)).toBe(FILE_CONTENT); // latest -> kept + }); + + test("(c) pruneToolOutputs never mutates entries before keepBoundaryId", () => { + const [call1, result1] = readPair("src/old.ts", FILE_CONTENT, T0); // idx 0,1 — before boundary + const [call2, result2] = readPair("src/new.ts", FILE_CONTENT, T0 + 1_000); // idx 2,3 — boundary at call2 + const entries: SessionEntry[] = [call1, result1, call2, result2]; + + // protectTokens 0 -> the age path would prune both; the window is wide so the + // guard does not protect either; only keepBoundaryId shields result1. + pruneToolOutputs(entries, { + protectTokens: 0, + minimumSavings: 0, + protectedTools: [], + keepBoundaryId: call2.id, + cacheWarmSuffixTokens: 1_000_000, + }); + + expect(resultText(result1)).toBe(FILE_CONTENT); // before boundary -> untouched + expect(resultMessage(result1).prunedAt).toBeUndefined(); + expect(resultMessage(result2).prunedAt).toBeDefined(); // at/after boundary, in tail -> pruned + }); +}); diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 7f508f9ec..5e83d03c7 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,28 @@ ## [Unreleased] +## [16.1.0] - 2026-06-19 + +### Added + +- Added utility functions to strip schema descriptions for optimized LLM context usage + +## [16.0.10] - 2026-06-18 + +### Added + +- Replaced the old legacy XML-ish `pi` owned tool-calling dialect with the new sigil-delimited format (`§` call header with inline `key=value` scalars, `«…»` verbatim body fence for the dominant string argument, `¤` reasoning, `‡‡` tool result) using single-token markers that never occur in source code. Verbatim fences escalate Markdown-style (`««…»»`) so re-rendered history never collides with payload content, and the scanner gates a bare `§` on an exact known-tool name to avoid swallowing prose. Round-trips and streams through the existing scanner contract at ~46% fewer tokens than the legacy format on typical calls; selectable via `tools.format` or `PI_DIALECT=pi`. + +### Changed + +- Updated `pi` dialect formatting to use a token-frugal, sigil-delimited format (`§`, `¤`, `‡‡`) +- Updated `pi` dialect body fences to automatically escalate when content contains fence markers +- Changed `pi` dialect tool results response format to `‡‡` blocks + +### Fixed + +- Fixed Bedrock application inference profile ARNs to route requests to the ARN's region instead of the default Bedrock runtime region. ([#3004](https://github.com/can1357/oh-my-pi/issues/3004)) + ## [16.0.9] - 2026-06-18 ### Fixed diff --git a/packages/ai/package.json b/packages/ai/package.json index 1d2e7d093..7c14673c4 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-ai", - "version": "16.0.9", + "version": "16.1.1", "description": "Unified LLM API with automatic model discovery and provider configuration", "homepage": "https://omp.sh", "author": "Can Boluk", @@ -40,6 +40,7 @@ "@bufbuild/protobuf": "catalog:", "@oh-my-pi/pi-catalog": "catalog:", "@oh-my-pi/pi-utils": "catalog:", + "@oh-my-pi/pi-wire": "catalog:", "arktype": "catalog:", "partial-json": "catalog:", "zod": "catalog:" diff --git a/packages/ai/src/dialect/owned-stream.ts b/packages/ai/src/dialect/owned-stream.ts index 3140d8eb5..b66c0d31d 100644 --- a/packages/ai/src/dialect/owned-stream.ts +++ b/packages/ai/src/dialect/owned-stream.ts @@ -19,7 +19,7 @@ const RESPONSE_OPEN_TOKENS: Record = { minimax: ["", ""], deepseek: ["<|tool▁outputs▁begin|>", "<|tool▁output▁begin|>"], harmony: ["<|start|>functions."], - pi: [""], + pi: ["‡‡"], qwen3: [""], gemini: ["```tool_outputs"], gemma: ["<|tool_response>"], diff --git a/packages/ai/src/dialect/pi.md b/packages/ai/src/dialect/pi.md index f1a99c18e..506368713 100644 --- a/packages/ai/src/dialect/pi.md +++ b/packages/ai/src/dialect/pi.md @@ -1,57 +1,55 @@ ## Format guide -A tool call is a `…` block (or self-closing ``) written as plain assistant text; arguments are given as tag attributes, child elements, or a verbatim inline body. +A tool call begins with `§` immediately followed by the function NAME (start each call on its own line). Scalar arguments follow on the same line as `key=value` pairs; a single large or multi-line string argument goes in a verbatim body fenced by `«…»` right after the header. + +Scalar-only call (the line ends the call): ```text - +§read path=src/a.ts offset=50 limit=200 ``` -Objects and arrays use child elements, repeating an element for each array item: +Call with a verbatim body — everything between `«` and `»` is taken literally, no quoting or escaping: ```text - - -4 -alpha -beta - - -``` - -A single string argument can fill the body directly: - -```text - +§edit path=src/server/auth.ts« *** Begin Patch -... +*** Update File: src/server/auth.ts +@@ class AuthService +- login(user) { ++ async login(user, opts) { *** End Patch - +» ``` -Tool results arrive as response blocks, read in call order: +Argument values: + +- Strings are written bare and verbatim (`path=src/a.ts`). Quote with `"…"` only when the value contains spaces or starts with `"`, `[`, or `{` (`_i="run the tests"`). +- Numbers, booleans, and `null` are JSON literals (`offset=50`, `force=true`). +- Arrays and objects are inline JSON (`paths=["src","test"]`). +- The body fence holds the call's first long/multi-line string parameter; its key is implied, never written. + +Private reasoning goes in a `¤…¤` block before your calls: ```text - -verbatim tool result - -``` - -Private reasoning goes in a `…` block before your calls: - -```text - +¤ brief reasoning - +¤ +``` + +Tool results arrive in `‡‡…‡‡` blocks, read in call order: + +```text +‡‡ +verbatim tool result +‡‡ ``` ## Rules -- `NAME` must match a listed function; never wrap calls in JSON or fences. -- Use attributes only for top-level scalars; put objects, arrays, and long strings in child elements. -- Strings are verbatim (no quotes, no entity escaping); numbers, booleans, and null are JSON literals. -- An object opens a child block whose scalar subfields may also be attributes; an array repeats its element once per item. -- The inline body fills the first unset string-typed parameter and may contain any raw text except ``. -- Emit parallel calls as consecutive blocks. NEVER invent call ids; results are positional. -- Private reasoning goes in a `…` block before your calls; NEVER put calls inside it. -- Read each `` in call order. NEVER emit `` yourself. +- `NAME` MUST match a listed function; never wrap calls in JSON or fences. +- Put each scalar argument once as `key=value`; reserve the `«…»` body for the one dominant string argument (file contents, patches, commands, queries). +- Body text is verbatim — include no surrounding quotes. If the body itself contains `»`, widen BOTH guillemet fences equally (`««…»»`, `«««…»»»`). +- Emit parallel calls as consecutive `§…` blocks. NEVER invent call ids; results are positional. +- Private reasoning goes in a `¤…¤` block before your calls; NEVER put calls inside it, and keep a literal `¤` out of the reasoning text. +- Read each `‡‡…‡‡` result in call order. NEVER emit a `‡‡` block yourself. - After emitting your tool calls, YOU MUST EMIT THE STOP SEQUENCE AND HALT. diff --git a/packages/ai/src/dialect/pi.ts b/packages/ai/src/dialect/pi.ts index ed6535d46..acb1ca11b 100644 --- a/packages/ai/src/dialect/pi.ts +++ b/packages/ai/src/dialect/pi.ts @@ -1,19 +1,8 @@ import type { Message, ToolCall } from "../types"; import type { ToolArgShape } from "./coercion"; -import { - buildArgShapes, - coerceValue, - collectSchemaTypes, - getArrayItemSchema, - getObjectProperties, - isArraySchema, - isObjectSchema, - isStringOnlySchema, - mintToolCallId, - partialSuffixOverlapAny, -} from "./coercion"; +import { buildArgShapes, coerceValue, isStringOnlySchema, mintToolCallId, partialSuffixOverlapAny } from "./coercion"; import dialectPrompt from "./pi.md" with { type: "text" }; -import { renderChatMlTranscript, renderToolResponseResults, stringifyJson } from "./rendering"; +import { renderChatMlTranscript, stringifyJson } from "./rendering"; import type { DialectDefinition, DialectRenderOptions, @@ -23,57 +12,66 @@ import type { InbandScannerOptions, } from "./types"; -const CALL_PREFIX = "` / `` (3 tokens + the repeated name); +// - scalar arguments inline as `key=value` (the key appears once) rather than +// `value` (key twice + four bracket tokens); +// - the dominant string argument fills a verbatim body fence, dropping its key +// entirely and needing no escaping for code/patches. +// +// Verbatim fences (body `«»`, result `‡‡`) escalate Markdown/raw-string style: +// when the content contains the closer, the renderer widens the fence (`««…»»`, +// `‡‡‡…‡‡‡`) so re-rendered history can never collide with payload content. + +const CALL_SIGIL = "§"; +const FENCE_OPEN = "«"; +const FENCE_CLOSE = "»"; +const THINK_SIGIL = "¤"; +const RESULT_FENCE = "‡"; +const OUTSIDE_TAGS = [CALL_SIGIL, THINK_SIGIL] as const; +const CALL_TAGS = [CALL_SIGIL] as const; const NAME_START = /[A-Za-z_]/; const NAME_CHAR = /[A-Za-z0-9_-]/; const EMPTY_STRING_ARGS: ReadonlySet = new Set(); type ScannerState = "outside" | "body" | "thinking"; -type BodyMode = "undecided" | "inline" | "members"; -type RawAttribute = { name: string; value: string | true }; - -type OpenTag = { - name: string; - rawAttrs: RawAttribute[]; - selfClosing: boolean; - end: number; -}; - -type MembersResult = { - ok: boolean; - value: Record; - next: number; -}; - -type ValueResult = { - ok: boolean; - value: unknown; - next: number; -}; +type HeaderEnd = + | { kind: "fence"; index: number } + | { kind: "newline"; index: number } + | { kind: "eof"; index: number } + | { kind: "incomplete" }; export class PiNativeInbandScanner implements InbandScanner { #buffer = ""; #state: ScannerState = "outside"; - #bodyMode: BodyMode = "undecided"; #id = ""; #name = ""; #args: Record = {}; - #inlineKey = ""; - #inlineValue = ""; - #inlineLeading = false; + #bodyKey = ""; + #bodyValue = ""; + #bodyLeading = false; + #closeMarker = ""; #rawBlock = ""; + #thinking = ""; readonly #argShapes: Map; readonly #stringArgs: (toolName: string) => ReadonlySet; - #thinking = ""; + readonly #knowsTools: boolean; readonly #parseThinking: boolean; constructor(options: InbandScannerOptions = {}) { this.#argShapes = buildArgShapes(options.tools); + this.#knowsTools = this.#argShapes.size > 0; this.#stringArgs = options.stringArgs ?? (toolName => this.#argShapes.get(toolName)?.stringArgs ?? EMPTY_STRING_ARGS); this.#parseThinking = options.parseThinking !== false; @@ -96,37 +94,19 @@ export class PiNativeInbandScanner implements InbandScanner { if (!this.#consumeOutside(events, final)) break; continue; } - if (this.#state === "thinking") { if (!this.#consumeThinking(events, final)) break; continue; } - - if (this.#bodyMode === "undecided") { - const mode = this.#classifyBody(final); - if (!mode) break; - this.#bodyMode = mode; - if (mode === "inline") { - this.#inlineKey = this.#inlineTargetKey() ?? "input"; - this.#inlineValue = ""; - this.#inlineLeading = true; - } - } - - if (this.#bodyMode === "inline") { - if (!this.#consumeInline(events, final)) break; - continue; - } - - if (!this.#consumeMembers(events, final)) break; + if (!this.#consumeBody(events, final)) break; } if (final && this.#state === "thinking") this.#endThinking(events); return events; } #consumeOutside(events: InbandScanEvent[], final: boolean): boolean { - const call = this.#buffer.indexOf(CALL_PREFIX); - const think = this.#parseThinking ? this.#buffer.indexOf(THINK_OPEN) : -1; + const call = this.#buffer.indexOf(CALL_SIGIL); + const think = this.#parseThinking ? this.#buffer.indexOf(THINK_SIGIL) : -1; let start = call; let isThink = false; if (think !== -1 && (start === -1 || think < start)) { @@ -148,49 +128,174 @@ export class PiNativeInbandScanner implements InbandScanner { } if (isThink) { - this.#buffer = this.#buffer.slice(THINK_OPEN.length); + this.#buffer = this.#buffer.slice(THINK_SIGIL.length); this.#thinking = ""; events.push({ type: "thinkingStart" }); this.#state = "thinking"; return true; } - const tagEnd = findTagEnd(this.#buffer, 0); - if (tagEnd === -1) { - if (final) this.#buffer = ""; - return false; - } + return this.#beginCall(events, final); + } - const tag = parseCallOpenTag(this.#buffer); - if (!tag) { - events.push({ type: "text", text: this.#buffer[0] ?? "" }); - this.#buffer = this.#buffer.slice(1); - return true; - } + // Buffer starts with `§`. Resolve the tool name, then the header terminator. + // Returns false to wait for more input (call still streaming in). + #beginCall(events: InbandScanEvent[], final: boolean): boolean { + const nameStart = CALL_SIGIL.length; + if (nameStart >= this.#buffer.length && !final) return false; // just `§` so far + let nameEnd = nameStart; + if (!isNameStart(this.#buffer[nameEnd])) return this.#rejectSigil(events); + nameEnd++; + while (nameEnd < this.#buffer.length && isNameChar(this.#buffer[nameEnd])) nameEnd++; + if (nameEnd >= this.#buffer.length && !final) return false; // name may continue - this.#beginCall(tag, events); - this.#buffer = this.#buffer.slice(tag.end); - if (tag.selfClosing) { - events.push({ - type: "toolEnd", - id: this.#id, - name: this.#name, - arguments: this.#args, - rawBlock: this.#rawBlock, - }); - this.#reset(); - return true; - } + const name = this.#buffer.slice(CALL_SIGIL.length, nameEnd); + // Guard against `§` in prose: only claim a known tool when schemas exist. + if (this.#knowsTools && !this.#argShapes.has(name)) return this.#rejectSigil(events); - this.#state = "body"; - this.#bodyMode = "undecided"; + const header = findHeaderEnd(this.#buffer, nameEnd); + if (header.kind === "fence") { + let runEnd = header.index; + while (runEnd < this.#buffer.length && this.#buffer[runEnd] === FENCE_OPEN) runEnd++; + if (runEnd >= this.#buffer.length && !final) return false; // fence run may grow + return this.#startCall(events, name, nameEnd, header.index, "fence", runEnd - header.index); + } + if (header.kind === "newline") { + return this.#startCall(events, name, nameEnd, header.index, "newline", 0); + } + // "eof"/"incomplete": the header may still be streaming in — only a scalar + // call with no trailing newline at true end-of-stream finalizes here. + if (!final) return false; + return this.#startCall(events, name, nameEnd, this.#buffer.length, "eof", 0); + } + + // `§` not followed by a known tool name is prose — surface it as literal text. + #rejectSigil(events: InbandScanEvent[]): boolean { + events.push({ type: "text", text: CALL_SIGIL }); + this.#buffer = this.#buffer.slice(CALL_SIGIL.length); return true; } - #consumeThinking(events: InbandScanEvent[], final: boolean): boolean { - const close = this.#buffer.indexOf(THINK_CLOSE); + #startCall( + events: InbandScanEvent[], + name: string, + argsStart: number, + headerEnd: number, + kind: "fence" | "newline" | "eof", + fenceLen: number, + ): boolean { + const shape = this.#argShapes.get(name); + this.#id = mintToolCallId(); + this.#name = name; + this.#args = parseHeaderArgs(this.#buffer.slice(argsStart, headerEnd), shape?.properties ?? {}); + events.push({ type: "toolStart", id: this.#id, name: this.#name }); + + if (kind === "fence") { + const fenceEnd = headerEnd + fenceLen; + this.#rawBlock = this.#buffer.slice(0, fenceEnd); + this.#closeMarker = FENCE_CLOSE.repeat(fenceLen); + this.#bodyKey = this.#inlineTargetKey() ?? "input"; + this.#bodyValue = ""; + this.#bodyLeading = true; + this.#buffer = this.#buffer.slice(fenceEnd); + this.#state = "body"; + return true; + } + + this.#rawBlock = this.#buffer.slice(0, headerEnd); + events.push({ + type: "toolEnd", + id: this.#id, + name: this.#name, + arguments: this.#args, + rawBlock: this.#rawBlock, + }); + let next = headerEnd; + if (kind === "newline") { + if (this.#buffer[next] === "\r") next++; + if (this.#buffer[next] === "\n") next++; + } + this.#buffer = this.#buffer.slice(next); + this.#reset(); + return true; + } + + #consumeBody(events: InbandScanEvent[], final: boolean): boolean { + this.#stripBodyLeading(final); + const close = this.#buffer.indexOf(this.#closeMarker); if (close === -1) { - const hold = final ? 0 : partialSuffixOverlapAny(this.#buffer, [THINK_CLOSE]); + if (final) { + this.#reset(); + this.#buffer = ""; + return false; + } + const overlap = partialSuffixOverlapAny(this.#buffer, [this.#closeMarker]); + let hold = Math.max(this.#closeMarker.length, overlap); + if (overlap > 0) { + const beforeOverlap = this.#buffer.length - overlap - 1; + if (this.#buffer[beforeOverlap] === "\n") { + hold = Math.max(hold, overlap + 1); + if (this.#buffer[beforeOverlap - 1] === "\r") hold = Math.max(hold, overlap + 2); + } + } + const emitLength = this.#buffer.length - hold; + if (emitLength > 0) { + const delta = this.#buffer.slice(0, emitLength); + this.#rawBlock += delta; + this.#emitBodyDelta(delta, events); + this.#buffer = this.#buffer.slice(emitLength); + } + return false; + } + + const rawDelta = this.#buffer.slice(0, close); + this.#rawBlock += rawDelta + this.#closeMarker; + let delta = rawDelta; + if (delta.endsWith("\r\n")) delta = delta.slice(0, -2); + else if (delta.endsWith("\n")) delta = delta.slice(0, -1); + this.#emitBodyDelta(delta, events); + this.#args[this.#bodyKey] = this.#bodyValue; + events.push({ + type: "toolEnd", + id: this.#id, + name: this.#name, + arguments: this.#args, + rawBlock: this.#rawBlock, + }); + this.#buffer = this.#buffer.slice(close + this.#closeMarker.length); + this.#reset(); + return true; + } + + #stripBodyLeading(final: boolean): void { + if (!this.#bodyLeading) return; + if (this.#buffer.length === 0) return; + if (this.#buffer[0] === "\r") { + if (this.#buffer.length === 1 && !final) return; + if (this.#buffer[1] === "\n") { + this.#rawBlock += this.#buffer.slice(0, 2); + this.#buffer = this.#buffer.slice(2); + } + this.#bodyLeading = false; + return; + } + if (this.#buffer[0] === "\n") { + this.#rawBlock += this.#buffer[0]; + this.#buffer = this.#buffer.slice(1); + } + this.#bodyLeading = false; + } + + #emitBodyDelta(delta: string, events: InbandScanEvent[]): void { + if (delta.length === 0) return; + this.#bodyValue += delta; + events.push({ type: "toolArgDelta", id: this.#id, name: this.#name, key: this.#bodyKey, delta }); + } + + #consumeThinking(events: InbandScanEvent[], final: boolean): boolean { + const close = this.#buffer.indexOf(THINK_SIGIL); + if (close === -1) { + const hold = final ? 0 : partialSuffixOverlapAny(this.#buffer, [THINK_SIGIL]); this.#emitThinking(this.#buffer.slice(0, this.#buffer.length - hold), events); this.#buffer = this.#buffer.slice(this.#buffer.length - hold); if (final) { @@ -200,7 +305,7 @@ export class PiNativeInbandScanner implements InbandScanner { return false; } this.#emitThinking(this.#buffer.slice(0, close), events); - this.#buffer = this.#buffer.slice(close + THINK_CLOSE.length); + this.#buffer = this.#buffer.slice(close + THINK_SIGIL.length); this.#endThinking(events); this.#state = "outside"; return true; @@ -218,151 +323,8 @@ export class PiNativeInbandScanner implements InbandScanner { this.#state = "outside"; } - #beginCall(tag: OpenTag, events: InbandScanEvent[]): void { - this.#id = mintToolCallId(); - this.#name = tag.name; - this.#args = coerceAttributes(tag.rawAttrs, this.#shape()?.properties ?? {}); - this.#inlineKey = ""; - this.#inlineValue = ""; - this.#inlineLeading = false; - this.#rawBlock = this.#buffer.slice(0, tag.end); - events.push({ type: "toolStart", id: this.#id, name: this.#name }); - } - - #classifyBody(final: boolean): BodyMode | undefined { - const closeTag = this.#closeTag(); - const first = skipWhitespace(this.#buffer, 0); - const close = this.#buffer.indexOf(closeTag); - const inlineKey = this.#inlineTargetKey(); - - if (close !== -1 && first >= close) return inlineKey ? "inline" : "members"; - if (first >= this.#buffer.length) return undefined; - - const fromFirst = this.#buffer.slice(first); - if (!final && closeTag.startsWith(fromFirst)) return undefined; - - if (this.#buffer[first] !== "<") return inlineKey ? "inline" : "members"; - if (this.#buffer.startsWith(closeTag, first)) return inlineKey ? "inline" : "members"; - if (this.#buffer.startsWith(" 0) { - const beforeOverlap = this.#buffer.length - overlap - 1; - if (this.#buffer[beforeOverlap] === "\n") { - hold = Math.max(hold, overlap + 1); - if (this.#buffer[beforeOverlap - 1] === "\r") hold = Math.max(hold, overlap + 2); - } - } - const emitLength = this.#buffer.length - hold; - if (emitLength > 0) { - const delta = this.#buffer.slice(0, emitLength); - this.#rawBlock += delta; - this.#emitInlineDelta(delta, events); - this.#buffer = this.#buffer.slice(emitLength); - } - return false; - } - - const rawDelta = this.#buffer.slice(0, close); - this.#rawBlock += rawDelta + closeTag; - let delta = rawDelta; - if (delta.endsWith("\r\n")) delta = delta.slice(0, -2); - else if (delta.endsWith("\n")) delta = delta.slice(0, -1); - this.#emitInlineDelta(delta, events); - this.#args[this.#inlineKey] = this.#inlineValue; - events.push({ type: "toolEnd", id: this.#id, name: this.#name, arguments: this.#args, rawBlock: this.#rawBlock }); - this.#buffer = this.#buffer.slice(close + closeTag.length); - this.#reset(); - return true; - } - - #consumeMembers(events: InbandScanEvent[], final: boolean): boolean { - const closeTag = this.#closeTag(); - let searchFrom = 0; - while (true) { - const close = this.#buffer.indexOf(closeTag, searchFrom); - if (close === -1) { - if (final) { - this.#reset(); - this.#buffer = ""; - } - return false; - } - - const body = this.#buffer.slice(0, close); - const parsed = parseMembers(body, 0, undefined, this.#shape()?.properties ?? {}); - if (!parsed.ok || skipWhitespace(body, parsed.next) !== body.length) { - searchFrom = close + closeTag.length; - continue; - } - - const bodyArgs = parsed.value; - const args = { ...this.#args, ...bodyArgs }; - this.#rawBlock += body + closeTag; - this.#emitCompletedStringDeltas(bodyArgs, events); - events.push({ type: "toolEnd", id: this.#id, name: this.#name, arguments: args, rawBlock: this.#rawBlock }); - this.#buffer = this.#buffer.slice(close + closeTag.length); - this.#reset(); - return true; - } - } - - #emitCompletedStringDeltas(args: Record, events: InbandScanEvent[]): void { - for (const key in args) { - const value = args[key]; - if (typeof value === "string" && value.length > 0) { - events.push({ type: "toolArgDelta", id: this.#id, name: this.#name, key, delta: value }); - } - } - } - - #stripInlineLeadingDelimiter(final: boolean): void { - if (!this.#inlineLeading) return; - if (this.#buffer.length === 0) return; - if (this.#buffer[0] === "\r") { - if (this.#buffer.length === 1 && !final) return; - if (this.#buffer[1] === "\n") { - this.#rawBlock += this.#buffer.slice(0, 2); - this.#buffer = this.#buffer.slice(2); - } - this.#inlineLeading = false; - return; - } - if (this.#buffer[0] === "\n") { - this.#rawBlock += this.#buffer[0]; - this.#buffer = this.#buffer.slice(1); - } - this.#inlineLeading = false; - } - - #emitInlineDelta(delta: string, events: InbandScanEvent[]): void { - if (delta.length === 0) return; - this.#inlineValue += delta; - events.push({ type: "toolArgDelta", id: this.#id, name: this.#name, key: this.#inlineKey, delta }); - } - #inlineTargetKey(): string | undefined { - const shape = this.#shape(); + const shape = this.#argShapes.get(this.#name); if (shape) { for (const key of shape.parameterOrder) { if (Object.hasOwn(this.#args, key)) continue; @@ -370,258 +332,149 @@ export class PiNativeInbandScanner implements InbandScanner { } return undefined; } - for (const key of this.#stringArgs(this.#name)) { if (!Object.hasOwn(this.#args, key)) return key; } return "input"; } - #shape(): ToolArgShape | undefined { - return this.#argShapes.get(this.#name); - } - - #closeTag(): string { - return ``; - } - #reset(): void { this.#state = "outside"; - this.#bodyMode = "undecided"; this.#id = ""; this.#name = ""; this.#args = {}; - this.#inlineKey = ""; - this.#inlineValue = ""; - this.#inlineLeading = false; + this.#bodyKey = ""; + this.#bodyValue = ""; + this.#bodyLeading = false; + this.#closeMarker = ""; this.#rawBlock = ""; } } -function parseMembers( - text: string, - position: number, - endTag: string | undefined, - properties: Record, -): MembersResult { - const value: Record = {}; - let index = position; +function parseHeaderArgs(text: string, properties: Record): Record { + const args: Record = {}; + let index = skipWhitespace(text, 0); while (index < text.length) { - index = skipWhitespace(text, index); - if (endTag && text.startsWith(endTag, index)) return { ok: true, value, next: index + endTag.length }; - if (index >= text.length) break; - if (text[index] !== "<" || text.startsWith(" 0) return { ok: true, value: attrs, next: tag.end }; - return { ok: true, value: coerceValue("", schema), next: tag.end }; - } - - const bodyStart = tag.end; - const closeTag = ``; - if (shouldParseObjectBody(text, bodyStart, closeTag, schema, tag.rawAttrs.length > 0)) { - const parsed = parseMembers(text, bodyStart, closeTag, attrProperties); - if (!parsed.ok) return { ok: false, value: undefined, next: bodyStart }; - return { ok: true, value: { ...attrs, ...parsed.value }, next: parsed.next }; - } - - const close = text.indexOf(closeTag, bodyStart); - if (close === -1) return { ok: false, value: undefined, next: bodyStart }; - const raw = stripBlockDelimiters(text.slice(bodyStart, close)); - return { ok: true, value: coerceValue(raw, schema), next: close + closeTag.length }; -} - -function shouldParseObjectBody( - text: string, - bodyStart: number, - closeTag: string, - schema: unknown, - hasAttrs: boolean, -): boolean { - if (isObjectSchema(schema)) return true; - if (isTypedScalarSchema(schema)) return false; - if (hasAttrs) return true; - const first = skipWhitespace(text, bodyStart); - if (text.startsWith(closeTag, first)) return false; - return text[first] === "<" && !text.startsWith(", key: string, value: unknown, schemaArray: boolean): void { - if (schemaArray) { - const existing = target[key]; - if (Array.isArray(existing)) existing.push(value); - else target[key] = [value]; - return; - } - - if (!Object.hasOwn(target, key)) { - target[key] = value; - return; - } - - const existing = target[key]; - if (Array.isArray(existing)) existing.push(value); - else target[key] = [existing, value]; -} - -function coerceAttributes( - rawAttrs: readonly RawAttribute[], - properties: Record, -): Record { - const attrs: Record = {}; - for (const attr of rawAttrs) { - attrs[attr.name] = attr.value === true ? true : coerceValue(attr.value, properties[attr.name]); - } - return attrs; -} - -function parseCallOpenTag(text: string): OpenTag | undefined { - if (!text.startsWith(CALL_PREFIX)) return undefined; - const tagEnd = findTagEnd(text, 0); - if (tagEnd === -1) return undefined; - return parseOpenTagContent(text, CALL_PREFIX.length, tagEnd); -} - -function parseElementOpenTag(text: string, start: number): OpenTag | undefined { - if (text[start] !== "<" || text.startsWith("= cursor && isWhitespace(text[last])) last--; - if (text[last] === "/") { - selfClosing = true; - contentEnd = last; - } - - return { - name, - rawAttrs: parseRawAttributes(text.slice(cursor, contentEnd)), - selfClosing, - end: tagEnd + 1, - }; -} - -function parseRawAttributes(text: string): RawAttribute[] { - const attrs: RawAttribute[] = []; - let index = 0; - while (index < text.length) { - index = skipWhitespace(text, index); - if (index >= text.length) break; if (!isNameStart(text[index])) { index++; continue; } - const nameStart = index; index++; while (index < text.length && isNameChar(text[index])) index++; - const name = text.slice(nameStart, index); + const key = text.slice(nameStart, index); index = skipWhitespace(text, index); if (text[index] !== "=") { - attrs.push({ name, value: true }); + args[key] = true; continue; } - - index++; - index = skipWhitespace(text, index); - if (index >= text.length) { - attrs.push({ name, value: "" }); - break; - } - - const quote = text[index]; - if (quote === '"' || quote === "'") { - const valueStart = ++index; - while (index < text.length && text[index] !== quote) index++; - attrs.push({ name, value: text.slice(valueStart, index) }); - if (index < text.length) index++; - continue; - } - - const valueStart = index; - while (index < text.length && !isWhitespace(text[index])) index++; - attrs.push({ name, value: text.slice(valueStart, index) }); + index = skipWhitespace(text, index + 1); + const parsed = readInlineValue(text, index, properties[key]); + args[key] = parsed.value; + index = skipWhitespace(text, parsed.next); } - return attrs; + return args; } -function readElementNamePrefix(text: string, ltIndex: number): string | undefined { - let index = ltIndex + 1; - if (index >= text.length) return undefined; - if (!isNameStart(text[index])) return ""; - const start = index; - index++; - while (index < text.length && isNameChar(text[index])) index++; - if (index >= text.length) return undefined; - const next = text[index]; - return isWhitespace(next) || next === "/" || next === ">" ? text.slice(start, index) : ""; +type InlineValue = { value: unknown; next: number }; + +function readInlineValue(text: string, start: number, schema: unknown): InlineValue { + const ch = text[start]; + if (ch === '"') { + let index = start + 1; + while (index < text.length) { + const c = text[index]; + if (c === "\\") { + index += 2; + continue; + } + if (c === '"') { + index++; + break; + } + index++; + } + const raw = text.slice(start, index); + try { + return { value: JSON.parse(raw) as unknown, next: index }; + } catch { + return { value: raw.slice(1, raw.endsWith('"') ? -1 : undefined), next: index }; + } + } + if (ch === "[" || ch === "{") { + const end = matchBracket(text, start); + const raw = text.slice(start, end); + try { + return { value: JSON.parse(raw) as unknown, next: end }; + } catch { + return { value: raw, next: end }; + } + } + let index = start; + while (index < text.length && !isWhitespace(text[index])) index++; + return { value: coerceValue(text.slice(start, index), schema), next: index }; } -function findTagEnd(text: string, start: number): number { - let quote = ""; +function matchBracket(text: string, start: number): number { + let depth = 0; + let inString = false; for (let index = start; index < text.length; index++) { const ch = text[index]; - if (quote) { - if (ch === quote) quote = ""; + if (inString) { + if (ch === "\\") { + index++; + continue; + } + if (ch === '"') inString = false; continue; } - if (ch === '"' || ch === "'") { - quote = ch; + if (ch === '"') { + inString = true; continue; } - if (ch === ">") return index; + if (ch === "[" || ch === "{") depth++; + else if (ch === "]" || ch === "}") { + depth--; + if (depth === 0) return index + 1; + } } - return -1; + return text.length; } -function stripBlockDelimiters(raw: string): string { - let start = 0; - let end = raw.length; - if (raw.startsWith("\r\n")) start = 2; - else if (raw.startsWith("\n")) start = 1; - if (end > start) { - if (raw.endsWith("\r\n")) end -= 2; - else if (raw.endsWith("\n")) end -= 1; +// Locate where a call header ends: the first top-level body fence, the first +// literal newline (scalar-only call), end-of-input, or "incomplete" when a +// quoted/bracketed value is still mid-stream. +function findHeaderEnd(text: string, start: number): HeaderEnd { + let inString = false; + let depth = 0; + for (let index = start; index < text.length; index++) { + const ch = text[index]; + if (inString) { + if (ch === "\\") { + index++; + continue; + } + if (ch === '"') inString = false; + continue; + } + if (ch === '"') { + inString = true; + continue; + } + if (ch === "[" || ch === "{") { + depth++; + continue; + } + if (ch === "]" || ch === "}") { + if (depth > 0) depth--; + continue; + } + if (depth > 0) continue; + if (ch === FENCE_OPEN) return { kind: "fence", index }; + if (ch === "\n" || ch === "\r") return { kind: "newline", index }; } - return raw.slice(start, end); + if (inString || depth > 0) return { kind: "incomplete" }; + return { kind: "eof", index: text.length }; } function skipWhitespace(text: string, index: number): number { @@ -645,26 +498,83 @@ function renderToolCall(call: ToolCall, options: DialectRenderOptions = {}): str return renderInvocation(call, buildArgShapes(options.tools).get(call.name)); } -function renderInvocation(call: ToolCall, shape: ToolArgShape | undefined): string { - let body = ``; - for (const key in call.arguments) { - body += `\n${renderElement(key, call.arguments[key], shape?.properties[key])}`; - } - return `${body}\n`; -} - function renderAssistantToolCalls(calls: readonly ToolCall[], options: DialectRenderOptions = {}): string { const shapes = buildArgShapes(options.tools); return calls.map(call => renderInvocation(call, shapes.get(call.name))).join("\n"); } +function renderInvocation(call: ToolCall, shape: ToolArgShape | undefined): string { + const properties = shape?.properties ?? {}; + const bodyKey = selectBodyKey(call.arguments, shape); + let header = `${CALL_SIGIL}${call.name}`; + for (const key in call.arguments) { + if (key === bodyKey) continue; + header += ` ${key}=${renderInlineValue(call.arguments[key], properties[key])}`; + } + if (bodyKey === undefined) return header; + const body = String(call.arguments[bodyKey]); + const fence = 1 + maxRun(body, FENCE_CLOSE); + return `${header}${FENCE_OPEN.repeat(fence)}\n${body}\n${FENCE_CLOSE.repeat(fence)}`; +} + +// The body holds a single dominant string argument: the first string-only +// parameter whose value contains a newline. Single-line strings stay inline +// (quoted when needed) so the verbatim fence is reserved for genuine blocks. +function selectBodyKey(args: Record, shape: ToolArgShape | undefined): string | undefined { + // Round-trip requires renderer and scanner to agree on the omitted body key. + // The scanner assigns the body to the first string-only parameter missing from + // the header, so a body is only safe when no earlier parameter is also absent + // (no schema → keep everything inline). + if (!shape) return undefined; + for (const key of shape.parameterOrder) { + if (!Object.hasOwn(args, key)) return undefined; + const value = args[key]; + if (typeof value === "string" && value.includes("\n") && isStringOnlySchema(shape.properties[key])) return key; + } + return undefined; +} + +function renderInlineValue(value: unknown, schema: unknown): string { + if (typeof value === "string") { + return needsQuote(value) ? JSON.stringify(value) : value; + } + if (isStringOnlySchema(schema) && value === null) return '""'; + return stringifyJson(value); +} + +function needsQuote(value: string): boolean { + if (value.length === 0) return true; + const first = value[0]; + if (first === '"' || first === "[" || first === "{") return true; + return /[\s«»]/.test(value); +} + +function maxRun(text: string, ch: string): number { + let best = 0; + let run = 0; + for (let index = 0; index < text.length; index++) { + if (text[index] === ch) { + run++; + if (run > best) best = run; + } else { + run = 0; + } + } + return best; +} + function renderToolResults(results: readonly DialectToolResult[], _options?: DialectRenderOptions): string { - return renderToolResponseResults(results); + return results + .map(result => { + const fence = RESULT_FENCE.repeat(Math.max(2, 1 + maxRun(result.text, RESULT_FENCE))); + return `${fence}\n${result.text}\n${fence}`; + }) + .join("\n"); } function renderThinking(text: string): string { if (!text) return ""; - return `\n${text}\n`; + return `${THINK_SIGIL}\n${text}\n${THINK_SIGIL}`; } function renderTranscript(messages: readonly Message[], options: DialectRenderOptions = {}): string { @@ -676,29 +586,6 @@ function renderTranscript(messages: readonly Message[], options: DialectRenderOp }); } -function renderElement(key: string, value: unknown, schema: unknown): string { - if (Array.isArray(value)) { - const itemSchema = getArrayItemSchema(schema); - return value.map(item => renderElement(key, item, itemSchema)).join("\n"); - } - if (value && typeof value === "object") { - const record = value as Record; - const properties = getObjectProperties(schema); - let body = `<${key}>`; - for (const childKey in record) { - body += `\n${renderElement(childKey, record[childKey], properties[childKey])}`; - } - return `${body}\n`; - } - return `<${key}>${renderScalar(value, schema)}`; -} - -function renderScalar(value: unknown, schema: unknown): string { - if (typeof value === "string") return value; - if (isStringOnlySchema(schema) && value === null) return ""; - return stringifyJson(value); -} - const definition: DialectDefinition = { dialect: "pi", prompt: dialectPrompt, diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index 7fd2da65c..3289fb327 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -80,6 +80,13 @@ function resolveBearerToken(options: BedrockOptions): string | undefined { return options.bearerToken || apiKey || $env.AWS_BEARER_TOKEN_BEDROCK; } +function inferRegionFromBedrockArn(modelId: string): string | undefined { + const parts = modelId.split(":", 6); + if (parts[0] !== "arn" || parts[2] !== "bedrock") return undefined; + const region = parts[3]; + return region || undefined; +} + type Block = (TextContent | ThinkingContent | ToolCall) & { index?: number; partialJson?: string; @@ -206,7 +213,12 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = ( const blocks = output.content as Block[]; let rawRequestDump: RawHttpRequestDump | undefined; - const region = options.region || $env.AWS_REGION || $env.AWS_DEFAULT_REGION || "us-east-1"; + const region = + options.region || + inferRegionFromBedrockArn(model.id) || + $env.AWS_REGION || + $env.AWS_DEFAULT_REGION || + "us-east-1"; try { const cacheRetention = resolveCacheRetention(options.cacheRetention); diff --git a/packages/ai/src/registry/oauth/index.ts b/packages/ai/src/registry/oauth/index.ts index 5c365ab16..5f293fb01 100644 --- a/packages/ai/src/registry/oauth/index.ts +++ b/packages/ai/src/registry/oauth/index.ts @@ -19,6 +19,7 @@ const builtInOAuthProviders: OAuthProviderInfo[] = PROVIDER_REGISTRY.filter( id: provider.id, name: provider.name, available: provider.available ?? true, + storeCredentialsAs: provider.storeCredentialsAs, })); const customOAuthProviders = new Map(); @@ -163,6 +164,7 @@ export function getOAuthProviders(): OAuthProviderInfo[] { id: provider.id, name: provider.name, available: true, + storeCredentialsAs: provider.storeCredentialsAs, })); return [...builtInOAuthProviders, ...customProviders]; } diff --git a/packages/ai/src/registry/oauth/types.ts b/packages/ai/src/registry/oauth/types.ts index 859d8b1c3..20bc43219 100644 --- a/packages/ai/src/registry/oauth/types.ts +++ b/packages/ai/src/registry/oauth/types.ts @@ -31,6 +31,12 @@ export interface OAuthProviderInfo { id: OAuthProviderId; name: string; available: boolean; + /** + * Provider id the login stores credentials under, when it differs from `id` + * (e.g. `openai-codex-device` ⇒ `openai-codex`). Lets callers map a login + * entry back to the model provider it authenticates. + */ + storeCredentialsAs?: string; } export interface OAuthController { diff --git a/packages/ai/src/utils/schema/wire.ts b/packages/ai/src/utils/schema/wire.ts index b24a9f593..ffb25fdb5 100644 --- a/packages/ai/src/utils/schema/wire.ts +++ b/packages/ai/src/utils/schema/wire.ts @@ -152,6 +152,7 @@ function arkJsonAstToWire(value: unknown): unknown { const kZodWireSchema = Symbol("pi.schema.zod.wire"); const kJsonWireSchema = Symbol("pi.schema.json.wire"); const kArkWireSchema = Symbol("pi.schema.ark.wire"); +const kStrippedSchema = Symbol("pi.schema.descriptions.stripped"); /** * Post-process Zod-emitted JSON Schema so it matches the wire shape providers @@ -634,3 +635,87 @@ export function toolWireSchema(tool: Tool): Record { return postProcessJsonSchema(upgraded); }); } + +/** + * Schema-valued keywords whose value is a single subschema (or an array of + * subschemas — the recursion dispatches on array-ness, so tuple forms like + * draft-07 `items: []` are handled too). Covers the draft 2020-12 surface plus + * the legacy `additionalItems` that may survive an incomplete upgrade. + */ +const STRIP_SCHEMA_VALUE_KEYS = [ + "additionalProperties", + "unevaluatedProperties", + "unevaluatedItems", + "items", + "additionalItems", + "contains", + "propertyNames", + "contentSchema", + "if", + "then", + "else", + "not", + "anyOf", + "oneOf", + "allOf", + "prefixItems", +] as const; + +/** Keywords whose value is a `{ name: Schema }` map — names are NOT annotations. */ +const STRIP_SCHEMA_MAP_KEYS = ["properties", "patternProperties", "$defs", "definitions", "dependentSchemas"] as const; + +/** + * Recursively strip human-readable `description` annotations from a JSON Schema, + * descending only through schema-valued keywords so a property literally named + * `"description"` inside a `properties`/`$defs` map keeps its schema (only its own + * annotation is dropped), and data-bearing keywords (`default`/`const`/`examples`) + * are never traversed. Mutates `node` in place — callers pass a clone. + */ +function stripSchemaDescriptionsInPlace(node: unknown): void { + if (Array.isArray(node)) { + for (const child of node) stripSchemaDescriptionsInPlace(child); + return; + } + if (!isSchemaRecord(node)) return; + delete node.description; + for (const key of STRIP_SCHEMA_VALUE_KEYS) { + if (Object.hasOwn(node, key)) stripSchemaDescriptionsInPlace(node[key]); + } + for (const mapKey of STRIP_SCHEMA_MAP_KEYS) { + const map = node[mapKey]; + if (isSchemaRecord(map)) { + for (const key in map) stripSchemaDescriptionsInPlace(map[key]); + } + } +} + +/** + * Return a deep clone of `schema` with every `description` annotation removed. + * The result is memoized on the input via a non-enumerable symbol (`stamp`) so + * repeated provider requests reuse the same stripped object; the input is never + * mutated, so the stamped `toolWireSchema` cache stays intact for + * system-prompt/UI rendering. + */ +export function stripSchemaDescriptions(schema: Record): Record { + return stamp(schema, kStrippedSchema, source => { + const clone = structuredClone(source); + stripSchemaDescriptionsInPlace(clone); + return clone; + }); +} + +/** + * Strip a tool's human-readable text from its provider-bound spec: empties the + * top-level `description` and removes nested schema `description` annotations. + * Used when the full tool catalog is rendered into the system prompt instead, so + * the descriptions ride the wire once (in the prompt) rather than duplicated on + * every tool definition. Parameters are resolved to wire JSON Schema and cloned, + * leaving the original tool objects and the stamped schema cache untouched. + */ +export function stripToolDescriptions(tools: readonly Tool[]): Tool[] { + return tools.map(tool => ({ + ...tool, + description: "", + parameters: stripSchemaDescriptions(toolWireSchema(tool)), + })); +} diff --git a/packages/ai/test/bedrock-inference-profile.test.ts b/packages/ai/test/bedrock-inference-profile.test.ts new file mode 100644 index 000000000..3753bedff --- /dev/null +++ b/packages/ai/test/bedrock-inference-profile.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test"; +import { streamBedrock } from "@oh-my-pi/pi-ai/providers/amazon-bedrock"; +import type { Context, FetchImpl, Model } from "@oh-my-pi/pi-ai/types"; +import { buildModel } from "@oh-my-pi/pi-catalog/build"; + +const profileArn = "arn:aws:bedrock:us-east-2:1234567890:application-inference-profile/company-opus-48"; +const profileModel: Model<"bedrock-converse-stream"> = buildModel({ + id: profileArn, + name: "Bedrock inference profile", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + contextWindow: 1000000, + maxTokens: 128000, +}); + +function userContext(): Context { + return { + messages: [{ role: "user", content: "Say hello", timestamp: 0 }], + }; +} + +describe("Bedrock inference profile ARNs", () => { + test("routes requests to the ARN region and preserves the ARN model id", async () => { + const calls: string[] = []; + const customFetch: FetchImpl = Object.assign( + async (input: string | URL | Request, _init?: RequestInit) => { + calls.push(String(input instanceof Request ? input.url : input)); + return new Response("nope", { status: 418 }); + }, + { preconnect: fetch.preconnect }, + ); + + const result = await streamBedrock(profileModel, userContext(), { + bearerToken: "test-token", + fetch: customFetch, + maxTokens: 16, + }).result(); + + expect(result.stopReason).toBe("error"); + expect(calls).toEqual([ + `https://bedrock-runtime.us-east-2.amazonaws.com/model/${encodeURIComponent(profileArn)}/converse-stream`, + ]); + }); +}); diff --git a/packages/ai/test/dialect-thinking.test.ts b/packages/ai/test/dialect-thinking.test.ts index 9ec128887..84f2f32c3 100644 --- a/packages/ai/test/dialect-thinking.test.ts +++ b/packages/ai/test/dialect-thinking.test.ts @@ -164,26 +164,26 @@ describe("kimi think tags (…)", () => { }); }); -describe("pi native thinking channel (…)", () => { - it("routes to thinking, keeping it out of the reply and calls", () => { - const events = scan("pi", "reasoning"); +describe("pi native thinking channel (¤…¤)", () => { + it("routes ¤ to thinking, keeping it out of the reply and calls", () => { + const events = scan("pi", "¤reasoning¤§foo x=1\n"); expect(thinkingText(events)).toBe("reasoning"); expect(callNames(events)).toEqual([{ name: "foo", arguments: { x: 1 } }]); - expect(visibleText(events)).not.toContain(""); + expect(visibleText(events)).not.toContain("¤"); }); it("round-trips renderThinking through the scanner", () => { const rendered = getDialectDefinition("pi").renderThinking("reasoning"); - expect(rendered).toBe("\nreasoning\n"); + expect(rendered).toBe("¤\nreasoning\n¤"); const events = scan("pi", `${rendered}visible`); expect(thinkingText(events).trim()).toBe("reasoning"); expect(visibleText(events)).toBe("visible"); }); - it("treats as plain text when parseThinking is disabled", () => { - const events = scan("pi", "xanswer", { options: { parseThinking: false } }); + it("treats ¤ as plain text when parseThinking is disabled", () => { + const events = scan("pi", "¤x¤answer", { options: { parseThinking: false } }); expect(thinkingBoundaries(events)).toBe(0); - expect(visibleText(events)).toContain("x"); + expect(visibleText(events)).toContain("¤x¤"); }); }); @@ -223,7 +223,7 @@ describe("unterminated thinking at stream end", () => { { dialect: "gemma", input: "<|channel>thought\npartial" }, { dialect: "glm", input: "partial" }, { dialect: "kimi", input: "partial" }, - { dialect: "pi", input: "partial" }, + { dialect: "pi", input: "¤partial" }, { dialect: "qwen3", input: "partial" }, ]; diff --git a/packages/ai/test/image-limits.test.ts b/packages/ai/test/image-limits.test.ts index be852cee0..d19970f0c 100644 --- a/packages/ai/test/image-limits.test.ts +++ b/packages/ai/test/image-limits.test.ts @@ -70,6 +70,7 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { execSync } from "node:child_process"; import * as fs from "node:fs"; +import * as os from "node:os"; import * as path from "node:path"; import { complete } from "@oh-my-pi/pi-ai/stream"; import type { Api, Context, ImageContent, Model, OptionsForApi, UserMessage } from "@oh-my-pi/pi-ai/types"; @@ -77,7 +78,7 @@ import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { $which } from "@oh-my-pi/pi-utils"; import { e2eApiKey } from "./oauth"; -const TEMP_DIR = path.join(import.meta.dir, ".temp-images"); +const TEMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omp-temp-images-")); /** * Generate a valid PNG image of specified dimensions using ImageMagick @@ -231,8 +232,6 @@ describe("Image Limits E2E Tests", () => { beforeAll(async () => { if (!$which("magick")) return; - // Create temp directory - fs.mkdirSync(TEMP_DIR, { recursive: true }); // Generate small test image for count tests smallImage = await generateImage(100, 100, "small.png"); diff --git a/packages/ai/test/inband-tools.test.ts b/packages/ai/test/inband-tools.test.ts index 8598599b9..0aa9e7966 100644 --- a/packages/ai/test/inband-tools.test.ts +++ b/packages/ai/test/inband-tools.test.ts @@ -154,11 +154,7 @@ describe("in-band tool dialects", () => { '<|start|>assistant<|channel|>commentary to=functions.read<|message|>{"path":"src/a.ts"}<|call|>', '<|start|>assistant<|channel|>commentary to=functions.read<|message|>{"path":"src/a.ts"}<|call|>', ); - expectRawBlock( - "pi", - '\nhello\n', - '\nhello\n', - ); + expectRawBlock("pi", "§write path=out.ts«\nhello\n»", "§write path=out.ts«\nhello\n»"); }); it("projects raw tool blocks onto parsed ToolCall content", () => { @@ -244,9 +240,7 @@ describe("in-band tool dialects", () => { expect(getDialectDefinition("qwen3").renderToolResults([resultBlock])).toBe( "\nFILE\n", ); - expect(getDialectDefinition("pi").renderToolResults([resultBlock])).toBe( - "\nFILE\n", - ); + expect(getDialectDefinition("pi").renderToolResults([resultBlock])).toBe("‡‡\nFILE\n‡‡"); expect(getDialectDefinition("gemini").renderToolResults([resultBlock])).toBe("```tool_outputs\nFILE\n```"); expect(getDialectDefinition("gemma").renderToolResults([resultBlock])).toBe( '<|tool_response>response:read{output:<|"|>FILE<|"|>}', @@ -296,4 +290,60 @@ describe("in-band tool dialects", () => { .join(""); expect(deltas).toBe("line1\nconst x = `a`;"); }); + + it("streams the verbatim body incrementally for pi", () => { + const text = getDialectDefinition("pi").renderAssistantToolCalls( + [ + { + type: "toolCall", + id: "c1", + name: "write", + arguments: { path: "out.ts", content: "line1\nconst x = `a`;" }, + }, + ], + { tools: TOOLS }, + ); + const deltas = feedText("pi", text) + .filter( + (event): event is Extract => + event.type === "toolArgDelta" && event.key === "content", + ) + .map(event => event.delta) + .join(""); + expect(deltas).toBe("line1\nconst x = `a`;"); + }); + + it("escalates the pi body fence so payloads containing » round-trip", () => { + const content = "before »close« and »» double\nsecond line"; + const text = getDialectDefinition("pi").renderToolCall( + { type: "toolCall", id: "c1", name: "write", arguments: { path: "a.md", content } }, + { tools: TOOLS }, + ); + expect(text).toContain("«««"); // fence widened past the »» run in the body + const calls = toolEnds(feedText("pi", text)); + expect(calls).toHaveLength(1); + expect(calls[0]!.arguments).toEqual({ path: "a.md", content }); + }); + + it("ends a scalar-only pi call at the newline and keeps following prose", () => { + const events = feedText("pi", "§read path=a.ts count=5\ndone"); + const calls = toolEnds(events); + expect(calls).toHaveLength(1); + expect(calls[0]!.arguments).toEqual({ path: "a.ts", count: 5 }); + const visible = events + .filter((event): event is Extract => event.type === "text") + .map(event => event.text) + .join(""); + expect(visible).toBe("done"); + }); + + it("treats a § not naming a known tool as literal text", () => { + const events = feedText("pi", "ref §section 3 and §frobnicate end"); + expect(toolEnds(events)).toHaveLength(0); + const visible = events + .filter((event): event is Extract => event.type === "text") + .map(event => event.text) + .join(""); + expect(visible).toBe("ref §section 3 and §frobnicate end"); + }); }); diff --git a/packages/ai/test/schema-wire.test.ts b/packages/ai/test/schema-wire.test.ts index 46ce7a076..56506b07d 100644 --- a/packages/ai/test/schema-wire.test.ts +++ b/packages/ai/test/schema-wire.test.ts @@ -8,6 +8,8 @@ import { normalizeEmptySchemas, normalizeSchemaForCCA, normalizeSchemaForGoogle, + stripSchemaDescriptions, + stripToolDescriptions, toolWireSchema, zodToWireSchema, } from "@oh-my-pi/pi-ai/utils/schema"; @@ -405,3 +407,114 @@ describe("arkToWireSchema — authored property order", () => { expect(Object.keys(wire.properties as Record)).toEqual(["pattern", "i", "paths", "skip"]); }); }); + +describe("stripSchemaDescriptions", () => { + it("removes annotations through nested schema keywords while preserving structure", () => { + const schema = { + type: "object", + description: "object annotation", + properties: { + path: { type: "string", description: "the path" }, + choice: { + anyOf: [ + { type: "string", description: "string variant" }, + { type: "number", description: "number variant" }, + ], + }, + }, + dependentSchemas: { + path: { type: "object", description: "dependent annotation" }, + }, + required: ["path"], + }; + const stripped = stripSchemaDescriptions(schema); + expect(JSON.stringify(stripped)).not.toContain("annotation"); + expect(JSON.stringify(stripped)).not.toContain("variant"); + expect(JSON.stringify(stripped)).not.toContain("the path"); + // Structure survives: types, property names, required, union arity. + const props = stripped.properties as Record; + expect(props.path.type).toBe("string"); + expect(props.choice.anyOf).toHaveLength(2); + expect(stripped.required).toEqual(["path"]); + }); + + it("keeps a property literally named `description` (only its own annotation is dropped)", () => { + const schema = { + type: "object", + properties: { + description: { type: "string", description: "a field that is named description" }, + }, + }; + const stripped = stripSchemaDescriptions(schema); + const prop = (stripped.properties as Record).description; + expect(prop).toBeDefined(); + expect(prop.type).toBe("string"); + expect(prop.description).toBeUndefined(); + }); + + it("never descends into data-bearing keywords (default/const/examples)", () => { + const schema = { + type: "object", + properties: { + mode: { + type: "string", + description: "the mode", + default: { description: "data, keep me" }, + examples: [{ description: "example data" }], + }, + }, + }; + const stripped = stripSchemaDescriptions(schema); + const mode = (stripped.properties as Record>).mode; + expect(mode.description).toBeUndefined(); + expect(mode.default).toEqual({ description: "data, keep me" }); + expect(mode.examples).toEqual([{ description: "example data" }]); + }); + + it("does not mutate the input schema", () => { + const schema = { + type: "object", + description: "keep", + properties: { a: { type: "string", description: "keep a" } }, + }; + stripSchemaDescriptions(schema); + expect(schema.description).toBe("keep"); + expect(schema.properties.a.description).toBe("keep a"); + }); + + it("memoizes the result on the input via a hidden stamp", () => { + const schema = { type: "object", properties: { a: { type: "string", description: "x" } } }; + const first = stripSchemaDescriptions(schema); + const second = stripSchemaDescriptions(schema); + expect(second).toBe(first); + }); +}); + +describe("stripToolDescriptions", () => { + const tool: Tool = { + name: "demo", + description: "top-level tool description", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "where to read" }, + }, + required: ["path"], + }, + }; + + it("empties the top-level description and strips nested schema descriptions", () => { + const [stripped] = stripToolDescriptions([tool]); + expect(stripped.description).toBe(""); + expect(JSON.stringify(stripped.parameters)).not.toContain("where to read"); + expect((stripped.parameters as { properties: Record }).properties.path.type).toBe( + "string", + ); + }); + + it("leaves the original tool and the cached wire schema intact", () => { + stripToolDescriptions([tool]); + expect(tool.description).toBe("top-level tool description"); + expect(JSON.stringify(toolWireSchema(tool))).toContain("where to read"); + }); +}); diff --git a/packages/ai/test/stream-markup-healing.test.ts b/packages/ai/test/stream-markup-healing.test.ts index 32fdcc551..f5d386b9e 100644 --- a/packages/ai/test/stream-markup-healing.test.ts +++ b/packages/ai/test/stream-markup-healing.test.ts @@ -5,6 +5,7 @@ import type { Context, FetchImpl, Model, ThinkingContent, Tool, ToolCall } from import { getStreamMarkupHealingPattern, StreamMarkupHealing } from "@oh-my-pi/pi-ai/utils/stream-markup-healing"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; interface SseToolCallDelta { index: number; @@ -83,7 +84,7 @@ const bashTool: Tool = { parameters: { type: "object", properties: { - _i: { type: "string" }, + [INTENT_FIELD]: { type: "string" }, command: { type: "string" }, timeout: { type: "number" }, }, @@ -163,7 +164,7 @@ describe("StreamMarkupHealing DSML envelope pattern", () => { expect(call.id).toMatch(/^call_[0-9a-f]+$/); const args = JSON.parse(call.arguments) as Record; - expect(args._i).toBe("Check Fedora 42 available packages"); + expect(args[INTENT_FIELD]).toBe("Check Fedora 42 available packages"); expect(args.timeout).toBe(15); expect(String(args.command)).toContain("2>/dev/null"); expect(String(args.command)).toContain('grep -E "^python3|^git-|^sed-|^bash-"'); @@ -736,7 +737,7 @@ describe("OpenAI completions provider DSML envelope healing", () => { expect(toolCalls).toHaveLength(1); expect(toolCalls[0].name).toBe("bash"); expect(toolCalls[0].arguments).toMatchObject({ - _i: "Check Fedora 42 available packages", + [INTENT_FIELD]: "Check Fedora 42 available packages", timeout: 15, }); expect(result.stopReason).toBe("toolUse"); @@ -784,7 +785,7 @@ describe("OpenAI completions provider DSML envelope healing", () => { expect(toolCalls).toHaveLength(1); expect(toolCalls[0].name).toBe("bash"); expect(toolCalls[0].arguments).toMatchObject({ - _i: "Check Fedora 42 available packages", + [INTENT_FIELD]: "Check Fedora 42 available packages", timeout: 15, }); expect(result.stopReason).toBe("toolUse"); diff --git a/packages/ai/test/tool-examples.test.ts b/packages/ai/test/tool-examples.test.ts index d1ae732e6..a781b7dae 100644 --- a/packages/ai/test/tool-examples.test.ts +++ b/packages/ai/test/tool-examples.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import { renderToolExamples } from "../src/dialect/examples"; import type { InbandTool } from "../src/dialect/types"; @@ -50,9 +51,7 @@ describe("renderToolExamples", () => { }; const rendered = renderToolExamples(tool, "pi"); - expect(rendered).toContain(""); - expect(rendered).toContain(""); - expect(rendered).toContain("src/**/*.ts"); + expect(rendered).toContain('§find paths=["src/**/*.ts"]'); }); it("renders call example in hermes format", () => { @@ -155,10 +154,10 @@ describe("renderToolExamples", () => { parameters: { type: "object", properties: { - _i: { type: "string" }, + [INTENT_FIELD]: { type: "string" }, paths: { type: "array", items: { type: "string" } }, }, - required: ["_i", "paths"], + required: [INTENT_FIELD, "paths"], }, examples: [ { @@ -168,11 +167,11 @@ describe("renderToolExamples", () => { ], }; - const rendered = renderToolExamples(tool, "anthropic", "_i"); - expect(rendered).toContain(' { @@ -187,6 +186,6 @@ describe("renderToolExamples", () => { examples: [{ caption: "Find files", call: { paths: ["src/**/*.ts"] } }], }; - expect(renderToolExamples(tool, "anthropic")).not.toContain("_i"); + expect(renderToolExamples(tool, "anthropic")).not.toContain(INTENT_FIELD); }); }); diff --git a/packages/catalog/package.json b/packages/catalog/package.json index 94847af36..dcfb8eb9a 100644 --- a/packages/catalog/package.json +++ b/packages/catalog/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-catalog", - "version": "16.0.9", + "version": "16.1.1", "description": "Model catalog for omp: bundled model database, provider discovery descriptors, model identity, classification, and equivalence", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 22f30a319..9206c9b15 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,100 @@ ### Changed - Changed the temporary model picker to label Alt+P selections as session-only and point users to Alt+M or `/model` for role model assignment. ([#2952](https://github.com/can1357/oh-my-pi/issues/2952)) +## [16.1.1] - 2026-06-19 + +### Changed + +- Migrated legacy macOS power settings to a single sleep prevention enum +- Defaulted `display.cacheMissMarker` setting to `false` to suppress the cache-miss marker by default. +- Replaced the four separate `power.preventIdleSleep`, `power.preventSystemSleep`, `power.declareUserActive`, and `power.preventDisplaySleep` boolean settings with a single cumulative `power.sleepPrevention` enum (`off` → `idle` → `display` → `system`). Each level adds the caffeinate flags of all lower levels. Existing configs are migrated automatically. + +### Fixed + +- Cache-miss marker no longer fires on providers with implicit, best-effort prompt caching (Google/Gemini, OpenAI, Fireworks). Those report `cacheWrite: 0` and drop `cacheRead` to zero intermittently as routine propagation noise that self-heals the next turn. Only explicit, prefix-controlled caches (Anthropic/Bedrock `cache_control`), which re-create the prefix on a cold turn as `cacheWrite > 0`, now surface a marker — where a zero `cacheRead` genuinely means the prefix broke. +- Fixed advisor dependent settings staying visible while `advisor.enabled` is off ([#3027](https://github.com/can1357/oh-my-pi/issues/3027)). +- Fixed LSP servers that dynamically register capabilities, including Expert, hanging semantic requests after `client/registerCapability` was rejected ([#3029](https://github.com/can1357/oh-my-pi/issues/3029)). + +## [16.1.0] - 2026-06-19 + +### Added + +- Added a prompt-cache miss marker: a slim `⊘ cache miss · tokens` divider above an assistant turn whose request lost the provider prompt cache. Detected from per-turn usage — the previous turn cached a meaningful prefix but this request read none of it back and reprocessed the prompt (e.g. after a thinking-level / service-tier / model change, tool or system-prompt change, or a history rewrite). Reports the reprocessed token count. Toggle with the `display.cacheMissMarker` setting (Appearance → Display, on by default). + +### Changed + +- Optimized preview workflows by removing forced tool choices, reducing prompt cache invalidations +- Unified injected message styling with consistent rounded outlines and icon-tagged headers +- Refreshed branch summary messages with a uniform banner style to match compaction points +- Updated skill invocation UI with a compact header, home-shortened paths, and dynamic line counts +- Refined session context to utilize history blocks instead of raw images for snapcompact summaries +- Refreshed the skill-prompt transcript message to match the rest of the TUI: an icon-tagged `✦ skill ` header (with invocation args trailing), a single meta line with a home-shortened, click-to-open path in the accent color and the prompt size in muted, and a rounded outline around the card. Replaces the old `[skill]` label with the flat `Skill:`/`Path:`/`Prompt:` key/value dump (which also leaked the absolute home directory). +- Refreshed the default custom/hook transcript message frame to a rounded, icon-tagged card: the `[customType]` bracket label is replaced by an ` ` header (📦 for extension messages, 🪝 for legacy hook-role messages) with a subtle outline, matching the skill card. Covers every extension/hook custom type without a bespoke renderer. +- Refreshed the branch-summary transcript message to render as the same slim divider banner as `/compact`, handoff, and snapcompact (`⑂ branch · ctrl+o`, summary revealed on expand) instead of a `[branch]` box. +- Optimized network traffic by stripping tool descriptions from provider tool schemas +- Renamed the prompt setting from `repeatToolDescriptions` / "Repeat Tool Descriptions" to `inlineToolDescriptors` / "Inline Tool Descriptors" and enabled inline descriptors by default. +- Snapcompact compaction summaries now reach the model as ordered history blocks instead of one lead-in text block plus appended images: plain text at the oldest edge, an imaged middle, then plain text at the newest edge. This matches the new text-first snapcompact archive layout and preserves chronological order in the provider prompt. +- Fixed `/dump` output repeating the tool inventory twice when `inlineToolDescriptors` is enabled. +- Unified TUI border corners on the rounded style: tool-result frames, overlays, code fences, debug frames, and the interactive bash box now draw rounded corners (`╭╮╰╯`) to match the editor and message cards, instead of mixing rounded boxes with sharp (`┌┐└┘`) ones. `boxRound` now carries the sharp tee/cross junction glyphs (no rounded variant exists), so dividers still honor `boxSharp.tee*`/`cross` theme overrides. Markdown tables intentionally keep the fully sharp `boxSharp` set; its corner tokens now affect tables only. + +## [16.0.11] - 2026-06-19 + +### Added + +- Added `__advisor.jsonl` transcript persistence for advisor model usage attribution and visibility in the Agent Hub +- Added defensive reservation against naming a task `__advisor` to prevent filesystem collisions with internal transcripts +- Added `mode` property to the `CompactOptions` interface for extension-based compaction control +- Updated `/compact` slash command to support `soft`, `remote`, and `snapcompact` subcommands for per-run strategy overrides +- Added `/compact` mode subcommands so a manual compaction can override the configured `compaction.strategy`/`remoteEnabled` for that one run: `/compact soft` (summarize locally, skip remote endpoints), `/compact remote` (summarize via the remote endpoint / provider-native compaction; warns and falls back to a local summary when no remote path is available), and `/compact snapcompact` (archive history onto dense bitmap images, no LLM call). Bare `/compact` and `/compact ` keep their existing behavior; `soft`/`remote` still accept trailing focus instructions, `snapcompact` rejects them. The subcommands are advertised to ACP clients and surface in TUI autocomplete. Extensions can pass the same selection via `compact(instructions, { mode })`. +- The advisor now persists its own turns to a subagent-style transcript (`/__advisor.jsonl`) so the advisor model's usage is attributed in `omp stats` and its transcript is observable in the Agent Hub (read-only). The transcript follows session switches (`/new`, resume, branch) and is drained/released before a `/drop` deletes the old artifacts dir. The advisor remains a non-peer: it is hidden from agent-facing rosters (`irc` list, `history://`, subagent peer prompt, broadcast targets), is not messageable (`irc send` / collab chat), and is not revivable/killable from the Hub or collab. + +### Changed + +- Standardized file elision markers to a compact `[…Nln elided…]` format +- Updated transcript viewer to clear cached content if the session file is deleted while opened +- Refactored agent hub to use a fullscreen overlay for agent transcripts instead of inline chat +- Improved transcript rendering stability by using a non-incremental builder for viewer replays +- Advisor transcripts are now excluded from agent-facing surfaces like `irc`, `history://`, and peer rosters +- Advisor transcripts are read-only and cannot be messaged, revived, or killed via the Agent Hub or IRC +- Refined `/compact` argument parsing to reject focus instructions for modes that do not support them (e.g., `snapcompact`) +- Protocol hosts (RPC/`rpc-ui`/ACP) now host-default the full advisor settings group — `advisor.syncBacklog` and `advisor.immuneTurns` in addition to `advisor.enabled`/`advisor.subagents` — so a host that opts the advisor in gets the default tuning instead of inheriting the user's local advisor preferences. + +### Fixed + +- Fixed memory-leaking stale transcripts in the agent viewer when underlying files are deleted +- Fixed the Agent Hub transcript viewer rendering the transcript body one column right of the "Agent Hub" title (and the title appearing to shift when scrolled to the top): the fullscreen viewer added its own outer gutter on top of the transcript rows, which already carry a 1-column left pad, so the header and body no longer shared a gutter. The viewer now renders the scroll body at full width without the extra gutter, and the file-mention row carries the same 1-column pad as every other row. +- Fixed the bash tool failing with `pi-natives:command: syntax error at end of input` on a valid `&&`/`;` chain whose later pipeline stage is a compound command, e.g. `echo x && git log | while read h; do …; done | head`. The minimizer's segmented-chain runner rebuilds each chain segment from the brush AST via `pipeline.to_string()` and re-executes that string, but `simple_segment` only validated the *first* pipeline stage — so a compound later stage (`while`/`for`/`if`/subshell) was re-serialized without its terminator and re-run as broken shell. Every stage is now required to be a Display-safe simple command, and — as a general guard against the recurring class of brush `Display` round-trip divergences (previously: quoted here-doc close tags, multi-byte char/byte offsets) — each reconstructed segment is now re-parsed and must match the original pipeline shape before the chain runner executes it; any divergence runs the command whole, unsegmented, instead of corrupting it. +- Fixed `Ctrl+T` (toggle thinking blocks) and the `/settings` "Hide Thinking Blocks" toggle only collapsing/expanding thinking in the live region: blocks that had scrolled into committed native scrollback on ED3-risk terminals kept their pre-toggle snapshot, so scrolling up showed the old thinking state. Both paths now `resetDisplay()` after flipping each block's flag, forcing a full clear + replay of the whole transcript (matching the tool-output expansion toggle) so every block above the fold re-renders at its new height. +- Fixed ACP mobile voice settings being unable to call `speech.models.list` by exposing the local STT/TTS model and voice catalog without triggering setup or downloads ([#3011](https://github.com/can1357/oh-my-pi/issues/3011)). +- Fixed the collapsed inline arg preview used by tools without a custom renderer (e.g. `advise`, MCP tools) truncating every value at a fixed 24 columns, so a long note was cut to `note="Your “stric…"` even on a wide card with empty space to spare. Each value now grows into the width the card actually has, reserving only a small slice for the keys that still follow so a long leading value can't hide them. + +### Removed + +- Removed `display.tabWidth` setting and configurable tab width support + +## [16.0.10] - 2026-06-18 + +### Added + +- Updated the `pi` option of the `tools.format` setting to use the new compact sigil-delimited owned tool-calling dialect (`§`/`«…»`/`¤`/`‡‡`) that uses ~46% fewer tokens than the legacy format on typical calls. +- Integrated terminal QR codes directly into `/collab` and `/collab view` to display both deep links and scannable codes, and added a `collab.webUrl` setting for separately hosted collab web clients. + +### Changed + +- Updated description of Pi dialect in tool-calling settings to clarify compact sigil format + +### Fixed + +- Fixed legacy `settings.json`, `models.json`, and `keybindings.json` migration/loading to accept comments in JSON config files. +- Support quoted file paths with spaces (such as default macOS screenshots) in file mentions and `@`-prefixed CLI arguments ([#2909](https://github.com/can1357/oh-my-pi/pull/2909)). +- Fixed the first steering/follow-up message typed as auto-compaction begins being dropped instead of queued; the compaction abort controller (which backs `isCompacting`) is now installed before the `auto_compaction_start` event fires, so input routed during that window lands in the compaction queue rather than the core agent queue. +- Fixed queued steering/follow-up messages being silently cleared by the agent reset during a handoff; they are now captured and restored across the new-session reset. +- Fixed the `/login` provider list still offering providers listed in `disabledProviders` (and their credential-alias logins, e.g. `openai-codex-device` → `openai-codex`) as login targets ([#2906](https://github.com/can1357/oh-my-pi/pull/2906)). +- Fixed an owned MCP manager not being disconnected on `AgentSession.dispose()`, orphaning stdio MCP subprocesses across session teardown (`/exit`, print mode, subagent teardown); the dispose-time disconnect is now bounded so a slow HTTP/SSE transport close cannot stall exit ([#2839](https://github.com/can1357/oh-my-pi/pull/2839)). +- Fixed `inspect_image` resolving pasted image labels such as `Image #1`, `[Image #1, WxH]`, and `attachment://1` against current chat attachments instead of falling back to `/Image #1` ([#2787](https://github.com/can1357/oh-my-pi/issues/2787)). +- Accepted `max` as an alias for the top thinking level (`xhigh`) in selectors and `--thinking`, so DeepSeek V4 Pro can be selected with its provider-facing maximum effort ([#2727](https://github.com/can1357/oh-my-pi/issues/2727)). +- Fixed `--provider=amazon-bedrock --model ` being rejected as an unknown model before the Bedrock provider could send the ARN to Converse Stream. ([#3004](https://github.com/can1357/oh-my-pi/issues/3004)) + ## [16.0.9] - 2026-06-18 ### Added diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index c13dfdea5..e137954b6 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-coding-agent", - "version": "16.0.9", + "version": "16.1.1", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/coding-agent/src/advisor/index.ts b/packages/coding-agent/src/advisor/index.ts index 30dd105e5..3a7362afb 100644 --- a/packages/coding-agent/src/advisor/index.ts +++ b/packages/coding-agent/src/advisor/index.ts @@ -1,3 +1,4 @@ export * from "./advise-tool"; export * from "./runtime"; +export * from "./transcript-recorder"; export * from "./watchdog"; diff --git a/packages/coding-agent/src/advisor/transcript-recorder.ts b/packages/coding-agent/src/advisor/transcript-recorder.ts new file mode 100644 index 000000000..26b03d78e --- /dev/null +++ b/packages/coding-agent/src/advisor/transcript-recorder.ts @@ -0,0 +1,136 @@ +import * as path from "node:path"; +import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; +import type { Message, UserMessage } from "@oh-my-pi/pi-ai"; +import { logger } from "@oh-my-pi/pi-utils"; +import { SessionManager } from "../session/session-manager"; + +/** + * Reserved transcript stem for advisor session files. Chosen so it cannot + * collide with a task subagent's `.jsonl` (task ids are reserved against + * this exact stem in {@link AgentOutputManager}). + */ +export const ADVISOR_TRANSCRIPT_STEM = "__advisor"; +export const ADVISOR_TRANSCRIPT_FILENAME = `${ADVISOR_TRANSCRIPT_STEM}.jsonl`; + +const JSONL_SUFFIX = ".jsonl"; + +/** + * Append-only persister for an advisor agent's transcript. + * + * The advisor is a passive reviewer with its own model usage, so — like a task + * subagent — its turns are written to a JSONL inside the owning session's + * artifacts dir (`/__advisor.jsonl`, `//__advisor.jsonl` + * for subagent advisors). That single file gives the advisor model proper usage + * attribution in `omp stats` (the stats parser scans the session dir + * recursively) and a read-only transcript in the Agent Hub, without making the + * advisor a registered, messageable peer. + * + * The target is derived from the *session file* (`getSessionFile()`), never + * `getArtifactsDir()` — subagents adopt the parent's artifact manager, so the + * artifacts dir points at the parent root and every subagent advisor would + * collide. The file path is resolved synchronously when a message finalizes and + * captured for the queued write, so a `/new`, resume, or session switch in + * flight can never misattribute an old advisor turn into the new session's file. + * On such a switch the previous writer is closed and the new file opened on the + * next recorded turn. The recorder never truncates: the advisor's in-memory + * context resets/compacts independently, but every billed turn is appended here. + */ +export class AdvisorTranscriptRecorder { + #manager: SessionManager | undefined; + #file: string | undefined; + /** Serializes the async open/close against synchronous appends so records land in order. */ + #queue: Promise; + + /** + * @param after Optional barrier the queue starts behind — used on the advisor + * on→off→on toggle so a fresh recorder's first `open` waits for the prior + * recorder's `close` and the two never hold the same `__advisor.jsonl` at once. + */ + constructor( + private readonly resolveSessionFile: () => string | undefined, + private readonly resolveCwd: () => string, + after?: Promise, + ) { + this.#queue = after + ? after.then( + () => {}, + () => {}, + ) + : Promise.resolve(); + } + + /** + * Persist one finalized advisor message. Assistant turns carry the usage the + * stats parser reads; tool results round out the Hub transcript; user deltas + * (the advisor's "session update" prompts) are persisted but flagged + * `synthetic`/agent-attributed so they never inflate user-message metrics. + * Non-conversational message kinds are skipped. + */ + record(message: AgentMessage): void { + let persisted: Message; + switch (message.role) { + case "assistant": + case "toolResult": + persisted = message; + break; + case "user": + // Clone so the live advisor message stays untouched; mark synthetic so + // stats' user-message metrics skip these agent-internal review prompts. + persisted = { ...(message as UserMessage), synthetic: true, attribution: "agent" }; + break; + default: + return; + } + const sessionFile = this.resolveSessionFile(); + if (!sessionFile?.endsWith(JSONL_SUFFIX)) return; + const file = path.join(sessionFile.slice(0, -JSONL_SUFFIX.length), ADVISOR_TRANSCRIPT_FILENAME); + const cwd = this.resolveCwd(); + this.#enqueue(async () => { + if (file !== this.#file) { + await this.#closeManager(); + this.#manager = await SessionManager.open(file, undefined, undefined, { + initialCwd: cwd, + suppressBreadcrumb: true, + }); + this.#file = file; + } + this.#manager?.appendMessage(persisted); + }); + } + + /** Flush pending writes (best-effort). */ + flush(): Promise { + return this.#enqueueResult(async () => { + if (this.#manager) await this.#manager.flush(); + }); + } + + /** Flush and close the writer, releasing the session file. */ + close(): Promise { + return this.#enqueueResult(() => this.#closeManager()); + } + + async #closeManager(): Promise { + const manager = this.#manager; + this.#manager = undefined; + this.#file = undefined; + if (!manager) return; + try { + await manager.close(); + } catch (err) { + logger.debug("advisor transcript close failed", { err: String(err) }); + } + } + + #enqueue(work: () => Promise): void { + this.#queue = this.#queue.then(work, work).catch(err => { + logger.debug("advisor transcript record failed", { err: String(err) }); + }); + } + + #enqueueResult(work: () => Promise): Promise { + const next = this.#queue.then(work, work); + this.#queue = next.catch(() => {}); + return next; + } +} diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index b6aee338d..3e000658a 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -214,7 +214,13 @@ export function parseArgs(inputArgs: string[], extensionFlags?: Map 1) { + filePath = filePath.slice(1, -1); + } else if (filePath.startsWith("'") && filePath.endsWith("'") && filePath.length > 1) { + filePath = filePath.slice(1, -1); + } + result.fileArgs.push(filePath); } else if (!arg.startsWith("-") || arg === "-") { // Plain positional or lone `-` (stdin marker) — pass through as a // message rather than flagging it. diff --git a/packages/coding-agent/src/cli/stats-cli.ts b/packages/coding-agent/src/cli/stats-cli.ts index cd3e4705f..31177ab71 100644 --- a/packages/coding-agent/src/cli/stats-cli.ts +++ b/packages/coding-agent/src/cli/stats-cli.ts @@ -4,6 +4,7 @@ * Handles `omp stats` subcommand for viewing AI usage statistics. */ +import { truncateToWidth } from "@oh-my-pi/pi-tui/utils"; import { APP_NAME, formatDuration, formatNumber, formatPercent } from "@oh-my-pi/pi-utils"; import chalk from "chalk"; import { openPath } from "../utils/open"; @@ -32,7 +33,7 @@ function createSyncProgressReporter(): { const counter = chalk.cyan(`[${event.current}/${event.total}]`); const line = `${counter} ${pct}% ${label}`; const columns = stream.columns ?? 120; - const trimmed = truncateToColumns(line, columns - 1); + const trimmed = truncateToWidth(line, columns - 1); stream.write(`\r${trimmed.padEnd(lastWidth)}`); lastWidth = trimmed.length; }, @@ -50,16 +51,6 @@ function shortenSessionFile(p: string): string { return idx >= 0 ? p.slice(idx + marker.length) : p; } -function truncateToColumns(s: string, max: number): string { - if (max <= 0) return ""; - const width = Bun.stringWidth(s, { countAnsiEscapeCodes: false }); - if (width <= max) return s; - // Cheap right-trim with an ellipsis - we don't need ANSI-aware slicing - // because the colored prefix is short and the truncated tail is the - // dim filename, where dropping bytes is fine. - return `${s.slice(0, Math.max(0, max - 1))}\u2026`; -} - // ============================================================================= // Types // ============================================================================= diff --git a/packages/coding-agent/src/collab/host.ts b/packages/coding-agent/src/collab/host.ts index 55850839a..5f6aac84e 100644 --- a/packages/coding-agent/src/collab/host.ts +++ b/packages/coding-agent/src/collab/host.ts @@ -17,7 +17,7 @@ import { logger } from "@oh-my-pi/pi-utils"; import type { BusChannel, AgentEvent as WireAgentEvent, SessionEntry as WireSessionEntry } from "@oh-my-pi/pi-wire"; import type { InteractiveModeContext } from "../modes/types"; import { AgentLifecycleManager } from "../registry/agent-lifecycle"; -import { AgentRegistry } from "../registry/agent-registry"; +import { type AgentRef, AgentRegistry } from "../registry/agent-registry"; import type { AgentSessionEvent } from "../session/agent-session"; import { stripImagesFromMessage, USER_INTERRUPT_LABEL } from "../session/messages"; import type { SessionEntry as StoredSessionEntry } from "../session/session-entries"; @@ -133,7 +133,7 @@ export class CollabHost { return this.#link; } - /** Browser deep link (`https:///#`) — the relay serves the web client at `/`. */ + /** Browser deep link for the configured collab web UI. */ get webLink(): string { return this.#webLink; } @@ -156,15 +156,15 @@ export class CollabHost { return list; } - async start(relayUrl: string): Promise { + async start(relayUrl: string, webUrl = ""): Promise { const rawKey = generateRoomKey(); const writeToken = generateWriteToken(); const roomId = generateRoomId(); this.#writeToken = writeToken; this.#link = formatCollabLink(relayUrl, roomId, rawKey, writeToken); - this.#webLink = formatCollabWebLink(relayUrl, roomId, rawKey, writeToken); + this.#webLink = formatCollabWebLink(relayUrl, roomId, rawKey, writeToken, webUrl); this.#viewLink = formatCollabLink(relayUrl, roomId, rawKey); - this.#webViewLink = formatCollabWebLink(relayUrl, roomId, rawKey); + this.#webViewLink = formatCollabWebLink(relayUrl, roomId, rawKey, undefined, webUrl); const parsed = parseCollabLink(this.#link); if ("error" in parsed) throw new Error(parsed.error); const key = await importRoomKey(rawKey); @@ -445,18 +445,24 @@ export class CollabHost { } #snapshotAgents(): AgentSnapshot[] { - return AgentRegistry.global() - .list() - .map(ref => ({ - id: ref.id, - displayName: ref.displayName, - kind: ref.kind, - parentId: ref.parentId, - status: ref.status, - hasSessionFile: !!ref.sessionFile, - createdAt: ref.createdAt, - lastActivity: ref.lastActivity, - })); + return ( + AgentRegistry.global() + .list() + // Advisor transcripts are local observability only; never mirror them to + // guests (the wire AgentSnapshot kind has no `advisor`, and guests must not + // be able to chat/kill/revive them). + .filter((ref): ref is AgentRef & { kind: "main" | "sub" } => ref.kind !== "advisor") + .map(ref => ({ + id: ref.id, + displayName: ref.displayName, + kind: ref.kind, + parentId: ref.parentId, + status: ref.status, + hasSessionFile: !!ref.sessionFile, + createdAt: ref.createdAt, + lastActivity: ref.lastActivity, + })) + ); } #scheduleAgentsBroadcast(): void { @@ -472,6 +478,12 @@ export class CollabHost { this.#rejectReadOnly("agent control", fromPeer); return; } + // Advisor refs are excluded from snapshots, but reject control by id defensively: + // a stale/malicious client must never chat/kill/revive a read-only advisor transcript. + if (AgentRegistry.global().get(agentId)?.kind === "advisor") { + this.#socket?.send({ t: "error", message: `agent ${agentId}: advisor transcripts are read-only` }, fromPeer); + return; + } const fail = (err: unknown) => { logger.warn("collab agent-cmd failed", { cmd, agentId, error: String(err) }); this.#socket?.send({ t: "error", message: `agent ${agentId}: ${String(err)}` }, fromPeer); diff --git a/packages/coding-agent/src/collab/protocol.ts b/packages/coding-agent/src/collab/protocol.ts index 7b6513511..26cace5a2 100644 --- a/packages/coding-agent/src/collab/protocol.ts +++ b/packages/coding-agent/src/collab/protocol.ts @@ -116,6 +116,10 @@ const BARE_LINK_RE = /^([A-Za-z0-9_-]{10,64})[#.]([A-Za-z0-9_-]+)$/; const B64URL_RE = /^[A-Za-z0-9_-]+$/; const LOCAL_HOSTNAMES: Record = { localhost: true, "127.0.0.1": true, "::1": true, "[::1]": true }; +function isLocalHostname(hostname: string): boolean { + return LOCAL_HOSTNAMES[hostname] === true; +} + export function generateRoomId(): string { const bytes = new Uint8Array(ROOM_ID_BYTES); crypto.getRandomValues(bytes); @@ -143,7 +147,7 @@ function normalizeRelayOrigin(relayUrl: string): { origin: string } | { error: s default: return { error: `Unsupported relay URL scheme: ${url.protocol}` }; } - if (scheme === "ws:" && !LOCAL_HOSTNAMES[url.hostname]) { + if (scheme === "ws:" && !isLocalHostname(url.hostname)) { return { error: "relay link must be wss:// (plain ws:// is only allowed for localhost)" }; } const port = url.port ? `:${url.port}` : ""; @@ -178,24 +182,48 @@ export function formatCollabLink(relayUrl: string, roomId: string, key: Uint8Arr return `${compact}/r/${roomId}.${keyText}`; } +function normalizeCollabWebBaseUrl(relayUrl: string, webUrl?: string): string { + const explicitWebUrl = webUrl?.trim(); + if (!explicitWebUrl) { + const normalized = normalizeRelayOrigin(relayUrl); + if ("error" in normalized) throw new Error(normalized.error); + return normalized.origin.startsWith("wss://") + ? `https://${normalized.origin.slice("wss://".length)}` + : `http://${normalized.origin.slice("ws://".length)}`; + } + + let url: URL; + try { + url = new URL(explicitWebUrl); + } catch { + throw new Error("collab.webUrl must start with http:// or https://"); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("collab.webUrl must start with http:// or https://"); + } + if (url.protocol === "http:" && !isLocalHostname(url.hostname)) { + throw new Error("collab.webUrl must use https:// unless it targets localhost"); + } + if (url.search || url.hash) { + throw new Error("collab.webUrl must not include a query string or fragment"); + } + const path = url.pathname.replace(/\/+$/, ""); + return `${url.origin}${path}`; +} + /** - * Render the browser deep link: `http(s):///#`. The - * relay serves the web client at `/`, and the whole collab link (including the - * room key) rides in the fragment, so it never appears in any HTTP request. - * Terminals auto-link the https form, making it click-to-join. + * Render the browser deep link. The browser UI may be hosted separately from + * the relay; the fragment always carries the relay-specific collab link, so + * room secrets stay out of HTTP path and query bytes. */ export function formatCollabWebLink( relayUrl: string, roomId: string, key: Uint8Array, writeToken?: Uint8Array, + webUrl?: string, ): string { - const normalized = normalizeRelayOrigin(relayUrl); - if ("error" in normalized) throw new Error(normalized.error); - const httpOrigin = normalized.origin.startsWith("wss://") - ? `https://${normalized.origin.slice("wss://".length)}` - : `http://${normalized.origin.slice("ws://".length)}`; - return `${httpOrigin}/#${formatCollabLink(relayUrl, roomId, key, writeToken)}`; + return `${normalizeCollabWebBaseUrl(relayUrl, webUrl)}/#${formatCollabLink(relayUrl, roomId, key, writeToken)}`; } export function parseCollabLink(link: string): ParsedCollabLink | { error: string } { @@ -213,15 +241,20 @@ export function parseCollabLink(link: string): ParsedCollabLink | { error: strin } catch { return { error: `Invalid collab link: ${link}` }; } + if ((url.protocol === "http:" || url.protocol === "https:") && url.hash) { + const inner = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash; + const parsed = parseCollabLink(inner); + if (!("error" in parsed)) return parsed; + } const normalized = normalizeRelayOrigin(url.origin); if ("error" in normalized) return normalized; const match = ROOM_PATH_RE.exec(url.pathname); if (!match) { - // Web deep link: `http(s):///#` — the fragment holds - // the whole link, so recurse on it. The recursion terminates because - // the inner text is a strict suffix of the input. + // Non-http(s) deep links may also carry a complete collab link in the + // fragment. http(s) links are handled once above so invalid fragments + // fall through to direct relay validation instead of double-recursing. const inner = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash; - if (inner) return parseCollabLink(inner); + if (inner && url.protocol !== "http:" && url.protocol !== "https:") return parseCollabLink(inner); return { error: "Collab link must contain a /r/ path" }; } const roomId = match[1]!; diff --git a/packages/coding-agent/src/commit/agentic/agent.ts b/packages/coding-agent/src/commit/agentic/agent.ts index ca76ca3ca..98b4c8867 100644 --- a/packages/coding-agent/src/commit/agentic/agent.ts +++ b/packages/coding-agent/src/commit/agentic/agent.ts @@ -1,7 +1,8 @@ -import { INTENT_FIELD, type ThinkingLevel } from "@oh-my-pi/pi-agent-core"; +import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Api, Model } from "@oh-my-pi/pi-ai"; import { Markdown } from "@oh-my-pi/pi-tui"; import { prompt } from "@oh-my-pi/pi-utils"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import chalk from "chalk"; import typesDescriptionPrompt from "../../commit/prompts/types-description.md" with { type: "text" }; import type { ModelRegistry } from "../../config/model-registry"; diff --git a/packages/coding-agent/src/commit/agentic/tools/git-file-diff.ts b/packages/coding-agent/src/commit/agentic/tools/git-file-diff.ts index f523a4fa3..9333da3c9 100644 --- a/packages/coding-agent/src/commit/agentic/tools/git-file-diff.ts +++ b/packages/coding-agent/src/commit/agentic/tools/git-file-diff.ts @@ -87,7 +87,7 @@ function truncateDiffContent(diff: string): { content: string; truncated: boolea const truncatedCount = lines.length - KEEP_HEAD_LINES - KEEP_TAIL_LINES; return { - content: [...head, `\n... (truncated ${truncatedCount} lines) ...\n`, ...tail].join("\n"), + content: [...head, `\n[…${truncatedCount}ln elided…]\n`, ...tail].join("\n"), truncated: true, }; } @@ -117,7 +117,7 @@ function processDiffs(files: string[], diffs: Map): { result: st } content = truncated; if (content.length > remaining) { - content = `${content.slice(0, remaining)}\n... (diff truncated due to size) ...`; + content = `${content.slice(0, remaining)}\n[…${content.length - remaining}ch elided…]`; if (!truncatedFiles.includes(file)) { truncatedFiles.push(file); } diff --git a/packages/coding-agent/src/commit/changelog/index.ts b/packages/coding-agent/src/commit/changelog/index.ts index 5260db181..5374ab79e 100644 --- a/packages/coding-agent/src/commit/changelog/index.ts +++ b/packages/coding-agent/src/commit/changelog/index.ts @@ -138,7 +138,7 @@ export async function applyChangelogProposals({ function truncateDiff(diff: string, maxChars: number): string { if (diff.length <= maxChars) return diff; - return `${diff.slice(0, maxChars)}\n... (truncated)`; + return `${diff.slice(0, maxChars)}\n[…${diff.length - maxChars}ch elided…]`; } function formatExistingEntries(entries: Record): string { diff --git a/packages/coding-agent/src/commit/map-reduce/map-phase.ts b/packages/coding-agent/src/commit/map-reduce/map-phase.ts index a263bfead..7c268baf3 100644 --- a/packages/coding-agent/src/commit/map-reduce/map-phase.ts +++ b/packages/coding-agent/src/commit/map-reduce/map-phase.ts @@ -126,7 +126,7 @@ function generateContextHeader(files: FileDiff[], currentFile: string): string { } if (toShow.length < sorted.length) { - lines.push(`... and ${sorted.length - toShow.length} more files`); + lines.push(`[…${sorted.length - toShow.length} files elided…]`); } return lines.join("\n"); diff --git a/packages/coding-agent/src/commit/map-reduce/utils.ts b/packages/coding-agent/src/commit/map-reduce/utils.ts index c12860323..7948df9c7 100644 --- a/packages/coding-agent/src/commit/map-reduce/utils.ts +++ b/packages/coding-agent/src/commit/map-reduce/utils.ts @@ -5,5 +5,5 @@ export function estimateTokens(text: string): number { export function truncateToTokenLimit(text: string, maxTokens: number): string { const maxChars = maxTokens * 4; if (text.length <= maxChars) return text; - return `${text.slice(0, maxChars)}\n... (truncated)`; + return `${text.slice(0, maxChars)}\n[…${text.length - maxChars}ch elided…]`; } diff --git a/packages/coding-agent/src/config/config-file.ts b/packages/coding-agent/src/config/config-file.ts index 7769350f6..018d25c8f 100644 --- a/packages/coding-agent/src/config/config-file.ts +++ b/packages/coding-agent/src/config/config-file.ts @@ -40,7 +40,7 @@ function migrateJsonToYml(jsonPath: string, ymlPath: string) { } const content = fs.readFileSync(jsonPath, "utf-8"); - const parsed = JSON.parse(content); + const parsed = JSONC.parse(content); if (!parsed) { logger.warn("migrateJsonToYml: invalid json structure", { path: jsonPath }); migratedPaths.add(key); diff --git a/packages/coding-agent/src/config/keybindings.ts b/packages/coding-agent/src/config/keybindings.ts index fe339862a..5ad80c670 100644 --- a/packages/coding-agent/src/config/keybindings.ts +++ b/packages/coding-agent/src/config/keybindings.ts @@ -10,7 +10,7 @@ import { KeybindingsManager as TuiKeybindingsManager, } from "@oh-my-pi/pi-tui"; import { getAgentDir, isEnoent, logger } from "@oh-my-pi/pi-utils"; -import { YAML } from "bun"; +import { JSONC, YAML } from "bun"; /** * Application-level keybindings (coding agent specific). @@ -381,7 +381,7 @@ function loadRawConfig(filePath: string): unknown { try { const content = fs.readFileSync(filePath, "utf-8"); if (filePath.endsWith(".json")) { - return JSON.parse(content); + return JSONC.parse(content); } if (filePath.endsWith(".yml") || filePath.endsWith(".yaml")) { return YAML.parse(content); diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index f720b11ff..f12884945 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -563,10 +563,16 @@ function finalizeCustomModel(model: CustomModelOverlay, options: CustomModelBuil } as ModelSpec); } -function normalizeSuppressedSelector(selector: string): string { +function normalizeSuppressedSelector( + selector: string, + hasLiveModel?: (provider: string, id: string) => boolean, +): string { const trimmed = selector.trim(); if (!trimmed) return trimmed; - const parsed = parseModelString(trimmed); + const parsed = parseModelString(trimmed, { + allowMaxAlias: true, + isLiteralModelId: (provider, id) => hasLiveModel?.(provider, id) === true, + }); if (!parsed) return trimmed; // Retired effort-tier variant ids normalize to their collapsed logical id // so persisted suppressions keyed by raw member ids still bind. @@ -2155,14 +2161,20 @@ export class ModelRegistry { * Suppress a specific model selector (e.g., "provider/id") until a specific timestamp. */ suppressSelector(selector: string, untilMs: number): void { - this.#suppressedSelectors.set(normalizeSuppressedSelector(selector), untilMs); + this.#suppressedSelectors.set( + normalizeSuppressedSelector(selector, (provider, id) => this.find(provider, id) !== undefined), + untilMs, + ); } /** * Check if a model selector is currently suppressed due to rate limits. */ isSelectorSuppressed(selector: string): boolean { - const normalizedSelector = normalizeSuppressedSelector(selector); + const normalizedSelector = normalizeSuppressedSelector( + selector, + (provider, id) => this.find(provider, id) !== undefined, + ); const suppressedUntil = this.#suppressedSelectors.get(normalizedSelector); if (!suppressedUntil) return false; if (suppressedUntil <= Date.now()) { diff --git a/packages/coding-agent/src/config/model-resolver.ts b/packages/coding-agent/src/config/model-resolver.ts index 134d80bcd..74048d715 100644 --- a/packages/coding-agent/src/config/model-resolver.ts +++ b/packages/coding-agent/src/config/model-resolver.ts @@ -72,6 +72,21 @@ export interface ScopedModel { explicitThinkingLevel: boolean; } +interface ThinkingSuffixOptions { + allowMaxAlias?: boolean; +} + +interface ModelStringParseOptions extends ThinkingSuffixOptions { + isLiteralModelId?: (provider: string, id: string) => boolean; +} +const MAX_THINKING_SUFFIX_OPTIONS: ThinkingSuffixOptions = { allowMaxAlias: true }; + +function parseThinkingSuffix(value: string, options?: ThinkingSuffixOptions): ThinkingLevel | undefined { + const level = parseThinkingLevel(value); + if (level !== undefined) return level; + return options?.allowMaxAlias === true && value === "max" ? ThinkingLevel.XHigh : undefined; +} + /** * Split a trailing `:` thinking selector off a model pattern. * @@ -81,27 +96,89 @@ export interface ScopedModel { * role-alias callers pass `PREFIX_MODEL_ROLE.length` so the base is at least * as long as the `pi/` prefix. */ -function splitThinkingSuffix(pattern: string, minColonIndex = -1): { base: string; level?: ThinkingLevel } { +function splitThinkingSuffix( + pattern: string, + minColonIndex = -1, + options?: ThinkingSuffixOptions, +): { base: string; level?: ThinkingLevel } { const colonIdx = pattern.lastIndexOf(":"); if (colonIdx <= minColonIndex) return { base: pattern }; - const level = parseThinkingLevel(pattern.slice(colonIdx + 1)); + const level = parseThinkingSuffix(pattern.slice(colonIdx + 1), options); return level ? { base: pattern.slice(0, colonIdx), level } : { base: pattern }; } +function hasExactModelPattern(pattern: string, availableModels: readonly Model[]): boolean { + const normalized = pattern.toLowerCase(); + return availableModels.some( + model => model.id.toLowerCase() === normalized || `${model.provider}/${model.id}`.toLowerCase() === normalized, + ); +} + +function matchingGlobModels(pattern: string, availableModels: readonly Model[]): Model[] { + const glob = new Bun.Glob(pattern.toLowerCase()); + return availableModels.filter(model => { + const fullId = `${model.provider}/${model.id}`; + return glob.match(fullId.toLowerCase()) || glob.match(model.id.toLowerCase()); + }); +} + +function resolveGlobScopePattern( + pattern: string, + availableModels: readonly Model[], +): { models: Model[]; thinkingLevel?: ThinkingLevel; explicitThinkingLevel: boolean } { + const strictSuffix = splitThinkingSuffix(pattern); + if (strictSuffix.level !== undefined) { + return { + models: matchingGlobModels(strictSuffix.base, availableModels), + thinkingLevel: strictSuffix.level, + explicitThinkingLevel: true, + }; + } + + const maxSuffix = splitThinkingSuffix(pattern, -1, MAX_THINKING_SUFFIX_OPTIONS); + if (maxSuffix.level !== undefined) { + const literalMatches = matchingGlobModels(pattern, availableModels); + if (literalMatches.length > 0) { + return { models: literalMatches, thinkingLevel: undefined, explicitThinkingLevel: false }; + } + return { + models: matchingGlobModels(maxSuffix.base, availableModels), + thinkingLevel: maxSuffix.level, + explicitThinkingLevel: true, + }; + } + + return { + models: matchingGlobModels(pattern, availableModels), + thinkingLevel: undefined, + explicitThinkingLevel: false, + }; +} + /** * Parse a model string in "provider/modelId" format. * Returns undefined if the format is invalid. */ export function parseModelString( modelStr: string, + options?: ModelStringParseOptions, ): { provider: string; id: string; thinkingLevel?: ThinkingLevel } | undefined { const slashIdx = modelStr.indexOf("/"); if (slashIdx <= 0) return undefined; const id = modelStr.slice(slashIdx + 1); const provider = modelStr.slice(0, slashIdx); - // Strip valid thinking level suffix (e.g., "claude-sonnet-4-6:high" -> id "claude-sonnet-4-6", thinkingLevel "high") - const { base, level } = splitThinkingSuffix(id); - return level ? { provider, id: base, thinkingLevel: level } : { provider, id }; + // Strip strict thinking level suffixes first (e.g. "claude-sonnet-4-6:high" -> id "claude-sonnet-4-6", thinkingLevel "high"). + const strict = splitThinkingSuffix(id); + if (strict.level) return { provider, id: strict.base, thinkingLevel: strict.level }; + // `max` is a provider-facing alias for xhigh, but real model IDs can end in + // `:max`. Context-aware callers pass a literal lookup so those models win. + const maxAlias = splitThinkingSuffix(id, -1, options); + if (maxAlias.level) { + return options?.isLiteralModelId?.(provider, id) === true + ? { provider, id } + : { provider, id: maxAlias.base, thinkingLevel: maxAlias.level }; + } + return { provider, id }; } /** @@ -149,7 +226,10 @@ function getOpenRouterRouteSuffix(modelId: string): { baseId: string; suffix: st } const suffix = modelId.slice(colonIdx + 1).trim(); - if (!suffix || parseThinkingLevel(suffix)) { + // `max` is a thinking-level alias (xhigh), never an OpenRouter route suffix, so + // `openrouter/:max` falls through to the max-aware selector split instead of + // being cloned into a literal `:max` model id with the reasoning level lost. + if (!suffix || parseThinkingSuffix(suffix, MAX_THINKING_SUFFIX_OPTIONS)) { return undefined; } @@ -196,6 +276,50 @@ function cloneModelWithRequestedId(model: Model, requestedId: string): Mode }; } +const AMAZON_BEDROCK_PROVIDER = "amazon-bedrock"; +const BEDROCK_INFERENCE_PROFILE_ARN = + /^arn:aws(?:-[a-z]+)*:bedrock:[a-z0-9-]+:[0-9]*:(?:application-inference-profile|inference-profile)\/[a-z0-9][a-z0-9._:-]*$/i; + +function hasBedrockInferenceProfileThinkingSuffix(modelId: string): boolean { + const { base, level } = splitThinkingSuffix(modelId); + return level !== undefined && BEDROCK_INFERENCE_PROFILE_ARN.test(base.trim()); +} + +function resolveBedrockInferenceProfileModelId( + modelId: string, + availableModels: readonly Model[], +): Model | undefined { + const requestedId = modelId.trim(); + if (hasBedrockInferenceProfileThinkingSuffix(requestedId) || !BEDROCK_INFERENCE_PROFILE_ARN.test(requestedId)) { + return undefined; + } + + const template = availableModels.find(model => model.provider.toLowerCase() === AMAZON_BEDROCK_PROVIDER); + if (!template) return undefined; + + return buildModel({ + id: requestedId, + name: "Bedrock inference profile", + api: "bedrock-converse-stream", + provider: AMAZON_BEDROCK_PROVIDER, + baseUrl: template.baseUrl, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: null, + maxTokens: null, + }); +} + +function resolveBedrockInferenceProfileReference( + provider: string, + modelId: string, + availableModels: readonly Model[], +): Model | undefined { + if (provider.toLowerCase() !== AMAZON_BEDROCK_PROVIDER) return undefined; + return resolveBedrockInferenceProfileModelId(modelId, availableModels); +} + const UPSTREAM_ROUTING_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i; /** @@ -289,6 +413,11 @@ export function resolveProviderModelReference( } } + const bedrockInferenceProfile = resolveBedrockInferenceProfileReference(provider, modelId, availableModels); + if (bedrockInferenceProfile) { + return bedrockInferenceProfile; + } + if (normalizedProvider !== "openrouter") { return undefined; } @@ -470,6 +599,7 @@ function findExactCanonicalModelMatch( * The single model-matching engine. Tries, in order: * 1. exact `provider/id` reference (variant-alias and OpenRouter routed/date * fallbacks included), + * 2. exact canonical id (coalesces provider variants), * 3. exact bare id (preference-ranked), * 4. retired effort-tier variant alias (collapsed catalog entries), @@ -502,6 +632,11 @@ function matchModel( return pickPreferredModel(exactMatches, context); } + const bedrockInferenceProfile = resolveBedrockInferenceProfileModelId(modelPattern, availableModels); + if (bedrockInferenceProfile) { + return bedrockInferenceProfile; + } + // Retired effort-tier variant ids (bare, no provider prefix) resolve to // their collapsed logical model; models from the providers whose table // declared the alias win ties. Auto-derived `X-thinking` pairs resolve @@ -625,8 +760,10 @@ function parseModelPatternWithContext( return { model: exactMatch, thinkingLevel: undefined, warning: undefined, explicitThinkingLevel: false }; } - // No match - try stripping a valid thinking suffix and recursing - const { base, level } = splitThinkingSuffix(pattern); + // No match - try stripping a valid thinking suffix and recursing. + // `max` is accepted only after the full pattern failed, so literal model IDs + // ending in `:max` keep winning over the alias. + const { base, level } = splitThinkingSuffix(pattern, -1, MAX_THINKING_SUFFIX_OPTIONS); if (level) { const result = parseModelPatternWithContext(base, availableModels, context, options); if (result.model) { @@ -730,7 +867,11 @@ function resolveDefaultInheritedPatterns( const resolved: string[] = []; for (const pattern of normalizeModelPatternList(configuredDefault)) { - const { base: aliasCandidate, level: thinkingLevel } = splitThinkingSuffix(pattern, PREFIX_MODEL_ROLE.length); + const { base: aliasCandidate, level: thinkingLevel } = splitThinkingSuffix( + pattern, + PREFIX_MODEL_ROLE.length, + MAX_THINKING_SUFFIX_OPTIONS, + ); const aliasRole = getModelRoleAlias(aliasCandidate); if (aliasRole === role) { // Self-alias (e.g. modelRoles.default = "pi/smol") would loop back to the @@ -765,7 +906,11 @@ function resolveConfiguredRolePattern( const normalized = value.trim(); if (!normalized) return undefined; - const { base: aliasCandidate, level: thinkingLevel } = splitThinkingSuffix(normalized, PREFIX_MODEL_ROLE.length); + const { base: aliasCandidate, level: thinkingLevel } = splitThinkingSuffix( + normalized, + PREFIX_MODEL_ROLE.length, + MAX_THINKING_SUFFIX_OPTIONS, + ); const role = getModelRoleAlias(aliasCandidate); if (!role) return [normalized]; if (visited.has(role)) return undefined; @@ -888,9 +1033,19 @@ export function resolveModelRoleValue( return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning }; } +interface ExplicitThinkingSelectorOptions { + isLiteralModelId?: (provider: string, id: string) => boolean; +} + +function isLiteralModelSelector(value: string, options?: ExplicitThinkingSelectorOptions): boolean { + const parsed = parseModelString(value); + return parsed !== undefined && options?.isLiteralModelId?.(parsed.provider, parsed.id) === true; +} + export function extractExplicitThinkingSelector( value: string | undefined, settings?: Settings, + options?: ExplicitThinkingSelectorOptions, ): ThinkingLevel | undefined { if (!value) return undefined; const normalized = value.trim(); @@ -900,9 +1055,13 @@ export function extractExplicitThinkingSelector( let current = normalized; while (!visited.has(current)) { visited.add(current); - const thinkingSelector = splitThinkingSuffix(current, PREFIX_MODEL_ROLE.length).level; - if (thinkingSelector) { - return thinkingSelector; + const strictSelector = splitThinkingSuffix(current, PREFIX_MODEL_ROLE.length).level; + if (strictSelector) { + return strictSelector; + } + const maxSelector = splitThinkingSuffix(current, PREFIX_MODEL_ROLE.length, MAX_THINKING_SUFFIX_OPTIONS).level; + if (maxSelector && (current.startsWith(PREFIX_MODEL_ROLE) || !isLiteralModelSelector(current, options))) { + return maxSelector; } const expanded = expandRoleAlias(current, settings).trim(); if (!expanded || expanded === current) break; @@ -922,10 +1081,15 @@ export function resolveModelFromString( matchPreferences?: ModelMatchPreferences, modelRegistry?: CanonicalModelRegistry, ): Model | undefined { - const parsed = parseModelString(value); + const exact = available.find(model => `${model.provider}/${model.id}` === value); + if (exact) return exact; + const parsed = parseModelString(value, { + ...MAX_THINKING_SUFFIX_OPTIONS, + isLiteralModelId: (provider, id) => available.some(model => model.provider === provider && model.id === id), + }); if (parsed) { - const exact = available.find(model => model.provider === parsed.provider && model.id === parsed.id); - if (exact) return exact; + const parsedExact = available.find(model => model.provider === parsed.provider && model.id === parsed.id); + if (parsedExact) return parsedExact; } return parseModelPattern(value, available, matchPreferences, { modelRegistry }).model; } @@ -1065,7 +1229,10 @@ function resolveExactCanonicalScopePattern( modelRegistry: Pick, availableModels: Model[], ): { models: Model[]; thinkingLevel?: ThinkingLevel; explicitThinkingLevel: boolean } | undefined { - const { base: canonicalId, level: thinkingLevel } = splitThinkingSuffix(pattern); + if (pattern.endsWith(":max") && hasExactModelPattern(pattern, availableModels)) { + return undefined; + } + const { base: canonicalId, level: thinkingLevel } = splitThinkingSuffix(pattern, -1, MAX_THINKING_SUFFIX_OPTIONS); const explicitThinkingLevel = thinkingLevel !== undefined; const variants = modelRegistry @@ -1111,17 +1278,13 @@ export async function resolveModelScope( for (const pattern of patterns) { // Check if pattern contains glob characters if (pattern.includes("*") || pattern.includes("?") || pattern.includes("[")) { - // Extract optional thinking level suffix (e.g., "provider/*:high") - const { base: globPattern, level: thinkingLevel } = splitThinkingSuffix(pattern); - const explicitThinkingLevel = thinkingLevel !== undefined; - - // Match against "provider/modelId" format OR just model ID - // This allows "*sonnet*" to match without requiring "anthropic/*sonnet*" - const matchingModels = availableModels.filter(m => { - const fullId = `${m.provider}/${m.id}`; - const glob = new Bun.Glob(globPattern.toLowerCase()); - return glob.match(fullId.toLowerCase()) || glob.match(m.id.toLowerCase()); - }); + // Extract optional thinking level suffix (e.g., "provider/*:high") only + // after literal `:max` globs had a chance to match real model IDs. + const { + models: matchingModels, + thinkingLevel, + explicitThinkingLevel, + } = resolveGlobScopePattern(pattern, availableModels); if (matchingModels.length === 0) { logger.warn(`No models match pattern "${pattern}"`); @@ -1226,13 +1389,8 @@ export function filterAvailableModelsByEnabledPatterns( for (const pattern of patterns) { if (pattern.includes("*") || pattern.includes("?") || pattern.includes("[")) { - const { base: globPattern } = splitThinkingSuffix(pattern); - const glob = new Bun.Glob(globPattern.toLowerCase()); - for (const model of available) { - const fullId = `${model.provider}/${model.id}`.toLowerCase(); - if (glob.match(fullId) || glob.match(model.id.toLowerCase())) { - addAllowed(model); - } + for (const model of resolveGlobScopePattern(pattern, available).models) { + addAllowed(model); } continue; } diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 6883ca107..93bc36259 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -342,44 +342,38 @@ export const SETTINGS_SCHEMA = { }, // macOS power assertions (caffeinate flags). No-op on other platforms. - "power.preventIdleSleep": { - type: "boolean", - default: true, + "power.sleepPrevention": { + type: "enum", + values: ["off", "idle", "display", "system"] as const, + default: "idle", ui: { tab: "interaction", group: "Power (macOS)", - label: "Prevent Idle Sleep", - description: "Keep the system awake while a session is open (caffeinate -i)", - }, - }, - "power.preventSystemSleep": { - type: "boolean", - default: false, - ui: { - tab: "interaction", - group: "Power (macOS)", - label: "Prevent System Sleep on AC", - description: "Block all system sleep while on AC power (caffeinate -s)", - }, - }, - "power.declareUserActive": { - type: "boolean", - default: false, - ui: { - tab: "interaction", - group: "Power (macOS)", - label: "Declare User Active", - description: "Keep the display lit and treat the user as active (caffeinate -u)", - }, - }, - "power.preventDisplaySleep": { - type: "boolean", - default: false, - ui: { - tab: "interaction", - group: "Power (macOS)", - label: "Prevent Display Sleep", - description: "Keep the display from idle-sleeping while a session is open (caffeinate -d)", + label: "Sleep Prevention", + description: + "Prevent macOS sleep during active sessions. Each level is cumulative — it adds the flags of all lower levels.", + options: [ + { + value: "off", + label: "Off", + description: "Do not prevent any sleep", + }, + { + value: "idle", + label: "Prevent Idle Sleep", + description: "Keep the system awake while a session is open (caffeinate -i)", + }, + { + value: "display", + label: "Prevent Display Sleep", + description: "Also keep the display from idle-sleeping (caffeinate -i -d)", + }, + { + value: "system", + label: "Prevent System Sleep", + description: "Also block all system sleep on AC and declare the user active (caffeinate -i -d -s -u)", + }, + ], }, }, "advisor.enabled": { @@ -401,6 +395,7 @@ export const SETTINGS_SCHEMA = { group: "Advisor", label: "Advisor for Subagents", description: "Also enable the advisor on spawned task/eval subagents.", + condition: "advisorEnabled", }, }, "advisor.syncBacklog": { @@ -413,6 +408,7 @@ export const SETTINGS_SCHEMA = { label: "Advisor Sync Backlog", description: "Pause the main agent for up to 30 seconds if the advisor falls behind by this many turns. Off disables catch-up delays.", + condition: "advisorEnabled", }, }, "advisor.immuneTurns": { @@ -432,6 +428,7 @@ export const SETTINGS_SCHEMA = { { value: "4", label: "4 turns" }, { value: "5", label: "5 turns" }, ], + condition: "advisorEnabled", }, }, shellPath: { type: "string", default: undefined }, @@ -809,11 +806,6 @@ export const SETTINGS_SCHEMA = { description: "Remove the 1-character horizontal padding from the left and right of the terminal output", }, }, - // Display rendering - "display.tabWidth": { - type: "number", - default: 3, - }, "display.shimmer": { type: "enum", @@ -854,6 +846,17 @@ export const SETTINGS_SCHEMA = { }, }, + "display.cacheMissMarker": { + type: "boolean", + default: false, + ui: { + tab: "appearance", + group: "Display", + label: "Cache Miss Marker", + description: "Show a divider above an assistant turn whose request lost (missed) the prompt cache", + }, + }, + showHardwareCursor: { type: "boolean", default: true, // will be computed based on platform if undefined @@ -872,7 +875,7 @@ export const SETTINGS_SCHEMA = { // Reasoning and prompts defaultThinkingLevel: { type: "enum", - values: [...THINKING_EFFORTS, AUTO_THINKING], + values: [...THINKING_EFFORTS, AUTO_THINKING, "max"], default: "high", ui: { tab: "model", @@ -919,14 +922,15 @@ export const SETTINGS_SCHEMA = { }, }, - repeatToolDescriptions: { + inlineToolDescriptors: { type: "boolean", - default: false, + default: true, ui: { tab: "model", group: "Prompt", - label: "Repeat Tool Descriptions", - description: "Render full tool descriptions in the system prompt instead of a tool name list", + label: "Inline Tool Descriptors", + description: + "Render full tool descriptors in the system prompt and strip top-level/nested descriptions from provider tool schemas so descriptor text is sent once", }, }, @@ -1519,6 +1523,18 @@ export const SETTINGS_SCHEMA = { }, }, + "collab.webUrl": { + type: "string", + default: "", + ui: { + tab: "interaction", + group: "Collab", + label: "Web UI URL", + description: + "Browser UI used by /collab links; empty derives from collab.relayUrl; explicit http:// is localhost-only", + }, + }, + "collab.displayName": { type: "string", default: "", @@ -1878,7 +1894,7 @@ export const SETTINGS_SCHEMA = { { value: "anthropic", label: "Anthropic", description: "Use Anthropic-style in-band tool calls." }, { value: "deepseek", label: "DeepSeek", description: "Use DeepSeek-style in-band tool calls." }, { value: "harmony", label: "Harmony", description: "Use Harmony-style in-band tool calls." }, - { value: "pi", label: "Pi", description: "Use the Pi owned dialect." }, + { value: "pi", label: "Pi", description: "Use the Pi owned dialect (compact sigil-delimited tool calls)." }, { value: "qwen3", label: "Qwen3", description: "Use the Qwen3 owned dialect." }, { value: "gemini", label: "Gemini", description: "Use the Gemini owned dialect." }, { value: "gemma", label: "Gemma", description: "Use the Gemma owned dialect." }, diff --git a/packages/coding-agent/src/config/settings.ts b/packages/coding-agent/src/config/settings.ts index 93b1c66d7..58f0398f8 100644 --- a/packages/coding-agent/src/config/settings.ts +++ b/packages/coding-agent/src/config/settings.ts @@ -22,9 +22,8 @@ import { isEnoent, logger, procmgr, - setDefaultTabWidth, } from "@oh-my-pi/pi-utils"; -import { YAML } from "bun"; +import { JSONC, YAML } from "bun"; import { type Settings as SettingsCapabilityItem, settingsCapability } from "../capability/settings"; import type { ModelRole } from "../config/model-roles"; import { loadCapability } from "../discovery"; @@ -668,9 +667,9 @@ export class Settings { // 1. Migrate from settings.json const settingsJsonPath = path.join(this.#agentDir, "settings.json"); try { - const parsed = JSON.parse(await Bun.file(settingsJsonPath).text()); + const parsed: unknown = JSONC.parse(await Bun.file(settingsJsonPath).text()); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - settings = this.#deepMerge(settings, this.#migrateRawSettings(parsed)); + settings = this.#deepMerge(settings, this.#migrateRawSettings(parsed as RawSettings)); migrated = true; try { fs.renameSync(settingsJsonPath, `${settingsJsonPath}.bak`); @@ -910,6 +909,46 @@ export class Settings { } } + // power.preventIdleSleep / power.preventSystemSleep / power.declareUserActive + // / power.preventDisplaySleep (four booleans) → power.sleepPrevention enum. + // The enum is cumulative: each level adds the flags of all lower levels. + // Migration picks the highest level whose condition is met, scanning from + // most to least aggressive so a single enum value captures the old state. + if ( + !("sleepPrevention" in ((raw.power as Record) ?? {})) && + raw["power.sleepPrevention"] === undefined + ) { + const powerObj = raw.power as Record | undefined; + const getFlag = (key: string): boolean | undefined => { + const nested = powerObj?.[key]; + const flat = raw[`power.${key}`]; + const value = nested ?? flat; + return typeof value === "boolean" ? value : undefined; + }; + const idle = getFlag("preventIdleSleep"); + const system = getFlag("preventSystemSleep"); + const user = getFlag("declareUserActive"); + const display = getFlag("preventDisplaySleep"); + const anySet = idle !== undefined || system !== undefined || user !== undefined || display !== undefined; + if (anySet) { + const mode = system || user ? "system" : display ? "display" : idle !== false ? "idle" : "off"; + const powerRoot = (powerObj ?? {}) as Record; + powerRoot.sleepPrevention = mode; + raw.power = powerRoot; + } + // Clean up old keys (nested + flat) + if (powerObj) { + delete powerObj.preventIdleSleep; + delete powerObj.preventSystemSleep; + delete powerObj.declareUserActive; + delete powerObj.preventDisplaySleep; + } + delete raw["power.preventIdleSleep"]; + delete raw["power.preventSystemSleep"]; + delete raw["power.declareUserActive"]; + delete raw["power.preventDisplaySleep"]; + } + return raw; } @@ -1103,11 +1142,6 @@ const SETTING_HOOKS: Partial>> = { }); } }, - "display.tabWidth": value => { - if (typeof value === "number") { - setDefaultTabWidth(value); - } - }, "provider.appendOnlyContext": value => { if (typeof value === "string") { appendOnlyModeSignal.fire(value); diff --git a/packages/coding-agent/src/debug/log-viewer.ts b/packages/coding-agent/src/debug/log-viewer.ts index eb612a31c..4e45d37df 100644 --- a/packages/coding-agent/src/debug/log-viewer.ts +++ b/packages/coding-agent/src/debug/log-viewer.ts @@ -866,21 +866,21 @@ export class DebugLogViewerComponent implements Component { } #frameTop(innerWidth: number): string { - return `${theme.boxSharp.topLeft}${theme.boxSharp.horizontal.repeat(innerWidth)}${theme.boxSharp.topRight}`; + return `${theme.boxRound.topLeft}${theme.boxRound.horizontal.repeat(innerWidth)}${theme.boxRound.topRight}`; } #frameSeparator(innerWidth: number): string { - return `${theme.boxSharp.teeRight}${theme.boxSharp.horizontal.repeat(innerWidth)}${theme.boxSharp.teeLeft}`; + return `${theme.boxRound.teeRight}${theme.boxRound.horizontal.repeat(innerWidth)}${theme.boxRound.teeLeft}`; } #frameBottom(innerWidth: number): string { - return `${theme.boxSharp.bottomLeft}${theme.boxSharp.horizontal.repeat(innerWidth)}${theme.boxSharp.bottomRight}`; + return `${theme.boxRound.bottomLeft}${theme.boxRound.horizontal.repeat(innerWidth)}${theme.boxRound.bottomRight}`; } #frameLine(content: string, innerWidth: number): string { const truncated = truncateToWidth(content, innerWidth); const remaining = Math.max(0, innerWidth - visibleWidth(truncated)); - return `${theme.boxSharp.vertical}${truncated}${padding(remaining)}${theme.boxSharp.vertical}`; + return `${theme.boxRound.vertical}${truncated}${padding(remaining)}${theme.boxRound.vertical}`; } #copySelected() { diff --git a/packages/coding-agent/src/debug/raw-sse.ts b/packages/coding-agent/src/debug/raw-sse.ts index b5c406a77..f1ba280f1 100644 --- a/packages/coding-agent/src/debug/raw-sse.ts +++ b/packages/coding-agent/src/debug/raw-sse.ts @@ -273,20 +273,20 @@ export class RawSseViewerComponent implements Component { } #frameTop(innerWidth: number): string { - return `${theme.boxSharp.topLeft}${theme.boxSharp.horizontal.repeat(innerWidth)}${theme.boxSharp.topRight}`; + return `${theme.boxRound.topLeft}${theme.boxRound.horizontal.repeat(innerWidth)}${theme.boxRound.topRight}`; } #frameSeparator(innerWidth: number): string { - return `${theme.boxSharp.teeRight}${theme.boxSharp.horizontal.repeat(innerWidth)}${theme.boxSharp.teeLeft}`; + return `${theme.boxRound.teeRight}${theme.boxRound.horizontal.repeat(innerWidth)}${theme.boxRound.teeLeft}`; } #frameBottom(innerWidth: number): string { - return `${theme.boxSharp.bottomLeft}${theme.boxSharp.horizontal.repeat(innerWidth)}${theme.boxSharp.bottomRight}`; + return `${theme.boxRound.bottomLeft}${theme.boxRound.horizontal.repeat(innerWidth)}${theme.boxRound.bottomRight}`; } #frameLine(content: string, innerWidth: number): string { const truncated = truncateToWidth(content, innerWidth); const remaining = Math.max(0, innerWidth - visibleWidth(truncated)); - return `${theme.boxSharp.vertical}${truncated}${padding(remaining)}${theme.boxSharp.vertical}`; + return `${theme.boxRound.vertical}${truncated}${padding(remaining)}${theme.boxRound.vertical}`; } } diff --git a/packages/coding-agent/src/edit/file-snapshot-store.ts b/packages/coding-agent/src/edit/file-snapshot-store.ts index a1a94b0d9..3428cc2bd 100644 --- a/packages/coding-agent/src/edit/file-snapshot-store.ts +++ b/packages/coding-agent/src/edit/file-snapshot-store.ts @@ -102,7 +102,7 @@ const HASHLINE_LINE_PREFIX = /^[ *]?(\d+)(?:-(\d+))?:/; /** * The 1-indexed file lines a hashline-formatted body actually displayed. * Single `NN:` rows contribute that line; a collapsed summary `NN-MM:` row - * (a `{ .. }` brace pair) contributes only its boundary lines `NN` and `MM` — + * (a `{ … }` brace pair) contributes only its boundary lines `NN` and `MM` — * the elided interior was never shown, so editing inside it must be rejected. */ export function parseSeenLinesFromHashlineBody(body: string): number[] { diff --git a/packages/coding-agent/src/edit/renderer.ts b/packages/coding-agent/src/edit/renderer.ts index 1c81de0fa..e8fab3e0c 100644 --- a/packages/coding-agent/src/edit/renderer.ts +++ b/packages/coding-agent/src/edit/renderer.ts @@ -230,7 +230,7 @@ function truncateEditTitlePath(displayPath: string, maxWidth: number | undefined } function formatEditTitlePath(pathValue: string, maxWidth?: number): string { - return truncateEditTitlePath(replaceTabs(shortenPath(pathValue), pathValue), maxWidth); + return truncateEditTitlePath(replaceTabs(shortenPath(pathValue)), maxWidth); } function formatEditPathDisplay( @@ -275,8 +275,8 @@ function formatEditDescription( } function editHeaderLabelBudget(width: number, uiTheme: Theme): number { - const leftGlyphs = `${uiTheme.boxSharp.topLeft}${uiTheme.boxSharp.horizontal.repeat(3)}`; - return Math.max(0, width - visibleWidth(leftGlyphs) - visibleWidth(uiTheme.boxSharp.topRight) - 2); + const leftGlyphs = `${uiTheme.boxRound.topLeft}${uiTheme.boxRound.horizontal.repeat(3)}`; + return Math.max(0, width - visibleWidth(leftGlyphs) - visibleWidth(uiTheme.boxRound.topRight) - 2); } function renderEditHeader( @@ -326,11 +326,11 @@ function renderEditHeader( return buildHeader(fitted.description); } -function renderPlainTextPreview(text: string, uiTheme: Theme, filePath?: string): string { +function renderPlainTextPreview(text: string, uiTheme: Theme, _filePath?: string): string { const previewLines = sanitizeText(text).split("\n"); let preview = "\n\n"; for (const line of previewLines.slice(0, CALL_TEXT_PREVIEW_LINES)) { - preview += `${uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(line, filePath), CALL_TEXT_PREVIEW_WIDTH))}\n`; + preview += `${uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(line), CALL_TEXT_PREVIEW_WIDTH))}\n`; } if (previewLines.length > CALL_TEXT_PREVIEW_LINES) { preview += uiTheme.fg("dim", `… ${previewLines.length - CALL_TEXT_PREVIEW_LINES} more lines`); @@ -395,7 +395,7 @@ function formatMultiFileStreamingDiff( if (!preview.diff && !preview.error) continue; const header = uiTheme.fg("dim", `\n\n── ${shortenPath(preview.path)} ──`); if (preview.error) { - parts.push(`${header}\n${uiTheme.fg("error", replaceTabs(preview.error, preview.path))}`); + parts.push(`${header}\n${uiTheme.fg("error", replaceTabs(preview.error))}`); continue; } if (preview.diff) { @@ -637,7 +637,7 @@ export const editToolRenderer = { callPreviewCaches, ); if (applyPatchSummary?.error) { - body += `\n${uiTheme.fg("error", truncateToWidth(replaceTabs(applyPatchSummary.error, rawPath), Math.max(1, width - 2)))}`; + body += `\n${uiTheme.fg("error", truncateToWidth(replaceTabs(applyPatchSummary.error), Math.max(1, width - 2)))}`; } const bodyLines = body ? body.split("\n") : []; while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift(); @@ -733,11 +733,11 @@ function renderSingleFileResult( let body = ""; if (isError) { - if (errorText) body = uiTheme.fg("error", replaceTabs(errorText, rawPath)); + if (errorText) body = uiTheme.fg("error", replaceTabs(errorText)); } else if (details?.diff) { body = renderDiffSection(details.diff, rawPath, expanded, uiTheme, renderDiffFn, diffSectionCache); } else if (editDiffPreview) { - if ("error" in editDiffPreview) body = uiTheme.fg("error", replaceTabs(editDiffPreview.error, rawPath)); + if ("error" in editDiffPreview) body = uiTheme.fg("error", replaceTabs(editDiffPreview.error)); else if (editDiffPreview.diff) body = renderDiffSection(editDiffPreview.diff, rawPath, expanded, uiTheme, renderDiffFn, diffSectionCache); } diff --git a/packages/coding-agent/src/eval/js/tool-bridge.ts b/packages/coding-agent/src/eval/js/tool-bridge.ts index 7b3745450..60768763b 100644 --- a/packages/coding-agent/src/eval/js/tool-bridge.ts +++ b/packages/coding-agent/src/eval/js/tool-bridge.ts @@ -1,4 +1,5 @@ import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import type { ToolSession } from "../../tools"; import { ToolError } from "../../tools/tool-errors"; import { EVAL_AGENT_BRIDGE_NAME, runEvalAgent } from "../agent-bridge"; @@ -48,8 +49,8 @@ function normalizeArgs(args: unknown): unknown { return args; } const record = { ...(args as Record) }; - if (record._i === undefined) { - record._i = "js prelude"; + if (record[INTENT_FIELD] === undefined) { + record[INTENT_FIELD] = "js prelude"; } return record; } diff --git a/packages/coding-agent/src/eval/py/prelude.py b/packages/coding-agent/src/eval/py/prelude.py index b970d4e1e..d5f067e36 100644 --- a/packages/coding-agent/src/eval/py/prelude.py +++ b/packages/coding-agent/src/eval/py/prelude.py @@ -5,6 +5,7 @@ if "__omp_prelude_loaded__" not in globals(): from pathlib import Path import os, json, math, re from urllib.parse import unquote + INTENT_FIELD = "_i" # __omp_display is injected by runner.py before the prelude executes; it # mirrors IPython's display() semantics with the same MIME bundle output. @@ -479,8 +480,8 @@ if "__omp_prelude_loaded__" not in globals(): f"tool.{self._name}(...) expects a dict of arguments (got {type(args).__name__})" ) merged.update(kwargs) - if "_i" not in merged: - merged["_i"] = "py prelude" + if INTENT_FIELD not in merged: + merged[INTENT_FIELD] = "py prelude" return _bridge_call(self._name, merged) class _ToolProxy: diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index 8b109c2c1..247287ff8 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -44,6 +44,7 @@ import type * as PiCodingAgent from "../../index"; import type { MemoryRuntimeContext } from "../../memory-backend"; import type { CustomEditor } from "../../modes/components/custom-editor"; import type { Theme } from "../../modes/theme/theme"; +import type { CompactMode } from "../../session/compact-modes"; import type { CustomMessage } from "../../session/messages"; import type { ReadonlySessionManager, SessionManager } from "../../session/session-manager"; import type { @@ -287,6 +288,12 @@ export interface ContextUsage { export interface CompactOptions { onComplete?: (result: CompactionResult) => void; onError?: (error: Error) => void; + /** + * Force a one-off compaction mode for this invocation, overriding the + * configured `compaction.strategy` / `remoteEnabled` (the `/compact` + * subcommands: `soft` | `remote` | `snapcompact`). Omitted = configured behavior. + */ + mode?: CompactMode; } /** diff --git a/packages/coding-agent/src/hindsight/mental-models.ts b/packages/coding-agent/src/hindsight/mental-models.ts index eb0e3fc29..e4d3e8c60 100644 --- a/packages/coding-agent/src/hindsight/mental-models.ts +++ b/packages/coding-agent/src/hindsight/mental-models.ts @@ -387,7 +387,7 @@ export function diffMentalModelContent(previous: string | null, current: string, if (out.length > maxLines) { const dropped = out.length - maxLines; - return `${out.slice(0, maxLines).join("\n")}\n… ${dropped} more line${dropped === 1 ? "" : "s"} elided`; + return `${out.slice(0, maxLines).join("\n")}\n[…${dropped}ln elided…]`; } return out.join("\n"); } diff --git a/packages/coding-agent/src/internal-urls/history-protocol.ts b/packages/coding-agent/src/internal-urls/history-protocol.ts index 89e96714e..e84a64449 100644 --- a/packages/coding-agent/src/internal-urls/history-protocol.ts +++ b/packages/coding-agent/src/internal-urls/history-protocol.ts @@ -40,9 +40,12 @@ export class HistoryProtocolHandler implements ProtocolHandler { async resolve(url: InternalUrl): Promise { const agentId = url.rawHost || url.hostname; const registry = AgentRegistry.global(); + // Advisor transcripts are observability-only — surfaced in the Agent Hub, never + // in the agent-facing roster. Hide them from the index, lookup, and completions. + const visible = registry.list().filter(ref => ref.kind !== "advisor"); if (!agentId) { - const content = this.#renderIndex(registry.list()); + const content = this.#renderIndex(visible); return { url: url.href, content, @@ -52,13 +55,14 @@ export class HistoryProtocolHandler implements ProtocolHandler { } let ref = registry.get(agentId); + if (ref?.kind === "advisor") ref = undefined; if (!ref) { // Case-insensitive fallback: agent ids are human-typed (e.g. AuthLoader). const lower = agentId.toLowerCase(); - ref = registry.list().find(candidate => candidate.id.toLowerCase() === lower); + ref = visible.find(candidate => candidate.id.toLowerCase() === lower); } if (!ref) { - const known = registry.list().map(candidate => candidate.id); + const known = visible.map(candidate => candidate.id); const knownStr = known.length > 0 ? known.join(", ") : "none"; throw new Error(`Unknown agent: ${agentId}\nKnown agents: ${knownStr}\nList all with history://`); } @@ -105,6 +109,7 @@ export class HistoryProtocolHandler implements ProtocolHandler { async complete(): Promise { return AgentRegistry.global() .list() + .filter(ref => ref.kind !== "advisor") .map(ref => ({ value: ref.id, description: `${ref.status} · ${ref.kind}${ref.parentId ? ` · parent ${ref.parentId}` : ""}`, diff --git a/packages/coding-agent/src/irc/bus.ts b/packages/coding-agent/src/irc/bus.ts index ec35fe5cb..a40d59ec5 100644 --- a/packages/coding-agent/src/irc/bus.ts +++ b/packages/coding-agent/src/irc/bus.ts @@ -98,6 +98,14 @@ export class IrcBus { if (!ref || ref.status === "aborted") { return { to: message.to, outcome: "failed", error: `Unknown or terminated agent "${message.to}".` }; } + // Advisor refs are observability-only transcripts, never messageable peers. + if (ref.kind === "advisor") { + return { + to: message.to, + outcome: "failed", + error: `Agent "${message.to}" is a read-only advisor transcript and cannot be messaged.`, + }; + } let revived = false; if (ref.status === "parked") { diff --git a/packages/coding-agent/src/lsp/client.ts b/packages/coding-agent/src/lsp/client.ts index a2b6293e1..3a4ab3af0 100644 --- a/packages/coding-agent/src/lsp/client.ts +++ b/packages/coding-agent/src/lsp/client.ts @@ -5,6 +5,7 @@ import { applyWorkspaceEdit } from "./edits"; import { getLspmuxCommand, isLspmuxSupported } from "./lspmux"; import type { LspClient, + LspJsonRpcId, LspJsonRpcNotification, LspJsonRpcRequest, LspJsonRpcResponse, @@ -416,7 +417,6 @@ function currentWorkspaceFolders(client: LspClient): Array<{ uri: string; name: * Handle workspace/workspaceFolders requests from the server. */ async function handleWorkspaceFoldersRequest(client: LspClient, message: LspJsonRpcRequest): Promise { - if (typeof message.id !== "number") return; await sendResponse(client, message.id, currentWorkspaceFolders(client), "workspace/workspaceFolders"); } @@ -424,7 +424,6 @@ async function handleWorkspaceFoldersRequest(client: LspClient, message: LspJson * Handle workspace/configuration requests from the server. */ async function handleConfigurationRequest(client: LspClient, message: LspJsonRpcRequest): Promise { - if (typeof message.id !== "number") return; const params = message.params as { items?: Array<{ section?: string }> }; const items = params?.items ?? []; const result = items.map(item => { @@ -438,7 +437,6 @@ async function handleConfigurationRequest(client: LspClient, message: LspJsonRpc * Handle workspace/applyEdit requests from the server. */ async function handleApplyEditRequest(client: LspClient, message: LspJsonRpcRequest): Promise { - if (typeof message.id !== "number") return; const params = message.params as { edit?: WorkspaceEdit }; if (!params?.edit) { await sendResponse( @@ -475,13 +473,15 @@ async function handleServerRequest(client: LspClient, message: LspJsonRpcRequest return; } if (message.method === "window/workDoneProgress/create") { - // Accept progress token registration from the server - if (typeof message.id === "number") { - await sendResponse(client, message.id, null, message.method); - } + // Accept progress token registration from the server. + await sendResponse(client, message.id, null, message.method); + return; + } + if (message.method === "client/registerCapability" || message.method === "client/unregisterCapability") { + // Some servers block semantic requests until dynamic registration succeeds. + await sendResponse(client, message.id, null, message.method); return; } - if (typeof message.id !== "number") return; await sendResponse(client, message.id, null, message.method, { code: -32601, message: `Method not found: ${message.method}`, @@ -493,7 +493,7 @@ async function handleServerRequest(client: LspClient, message: LspJsonRpcRequest */ async function sendResponse( client: LspClient, - id: number, + id: LspJsonRpcId, result: unknown, method: string, error?: { code: number; message: string; data?: unknown }, diff --git a/packages/coding-agent/src/lsp/index.ts b/packages/coding-agent/src/lsp/index.ts index c04e2f14c..f85834aed 100644 --- a/packages/coding-agent/src/lsp/index.ts +++ b/packages/coding-agent/src/lsp/index.ts @@ -598,7 +598,7 @@ async function runWorkspaceDiagnostics( // Limit output length const lines = combined.split("\n"); if (lines.length > 50) { - return { output: `${lines.slice(0, 50).join("\n")}\n... and ${lines.length - 50} more lines`, projectType }; + return { output: `${lines.slice(0, 50).join("\n")}\n[…${lines.length - 50}ln elided…]`, projectType }; } return { output: combined, projectType }; } catch (e) { @@ -2035,7 +2035,7 @@ export class LspTool implements AgentTool formatSymbolInformation(s, this.session.cwd)); const truncationLine = dedupedSymbols.length > WORKSPACE_SYMBOL_LIMIT - ? `\n... ${dedupedSymbols.length - WORKSPACE_SYMBOL_LIMIT} additional symbol(s) omitted` + ? `\n[…${dedupedSymbols.length - WORKSPACE_SYMBOL_LIMIT} symbols elided…]` : ""; return { content: [ diff --git a/packages/coding-agent/src/lsp/render.ts b/packages/coding-agent/src/lsp/render.ts index dbe21945b..8953cafc3 100644 --- a/packages/coding-agent/src/lsp/render.ts +++ b/packages/coding-agent/src/lsp/render.ts @@ -223,10 +223,10 @@ function renderHover( const langLabel = lang ? theme.fg("mdCodeBlockBorder", ` ${lang}`) : ""; if (expanded) { - const h = theme.boxSharp.horizontal; - const v = theme.boxSharp.vertical; - const top = `${theme.boxSharp.topLeft}${h.repeat(3)}`; - const bottom = `${theme.boxSharp.bottomLeft}${h.repeat(3)}`; + const h = theme.boxRound.horizontal; + const v = theme.boxRound.vertical; + const top = `${theme.boxRound.topLeft}${h.repeat(3)}`; + const bottom = `${theme.boxRound.bottomLeft}${h.repeat(3)}`; let output = `${icon}${langLabel}`; if (beforeCode) { for (const line of beforeCode.split("\n")) { @@ -254,9 +254,9 @@ function renderHover( const preview = truncateToWidth(beforeCode, TRUNCATE_LENGTHS.TITLE); output += `\n ${theme.fg("dim", theme.tree.branch)} ${theme.fg("muted", preview)}`; } - const h = theme.boxSharp.horizontal; - const v = theme.boxSharp.vertical; - const bottom = `${theme.boxSharp.bottomLeft}${h.repeat(3)}`; + const h = theme.boxRound.horizontal; + const v = theme.boxRound.vertical; + const bottom = `${theme.boxRound.bottomLeft}${h.repeat(3)}`; output += `\n ${theme.fg("mdCodeBlockBorder", v)} ${firstCodeLine}`; if (codeLines.length > 1) { diff --git a/packages/coding-agent/src/lsp/types.ts b/packages/coding-agent/src/lsp/types.ts index d33b8ccca..13f2c6b80 100644 --- a/packages/coding-agent/src/lsp/types.ts +++ b/packages/coding-agent/src/lsp/types.ts @@ -399,7 +399,7 @@ export interface LspClient { diagnostics: Map; diagnosticsVersion: number; openFiles: Map; - pendingRequests: Map; + pendingRequests: Map; messageBuffer: Uint8Array; isReading: boolean; /** Lifecycle state: "connecting" until initialize completes, then "ready"; "error" on init failure or reader death. */ @@ -420,16 +420,19 @@ export interface LspClient { // JSON-RPC Protocol Types // ============================================================================= +/** JSON-RPC request/response identifier accepted by LSP peers. */ +export type LspJsonRpcId = number | string; + export interface LspJsonRpcRequest { jsonrpc: "2.0"; - id: number; + id: LspJsonRpcId; method: string; params: unknown; } export interface LspJsonRpcResponse { jsonrpc: "2.0"; - id?: number; + id?: LspJsonRpcId; result?: unknown; error?: { code: number; message: string; data?: unknown }; } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 30a459db2..42e5d08c9 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -72,7 +72,7 @@ import { shouldShowStartupSplash } from "./startup-splash"; import { discoverTitleSystemPromptFile, resolvePromptInput } from "./system-prompt"; import { createPersistedSubagentReviverFactory } from "./task/persisted-revive"; import { initTelemetryExport, isTelemetryExportEnabled } from "./telemetry-export"; -import { AUTO_THINKING } from "./thinking"; +import { AUTO_THINKING, parseConfiguredThinkingLevel } from "./thinking"; import type { LspStartupServerInfo } from "./tools"; import { getChangelogPath, @@ -133,9 +133,12 @@ const HOST_DEFAULTED_SETTING_PATHS: SettingPath[] = [ "memory.backend", "memories.enabled", // Advisor is interactive-session assistance. Protocol hosts opt in explicitly - // instead of inheriting a user's globally-enabled local preference. + // instead of inheriting a user's globally-enabled local preference, and when + // they do opt in they get the default tuning rather than the user's local tuning. "advisor.enabled", "advisor.subagents", + "advisor.syncBacklog", + "advisor.immuneTurns", ]; const RPC_BACKGROUND_DEFAULTED_SETTING_PATHS: SettingPath[] = [ @@ -857,7 +860,7 @@ async function buildSessionOptions( if (scopedModels.length > 0) { // `auto` is a session-level concept only; per-scoped-model (Ctrl+P) thinking // overrides stay concrete, so coerce the auto default to "unset" here. - const defaultThinkingLevelSetting = activeSettings.get("defaultThinkingLevel"); + const defaultThinkingLevelSetting = parseConfiguredThinkingLevel(activeSettings.get("defaultThinkingLevel")); const defaultThinkingLevel = defaultThinkingLevelSetting === AUTO_THINKING ? undefined : defaultThinkingLevelSetting; options.scopedModels = scopedModels.map(scopedModel => ({ diff --git a/packages/coding-agent/src/modes/acp/acp-agent.ts b/packages/coding-agent/src/modes/acp/acp-agent.ts index 469290dbe..0f568347d 100644 --- a/packages/coding-agent/src/modes/acp/acp-agent.ts +++ b/packages/coding-agent/src/modes/acp/acp-agent.ts @@ -68,10 +68,17 @@ import type { SessionInfo as StoredSessionInfo } from "../../session/session-lis import { SessionManager } from "../../session/session-manager"; import { executeAcpBuiltinSlashCommand } from "../../slash-commands/acp-builtins"; import { buildAvailableSlashCommands, toAcpAvailableCommands } from "../../slash-commands/available-commands"; +import { DEFAULT_STT_MODEL_KEY, STT_MODEL_OPTIONS } from "../../stt/models"; import { AUTO_THINKING, parseConfiguredThinkingLevel } from "../../thinking"; import { normalizeLocalScheme } from "../../tools/path-utils"; import { runResolveInvocation } from "../../tools/resolve"; import { ToolError } from "../../tools/tool-errors"; +import { + DEFAULT_TTS_LOCAL_MODEL_KEY, + DEFAULT_TTS_VOICE, + TTS_LOCAL_MODELS, + TTS_LOCAL_VOICE_OPTIONS, +} from "../../tts/models"; import { canonicalizeMessage } from "../../utils/thinking-display"; import { createAcpClientBridge } from "./acp-client-bridge"; import { @@ -91,6 +98,7 @@ const MODEL_CONFIG_ID = "model"; const THINKING_CONFIG_ID = "thinking"; const THINKING_OFF = "off"; const SESSION_PAGE_SIZE = 50; +const SPEECH_MODELS_LIST_METHOD = "speech.models.list"; /** * Delay between `session/new` (or `session/load` / `session/resume` / * `unstable_session/fork`) returning and the agent firing the first @@ -195,6 +203,59 @@ type MCPSourceMap = { type CreateAcpSession = (cwd: string) => Promise; +type AcpSpeechOption = { + value: string; + label: string; + description?: string; +}; + +type AcpSpeechVoiceOption = { + value: string; + label: string; +}; + +type AcpSpeechTtsModelOption = AcpSpeechOption & { + voices: AcpSpeechVoiceOption[]; +}; + +function buildAcpSpeechModelsCatalog(): Record { + const voices = TTS_LOCAL_VOICE_OPTIONS.map(({ value, label }) => ({ value, label })); + return { + settings: { + speechToTextModel: "stt.modelName", + textToSpeechModel: "tts.localModel", + textToSpeechVoice: "tts.localVoice", + speechVoice: "speech.voice", + }, + defaults: { + speechToTextModel: DEFAULT_STT_MODEL_KEY, + textToSpeechModel: DEFAULT_TTS_LOCAL_MODEL_KEY, + voice: DEFAULT_TTS_VOICE, + }, + speechToText: { + setting: "stt.modelName", + defaultValue: DEFAULT_STT_MODEL_KEY, + models: STT_MODEL_OPTIONS.map(({ value, label, description }) => ({ value, label, description })), + }, + textToSpeech: { + modelSetting: "tts.localModel", + voiceSetting: "tts.localVoice", + speechVoiceSetting: "speech.voice", + defaultModel: DEFAULT_TTS_LOCAL_MODEL_KEY, + defaultVoice: DEFAULT_TTS_VOICE, + models: TTS_LOCAL_MODELS.map( + ({ key, label, description, voices: modelVoices }): AcpSpeechTtsModelOption => ({ + value: key, + label, + description, + voices: modelVoices.map(({ id, label: voiceLabel }) => ({ value: id, label: voiceLabel })), + }), + ), + voices, + }, + }; +} + /** * Bridge a single ExtensionUIContext call to the ACP `unstable_createElicitation` * surface. Skills/extensions ask for one value at a time (a chosen option, a @@ -850,6 +911,8 @@ export class AcpAgent implements Agent { async extMethod(method: string, params: { [key: string]: unknown }): Promise<{ [key: string]: unknown }> { switch (method) { + case SPEECH_MODELS_LIST_METHOD: + return buildAcpSpeechModelsCatalog(); case "_omp/sessions/listAll": { const limit = typeof params.limit === "number" ? Math.max(1, Math.min(5000, params.limit as number)) : 1000; const sessions = await SessionManager.listAll(); diff --git a/packages/coding-agent/src/modes/components/__tests__/skill-message.test.ts b/packages/coding-agent/src/modes/components/__tests__/skill-message.test.ts new file mode 100644 index 000000000..b14467171 --- /dev/null +++ b/packages/coding-agent/src/modes/components/__tests__/skill-message.test.ts @@ -0,0 +1,92 @@ +import { beforeAll, describe, expect, it } from "bun:test"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Settings } from "../../../config/settings"; +import type { CustomMessage, SkillPromptDetails } from "../../../session/messages"; +import { getThemeByName, setThemeInstance, type Theme } from "../../theme/theme"; +import { SkillMessageComponent } from "../skill-message"; + +// Drop SGR colors and OSC 8 hyperlink wrappers so assertions see the visible text only. +const strip = (lines: readonly string[]): string => + lines + .join("\n") + .replace(/\x1b\]8;[^\x1b\x07]*(?:\x07|\x1b\\)/g, "") + .replace(/\x1b\[[0-9;]*m/g, ""); + +function makeMessage( + details: SkillPromptDetails, + content = "Use the atomic-commit workflow.", +): CustomMessage { + return { role: "custom", customType: "skill-prompt", content, display: true, details, timestamp: Date.now() }; +} + +describe("SkillMessageComponent", () => { + let uiTheme: Theme; + + beforeAll(async () => { + await Settings.init({ inMemory: true }); + const loaded = await getThemeByName("dark"); + if (!loaded) throw new Error("theme unavailable"); + uiTheme = loaded; + setThemeInstance(uiTheme); + }); + + const skillPath = path.join(os.homedir(), ".agent/skills/atomic-commit/SKILL.md"); + + it("renders a compact, outlined card instead of the archaic key:value dump", () => { + const component = new SkillMessageComponent( + makeMessage({ name: "atomic-commit", path: skillPath, lineCount: 88 }), + ); + const text = strip(component.render(80)); + + // New look: an icon-tagged "skill" header with the name and a single meta line. + expect(text).toContain("skill"); + expect(text).toContain("atomic-commit"); + expect(text).toContain("88 lines"); + + // The card is drawn with an outline. + expect(text).toContain(uiTheme.boxRound.topLeft); + expect(text).toContain(uiTheme.boxRound.bottomRight); + + // Path is home-shortened and never leaks the absolute home dir. + expect(text).toContain("~/.agent/skills/atomic-commit/SKILL.md"); + expect(text).not.toContain(os.homedir()); + + // The old archaic framing is gone. + expect(text).not.toContain("[skill]"); + expect(text).not.toContain("Skill:"); + expect(text).not.toContain("Path:"); + expect(text).not.toContain("Prompt:"); + }); + + it("flattens multi-line args onto the single-line header", () => { + const component = new SkillMessageComponent( + makeMessage({ name: "atomic-commit", path: skillPath, lineCount: 88, args: "stage all\nthen split" }), + ); + const text = strip(component.render(80)); + // Whitespace (including the newline) collapsed to single spaces so the header can't break. + expect(text).toContain("stage all then split"); + expect(text).not.toContain("stage all\nthen split"); + }); + + it("uses a singular unit for a one-line prompt", () => { + const component = new SkillMessageComponent(makeMessage({ name: "tiny", path: skillPath, lineCount: 1 })); + const text = strip(component.render(80)); + expect(text).toContain("1 line"); + expect(text).not.toContain("1 lines"); + }); + + it("reveals the prompt body under a calm subheader only when expanded", () => { + const details: SkillPromptDetails = { name: "atomic-commit", path: skillPath, lineCount: 88 }; + const body = "Step one: stage hunks."; + + const collapsed = new SkillMessageComponent(makeMessage(details, body)); + expect(strip(collapsed.render(80))).not.toContain(body); + + const expanded = new SkillMessageComponent(makeMessage(details, body)); + expanded.setExpanded(true); + const text = strip(expanded.render(80)); + expect(text).toContain("prompt"); + expect(text).toContain(body); + }); +}); diff --git a/packages/coding-agent/src/modes/components/agent-dashboard.ts b/packages/coding-agent/src/modes/components/agent-dashboard.ts index 1a9ad6c1a..87837ddcd 100644 --- a/packages/coding-agent/src/modes/components/agent-dashboard.ts +++ b/packages/coding-agent/src/modes/components/agent-dashboard.ts @@ -321,7 +321,7 @@ class TwoColumnBody implements Component { const rightLines = this.rightPane.render(rightWidth); const lineCount = this.maxHeight; const out: string[] = []; - const separator = theme.fg("dim", ` ${theme.boxSharp.vertical} `); + const separator = theme.fg("dim", ` ${theme.boxRound.vertical} `); for (let i = 0; i < lineCount; i++) { const left = truncateToWidth(leftLines[i] ?? "", leftWidth); diff --git a/packages/coding-agent/src/modes/components/agent-hub.ts b/packages/coding-agent/src/modes/components/agent-hub.ts index 1ff98a92d..42aed6f3d 100644 --- a/packages/coding-agent/src/modes/components/agent-hub.ts +++ b/packages/coding-agent/src/modes/components/agent-hub.ts @@ -15,63 +15,26 @@ */ import * as fs from "node:fs"; import * as path from "node:path"; -import type { AgentMessage, AgentTool } from "@oh-my-pi/pi-agent-core"; -import type { Usage } from "@oh-my-pi/pi-ai"; -import { Container, Editor, Ellipsis, matchesKey, ScrollView, Text, type TUI } from "@oh-my-pi/pi-tui"; -import { formatAge, formatBytes, formatDuration, formatNumber, getProjectDir, logger } from "@oh-my-pi/pi-utils"; -import type { AdvisorMessageDetails } from "../../advisor"; -import { COLLAB_PROMPT_MESSAGE_TYPE, type CollabPromptDetails } from "../../collab/protocol"; +import type { AgentTool } from "@oh-my-pi/pi-agent-core"; +import { Container, Ellipsis, matchesKey, type OverlayHandle, type TUI } from "@oh-my-pi/pi-tui"; +import { formatAge, getProjectDir, logger } from "@oh-my-pi/pi-utils"; +import { ADVISOR_TRANSCRIPT_FILENAME } from "../../advisor"; import type { KeyId } from "../../config/keybindings"; -import { settings } from "../../config/settings"; import type { MessageRenderer } from "../../extensibility/extensions/types"; import { IrcBus } from "../../irc/bus"; import { AgentLifecycleManager } from "../../registry/agent-lifecycle"; import { type AgentRef, AgentRegistry, type AgentStatus, MAIN_AGENT_ID } from "../../registry/agent-registry"; -import type { AgentSession } from "../../session/agent-session"; -import { - BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE, - type CustomMessage, - isSilentAbort, - LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE, - resolveAbortLabel, - SKILL_PROMPT_MESSAGE_TYPE, - type SkillPromptDetails, - USER_INTERRUPT_LABEL, -} from "../../session/messages"; -import type { SessionMessageEntry } from "../../session/session-entries"; -import { parseSessionEntries } from "../../session/session-loader"; -import { createIrcMessageCard } from "../../tools/irc"; +import { USER_INTERRUPT_LABEL } from "../../session/messages"; import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils"; -import { canonicalizeMessage } from "../../utils/thinking-display"; import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry"; -import { getEditorTheme, theme } from "../theme/theme"; +import { theme } from "../theme/theme"; import { matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers"; -import { createAdvisorMessageCard } from "./advisor-message"; -import { AssistantMessageComponent } from "./assistant-message"; -import { createBackgroundTanDispatchBlock } from "./background-tan-message"; -import { BashExecutionComponent } from "./bash-execution"; -import { BranchSummaryMessageComponent } from "./branch-summary-message"; -import { CollabPromptMessageComponent } from "./collab-prompt-message"; -import { CompactionSummaryMessageComponent, createHandoffSummaryMessageComponent } from "./compaction-summary-message"; -import { CustomMessageComponent } from "./custom-message"; +import { AgentTranscriptViewer } from "./agent-transcript-viewer"; import { DynamicBorder } from "./dynamic-border"; -import { EvalExecutionComponent } from "./eval-execution"; -import { type LateDiagnosticsFile, LateDiagnosticsMessageComponent } from "./late-diagnostics-message"; -import { ReadToolGroupComponent, readArgsHaveTarget, readArgsTargetInternalUrl } from "./read-tool-group"; -import { SkillMessageComponent } from "./skill-message"; -import { formatContextUsage } from "./status-line/context-thresholds"; -import { ToolExecutionComponent } from "./tool-execution"; -import { TranscriptBlock, TranscriptContainer } from "./transcript-container"; -import { createUsageRowBlock } from "./usage-row"; -import { UserMessageComponent } from "./user-message"; -/** Lines per page for PageUp/PageDown */ -const PAGE_SIZE = 15; /** Refresh cadence for the relative-time column */ const AGE_TICK_MS = 5_000; -/** Debounce for live-session transcript refreshes */ -const CHAT_REFRESH_DEBOUNCE_MS = 80; -/** Double-tap window for the left-left "go to parent" gesture (matches the editor's). */ +/** Double-tap window for the table's left-left "close hub" gesture. */ const LEFT_TAP_WINDOW_MS = 500; /** Compute the max content width for the current terminal, accounting for chrome. */ @@ -120,8 +83,33 @@ function registerPersistedSubagentsFromDir(registry: AgentRegistry, dir: string, } for (const entry of entries) { if (!entry.isFile() || !entry.name.endsWith(".jsonl") || entry.name.includes(".bak")) continue; - const id = entry.name.slice(0, -6); const sessionFile = path.join(dir, entry.name); + // The advisor transcript is observability-only: register it as a non-peer + // `advisor` kind under its owning session so the Hub can show its read-only + // transcript, but it never joins agent-facing rosters and is not revivable. + if (entry.name === ADVISOR_TRANSCRIPT_FILENAME) { + const owner = parentId ?? MAIN_AGENT_ID; + const advisorId = `${owner}/advisor`; + const existing = registry.get(advisorId); + // Never clobber a non-advisor ref that happens to share this id (a freak + // user task literally named `/advisor`): leave it, skip the advisor. + if (existing && existing.kind !== "advisor") continue; + if (existing?.sessionFile !== sessionFile) { + // The id is reused across `/new`; refresh it to the current session's file. + if (existing) registry.unregister(advisorId); + registry.register({ + id: advisorId, + displayName: "advisor", + kind: "advisor", + parentId: owner, + session: null, + sessionFile, + status: "parked", + }); + } + continue; + } + const id = entry.name.slice(0, -6); if (!registry.get(id)) { registry.register({ id, @@ -190,29 +178,17 @@ export class AgentHubOverlayComponent extends Container { #unsubscribers: Array<() => void> = []; #ageTimer: NodeJS.Timeout | undefined; #remote: AgentHubRemote | undefined; - #remoteFetchInFlight = false; - /** Invalidates stale in-flight fetch callbacks after openChat resets the cache. */ - #remoteFetchToken = 0; - #remoteTranscriptUnavailable = false; // Table state - #view: "table" | "chat" = "table"; #rows: AgentRef[] = []; #selectedRow = 0; #notice: string | undefined; /** Captured row order from the first refresh; keeps the hub stable while open. */ #rowOrder: Map | undefined; + /** Double-tap window state for the table's left-left "close hub" gesture. */ + #lastLeftTap = 0; - // Chat state - #chatAgentId: string | undefined; - #editor: Editor; - #sessionUnsubscribe: (() => void) | undefined; - #attachedSession: AgentSession | undefined; - #chatRefreshTimer: NodeJS.Timeout | undefined; - #transcriptCache: { path: string; bytesRead: number; entries: SessionMessageEntry[]; model?: string } | undefined; - - // Chat transcript: the same component renderers as the main session - // transcript, assembled incrementally from the persisted JSONL entries. + // Transcript-viewer launch deps (passed through to AgentTranscriptViewer). #ui: TUI; #getTool: ((name: string) => AgentTool | undefined) | undefined; #getMessageRenderer: ((customType: string) => MessageRenderer | undefined) | undefined; @@ -220,25 +196,10 @@ export class AgentHubOverlayComponent extends Container { #hideThinkingBlock: (() => boolean) | undefined; #expandKeys: KeyId[]; #focusAgent: ((id: string) => Promise) | undefined; - #chatLog = new TranscriptContainer(); - #chatEntriesRef: SessionMessageEntry[] | undefined; - #chatBuiltCount = 0; - #chatPendingTools = new Map(); - #chatReadArgs = new Map>(); - #chatReadGroup: ReadToolGroupComponent | null = null; - #pendingUsage: Usage | undefined; - #chatWaitingPoll: ToolExecutionComponent | null = null; - #chatExpandables: Array<{ setExpanded(expanded: boolean): void }> = []; - #chatExpanded = false; - #chatPlaceholder: string | undefined; - // Viewport state - #scrollOffset = 0; - #lastMaxScroll = 0; - #viewportHeight = 20; - #wasAtBottom = true; - #viewerHeaderLines: string[] = []; - #lastLeftTap = 0; + // Fullscreen transcript overlay opened by openChat(), if any. + #transcriptOverlay: OverlayHandle | undefined; + #transcriptViewer: AgentTranscriptViewer | undefined; constructor(deps: AgentHubDeps) { super(); @@ -265,10 +226,6 @@ export class AgentHubOverlayComponent extends Container { this.#expandKeys = deps.expandKeys ?? ["ctrl+o"]; this.#focusAgent = deps.focusAgent; - this.#editor = new Editor(getEditorTheme()); - this.#editor.setMaxHeight(4); - this.#editor.onSubmit = text => this.#submitChatMessage(text); - this.#unsubscribers.push(this.#registry.onChange(() => this.#onDataChange())); this.#unsubscribers.push(this.#observers.onChange(() => this.#onDataChange())); this.#ageTimer = setInterval(() => this.#requestRender(), AGE_TICK_MS); @@ -294,17 +251,11 @@ export class AgentHubOverlayComponent extends Container { clearInterval(this.#ageTimer); this.#ageTimer = undefined; } - if (this.#chatRefreshTimer) { - clearTimeout(this.#chatRefreshTimer); - this.#chatRefreshTimer = undefined; - } - this.#detachLiveSession(); - this.#resetChatLog(); + this.#closeTranscriptOverlay(); } override render(width: number): readonly string[] { - const lines = this.#view === "table" ? this.#renderTable(width) : this.#renderChat(width); - return lines.map(line => clampHubLine(line, width)); + return this.#renderTable(width).map(line => clampHubLine(line, width)); } handleInput(keyData: string): void { @@ -315,30 +266,53 @@ export class AgentHubOverlayComponent extends Container { return; } } - if (this.#view === "table") { - this.#handleTableInput(keyData); - } else { - this.#handleChatInput(keyData); - } + this.#handleTableInput(keyData); } - /** Open the chat view for an agent id (public for table Enter and tests). */ + /** + * Open the fullscreen transcript viewer for an agent id (public for table Enter + * and tests). Mounts {@link AgentTranscriptViewer} as a `fullscreen` overlay so it + * owns the alternate screen; the hub table stays mounted underneath and is + * restored when the viewer closes. No-op without a real TUI (render-only test stub). + */ openChat(id: string): void { if (!this.#registry.get(id)) return; - this.#view = "chat"; - this.#chatAgentId = id; + if (typeof this.#ui.showOverlay !== "function") return; + this.#closeTranscriptOverlay(); this.#notice = undefined; - this.#transcriptCache = undefined; - this.#remoteTranscriptUnavailable = false; - this.#remoteFetchInFlight = false; - this.#remoteFetchToken++; - this.#resetChatLog(); - this.#scrollOffset = 0; - this.#wasAtBottom = true; - this.#lastLeftTap = 0; - this.#editor.setText(""); - this.#attachLiveSession(); - this.#rebuildChatContent(); + const viewer = new AgentTranscriptViewer({ + agentId: id, + registry: this.#registry, + remote: this.#remote, + observers: this.#observers, + lifecycle: this.#remote ? undefined : this.#lifecycle, + ui: this.#ui, + getTool: this.#getTool, + getMessageRenderer: this.#getMessageRenderer, + cwd: this.#cwd, + hideThinkingBlock: this.#hideThinkingBlock, + expandKeys: this.#expandKeys, + hubKeys: this.#hubKeys, + requestRender: this.#requestRender, + onClose: () => this.#closeTranscriptOverlay(), + onHubClose: () => { + this.#closeTranscriptOverlay(); + this.#onDone(); + }, + }); + this.#transcriptViewer = viewer; + this.#transcriptOverlay = this.#ui.showOverlay(viewer, { width: "100%", margin: 0, fullscreen: true }); + this.#ui.setFocus(viewer); + this.#requestRender(); + } + + /** Close and dispose the transcript overlay, restoring focus to the hub table. */ + #closeTranscriptOverlay(): void { + this.#transcriptOverlay?.hide(); + this.#transcriptOverlay = undefined; + this.#transcriptViewer?.dispose(); + this.#transcriptViewer = undefined; + if (typeof this.#ui.setFocus === "function") this.#ui.setFocus(this); this.#requestRender(); } @@ -348,12 +322,6 @@ export class AgentHubOverlayComponent extends Container { #onDataChange(): void { this.#refreshRows(); - if (this.#view === "chat") { - // A revive/park swaps the live session out from under the chat view. - this.#attachLiveSession(); - this.#scheduleChatRefresh(); - return; - } this.#requestRender(); } @@ -389,39 +357,6 @@ export class AgentHubOverlayComponent extends Container { this.#selectedRow = keptIndex >= 0 ? keptIndex : Math.min(this.#selectedRow, Math.max(0, this.#rows.length - 1)); } - /** Subscribe to the chat agent's live session (if any) for transcript refreshes. Idempotent per session. */ - #attachLiveSession(): void { - // Remote refs carry no live session handle; refreshes come from observer onChange. - if (this.#remote) return; - const session = this.#chatAgentId ? (this.#registry.get(this.#chatAgentId)?.session ?? undefined) : undefined; - if (session === this.#attachedSession) return; - this.#detachLiveSession(); - if (!session) return; - this.#attachedSession = session; - this.#sessionUnsubscribe = session.subscribe(event => { - if (event.type === "message_end" || event.type === "tool_execution_end" || event.type === "agent_end") { - this.#scheduleChatRefresh(); - } - }); - } - - #detachLiveSession(): void { - this.#sessionUnsubscribe?.(); - this.#sessionUnsubscribe = undefined; - this.#attachedSession = undefined; - } - - #scheduleChatRefresh(): void { - if (this.#chatRefreshTimer) return; - this.#chatRefreshTimer = setTimeout(() => { - this.#chatRefreshTimer = undefined; - if (this.#view !== "chat") return; - this.#rebuildChatContent(); - this.#requestRender(); - }, CHAT_REFRESH_DEBOUNCE_MS); - this.#chatRefreshTimer.unref?.(); - } - #observableFor(id: string): ObservableSession | undefined { return this.#observers.getSessions().find(s => s.id === id); } @@ -553,7 +488,9 @@ export class AgentHubOverlayComponent extends Container { #activateAgent(ref: AgentRef): void { this.#notice = undefined; const focusAgent = this.#focusAgent; - if (this.#remote || !focusAgent) { + // Advisor refs are read-only transcripts with no live/ revivable session; + // open the in-hub chat view (file-backed) instead of trying to focus one. + if (ref.kind === "advisor" || this.#remote || !focusAgent) { this.openChat(ref.id); return; } @@ -571,6 +508,11 @@ export class AgentHubOverlayComponent extends Container { #reviveSelected(): void { const ref = this.#rows[this.#selectedRow]; if (!ref) return; + if (ref.kind === "advisor") { + this.#notice = `"${ref.id}" is a read-only advisor transcript — nothing to revive.`; + this.#requestRender(); + return; + } if (ref.status !== "parked") { this.#notice = `Agent "${ref.id}" is ${ref.status} — only parked agents can be revived.`; this.#requestRender(); @@ -595,6 +537,11 @@ export class AgentHubOverlayComponent extends Container { #killSelected(): void { const ref = this.#rows[this.#selectedRow]; if (!ref) return; + if (ref.kind === "advisor") { + this.#notice = `"${ref.id}" is a read-only advisor transcript — cannot be killed.`; + this.#requestRender(); + return; + } this.#notice = undefined; if (this.#remote) { this.#remote.kill(ref.id); @@ -616,774 +563,4 @@ export class AgentHubOverlayComponent extends Container { this.#requestRender(); })(); } - - // ======================================================================== - // Chat view - // ======================================================================== - - #renderChat(width: number): string[] { - const termHeight = process.stdout.rows || 40; - const innerWidth = Math.max(20, width - 2); - const editorLines = this.#editor.render(innerWidth); - const noticeLine = this.#notice - ? ` ${theme.fg("error", sanitizeLine(this.#notice, Math.max(10, width - 2)))}` - : undefined; - const footerLines = this.#buildChatFooterLines(); - - // Header: border + headerLines + border; footer: notice? + editor + footer + border - const headerChrome = this.#viewerHeaderLines.length + 2; - const footerChrome = editorLines.length + footerLines.length + (noticeLine ? 1 : 0) + 1; - this.#viewportHeight = Math.max(5, termHeight - headerChrome - footerChrome); - - const contentLines: readonly string[] = this.#chatPlaceholder - ? [theme.fg("dim", this.#chatPlaceholder)] - : this.#chatLog.render(innerWidth); - - const maxScroll = Math.max(0, contentLines.length - this.#viewportHeight); - this.#lastMaxScroll = maxScroll; - if (this.#wasAtBottom) this.#scrollOffset = maxScroll; - this.#scrollOffset = Math.max(0, Math.min(this.#scrollOffset, maxScroll)); - - const lines: string[] = []; - lines.push(...new DynamicBorder().render(width)); - for (const headerLine of this.#viewerHeaderLines) { - lines.push(` ${headerLine}`); - } - lines.push(...new DynamicBorder().render(width)); - - const scrollView = new ScrollView( - contentLines.slice(this.#scrollOffset, this.#scrollOffset + this.#viewportHeight), - { - height: this.#viewportHeight, - scrollbar: "auto", - totalRows: contentLines.length, - theme: { track: t => theme.fg("dim", t), thumb: t => theme.fg("accent", t) }, - }, - ); - scrollView.setScrollOffset(this.#scrollOffset); - for (const row of scrollView.render(Math.max(1, width - 1))) lines.push(` ${row}`); - - if (noticeLine) lines.push(noticeLine); - for (const editorLine of editorLines) lines.push(` ${editorLine}`); - lines.push(...footerLines); - lines.push(...new DynamicBorder().render(width)); - return lines; - } - - #buildChatFooterLines(): string[] { - const lines: string[] = []; - const observed = this.#chatAgentId ? this.#observableFor(this.#chatAgentId) : undefined; - const statsLine = this.#buildStatsLine(observed); - if (statsLine) lines.push(` ${statsLine}`); - lines.push( - ` ${theme.fg("dim", `Enter:send Esc:back ←←:parent ${this.#expandKeys[0] ?? "ctrl+o"}:expand empty input: j/k:scroll g/G:top/bottom`)}`, - ); - return lines; - } - - #buildStatsLine(observed: ObservableSession | undefined): string { - const progress = observed?.progress; - if (!progress) return ""; - const stats: string[] = []; - // Current per-turn context — match the status line's `%/` gauge (e.g. `5.1%/1M`). - if (progress.contextTokens && progress.contextTokens > 0) { - const ctx = - progress.contextWindow && progress.contextWindow > 0 - ? formatContextUsage((progress.contextTokens / progress.contextWindow) * 100, progress.contextWindow) - : `${formatNumber(progress.contextTokens)}`; - stats.push(ctx); - } - if (progress.durationMs > 0) { - stats.push(formatDuration(progress.durationMs)); - } - const parts: string[] = []; - if (stats.length > 0 || progress.toolCount > 0) { - const toolCountStat = - progress.toolCount > 0 ? `${formatNumber(progress.toolCount)} ${theme.icon.extensionTool}` : undefined; - const statSegments = [toolCountStat, ...stats].filter((segment): segment is string => Boolean(segment)); - parts.push(theme.fg("dim", statSegments.join(theme.sep.dot))); - } - if (progress.cost > 0) { - parts.push(theme.fg("statusLineCost", `$${progress.cost.toFixed(2)}`)); - } - return parts.join(theme.sep.dot); - } - - /** Rebuild the chat header and sync transcript components from new entries */ - #rebuildChatContent(): void { - const id = this.#chatAgentId; - const ref = id ? this.#registry.get(id) : undefined; - - // Load transcript first so model info is available for the header - let messageEntries: SessionMessageEntry[] | null = null; - if (this.#remote) { - if (id) this.#fetchRemoteTranscript(id); - messageEntries = this.#transcriptCache?.entries ?? []; - } else if (ref?.sessionFile) { - messageEntries = this.#loadTranscript(ref.sessionFile); - } - - this.#viewerHeaderLines = []; - this.#viewerHeaderLines.push(theme.fg("accent", `Agent Hub > ${id ?? "?"}`)); - if (ref) { - const observed = this.#observableFor(ref.id); - const model = observed?.progress?.resolvedModel ?? this.#transcriptCache?.model; - const kindTag = theme.fg("dim", ` ${ref.parentId ? `${ref.kind} · of ${ref.parentId}` : ref.kind}`); - const modelLabel = model ? theme.fg("muted", `${theme.sep.dot}${model}`) : ""; - this.#viewerHeaderLines.push(`${theme.bold(ref.id)} ${statusBadge(ref.status)}${kindTag}${modelLabel}`); - } - - if (!ref) { - this.#chatPlaceholder = "Agent no longer registered."; - } else if (!this.#remote && !ref.sessionFile) { - this.#chatPlaceholder = "No session file available yet."; - } else if (!messageEntries) { - this.#chatPlaceholder = "Unable to read session file."; - } else if (messageEntries.length === 0) { - if (this.#remote && this.#remoteTranscriptUnavailable) { - this.#chatPlaceholder = "Transcript lives on the host — not available."; - } else if (this.#remote && !this.#transcriptCache) { - this.#chatPlaceholder = "Loading transcript from host…"; - } else { - this.#chatPlaceholder = "No messages yet."; - } - } else { - this.#chatPlaceholder = undefined; - this.#syncChatComponents(messageEntries); - } - } - - #handleChatInput(keyData: string): void { - const editorEmpty = this.#editor.getText().trim() === ""; - - if (matchesKey(keyData, "escape")) { - if (!editorEmpty) { - this.#editor.setText(""); - this.#requestRender(); - return; - } - this.#closeChat(); - return; - } - - // Tool output expansion mirrors the main transcript's app.tools.expand toggle. - for (const key of this.#expandKeys) { - if (matchesKey(keyData, key)) { - this.#chatExpanded = !this.#chatExpanded; - for (const component of this.#chatExpandables) component.setExpanded(this.#chatExpanded); - this.#requestRender(); - return; - } - } - - // Double-tap left on an empty editor hops to the parent session — - // the inverse of the main editor's double-left opening the hub. - if (editorEmpty && matchesKey(keyData, "left")) { - const now = Date.now(); - if (now - this.#lastLeftTap < LEFT_TAP_WINDOW_MS) { - this.#lastLeftTap = 0; - this.#openParent(); - } else { - this.#lastLeftTap = now; - } - return; - } - - // Scrolling works while the input is empty; once the user starts - // typing, the editor owns every key. - if (editorEmpty && this.#handleViewerNavigation(keyData)) { - return; - } - - this.#editor.handleInput(keyData); - this.#requestRender(); - } - - /** Open the chat for the agent's parent, or close the hub when the parent is the main session. */ - #openParent(): void { - const ref = this.#chatAgentId ? this.#registry.get(this.#chatAgentId) : undefined; - const parentId = ref?.parentId; - if (parentId && parentId !== MAIN_AGENT_ID && this.#registry.get(parentId)) { - this.openChat(parentId); - return; - } - this.#onDone(); - } - - #closeChat(): void { - this.#view = "table"; - this.#chatAgentId = undefined; - this.#notice = undefined; - this.#detachLiveSession(); - this.#resetChatLog(); - this.#refreshRows(); - this.#requestRender(); - } - - #submitChatMessage(text: string): void { - const id = this.#chatAgentId; - const trimmed = text.trim(); - if (!id || !trimmed) return; - this.#editor.setText(""); - this.#notice = undefined; - if (this.#remote) { - this.#remote.chat(id, trimmed); - this.#scheduleChatRefresh(); - this.#requestRender(); - return; - } - void (async () => { - try { - // Revives a parked agent; returns the live session for running/idle. - const session = await this.#lifecycle().ensureLive(id); - this.#attachLiveSession(); - // Steers a mid-turn agent; sends a normal prompt to an idle one. - await session.prompt(trimmed, { streamingBehavior: "steer" }); - } catch (error) { - this.#notice = error instanceof Error ? error.message : String(error); - } - this.#scheduleChatRefresh(); - this.#requestRender(); - })(); - this.#requestRender(); - } - - /** Viewport scrolling for the chat transcript. Returns true when handled. */ - #handleViewerNavigation(keyData: string): boolean { - const maxScroll = this.#lastMaxScroll; - const scrollBy = (delta: number) => { - this.#scrollOffset = Math.max(0, Math.min(this.#scrollOffset + delta, maxScroll)); - this.#wasAtBottom = this.#scrollOffset >= maxScroll; - this.#requestRender(); - }; - if (keyData === "j" || matchesSelectDown(keyData)) { - scrollBy(1); - return true; - } - if (keyData === "k" || matchesSelectUp(keyData)) { - scrollBy(-1); - return true; - } - if (matchesKey(keyData, "pageDown")) { - scrollBy(PAGE_SIZE); - return true; - } - if (matchesKey(keyData, "pageUp")) { - scrollBy(-PAGE_SIZE); - return true; - } - if (keyData === "G") { - this.#scrollOffset = maxScroll; - this.#wasAtBottom = true; - this.#requestRender(); - return true; - } - if (keyData === "g") { - this.#scrollOffset = 0; - this.#wasAtBottom = maxScroll === 0; - this.#requestRender(); - return true; - } - return false; - } - - // ======================================================================== - // Transcript assembly — the same components as the main session transcript - // (mirrors UiHelpers.renderSessionContext / addMessageToChat). - // ======================================================================== - - /** Tear down transcript components (sealing pending spinners) and reset build state. */ - #resetChatLog(): void { - for (const pending of this.#chatPendingTools.values()) pending.seal(); - this.#chatPendingTools.clear(); - this.#chatReadArgs.clear(); - this.#chatReadGroup = null; - this.#pendingUsage = undefined; - this.#chatWaitingPoll = null; - this.#chatExpandables = []; - this.#chatLog.dispose(); - this.#chatLog.clear(); - this.#chatEntriesRef = undefined; - this.#chatBuiltCount = 0; - this.#chatPlaceholder = undefined; - } - - /** Append components for entries not yet materialized. Rebuilds from scratch when the cache was replaced (agent switch, file rotation). */ - #syncChatComponents(entries: SessionMessageEntry[]): void { - if (this.#chatEntriesRef !== entries) { - this.#resetChatLog(); - this.#chatEntriesRef = entries; - } - for (let i = this.#chatBuiltCount; i < entries.length; i++) { - this.#appendChatMessage(entries[i].message); - } - this.#chatBuiltCount = entries.length; - // Flush the trailing turn's usage row only once its tools are materialized. - // A read (or any tool) whose toolResult lands in a later debounced sync stays - // pending in #chatReadArgs / #chatPendingTools; flushing now would emit the - // row above it. The sync that drains the maps flushes it below the tools. - if (this.#chatReadArgs.size === 0 && this.#chatPendingTools.size === 0) { - this.#flushPendingUsage(); - } - } - - #trackExpandable(component: { setExpanded(expanded: boolean): void }): void { - component.setExpanded(this.#chatExpanded); - this.#chatExpandables.push(component); - } - - /** A `job` poll showing all-running is displaced by the next `job` call (mirrors the rebuild path). */ - #resolveWaitingPoll(nextToolName?: string): void { - const previous = this.#chatWaitingPoll; - if (!previous) return; - this.#chatWaitingPoll = null; - if (nextToolName === "job" && previous.isDisplaceableBlock()) { - this.#chatLog.removeChild(previous); - } - previous.seal(); - } - - #ensureReadGroup(): ReadToolGroupComponent { - if (!this.#chatReadGroup) { - this.#chatReadGroup = new ReadToolGroupComponent({ - showContentPreview: settings.get("read.toolResultPreview"), - }); - this.#trackExpandable(this.#chatReadGroup); - this.#chatLog.addChild(this.#chatReadGroup); - } - return this.#chatReadGroup; - } - - // The per-turn token-usage row must land below the turn's tool blocks, but - // normal `read` calls only materialize their group in #appendToolResult. Defer - // the row: stash it on the assistant message and flush once the turn's tools - // are placed — before the next non-toolResult message and at the end of each - // sync pass — sealing the read run so the row sits under it. - #flushPendingUsage(): void { - if (!this.#pendingUsage) return; - this.#chatReadGroup?.seal(); - this.#chatReadGroup = null; - this.#chatLog.addChild(createUsageRowBlock(this.#pendingUsage)); - this.#pendingUsage = undefined; - } - - #appendChatMessage(message: AgentMessage): void { - if (message.role !== "toolResult") this.#flushPendingUsage(); - switch (message.role) { - case "assistant": - this.#appendAssistantMessage(message); - break; - case "toolResult": - this.#appendToolResult(message); - break; - case "user": - case "developer": { - // A user prompt closes the poll-displacement window, same as the live path. - if (message.role === "user") this.#resolveWaitingPoll(); - const textContent = - message.role !== "user" - ? "" - : typeof message.content === "string" - ? message.content - : message.content - .filter((block): block is { type: "text"; text: string } => block.type === "text") - .map(block => block.text) - .join(""); - if (textContent) { - const isSynthetic = message.role === "developer" ? true : (message.synthetic ?? false); - this.#chatLog.addChild(new UserMessageComponent(textContent, isSynthetic)); - } - break; - } - case "bashExecution": { - const component = new BashExecutionComponent(message.command, this.#ui, message.excludeFromContext); - if (message.output) component.appendOutput(message.output); - component.setComplete(message.exitCode, message.cancelled, { truncation: message.meta?.truncation }); - this.#chatLog.addChild(component); - break; - } - case "pythonExecution": { - const component = new EvalExecutionComponent(message.code, this.#ui, message.excludeFromContext); - if (message.output) component.appendOutput(message.output); - component.setComplete(message.exitCode, message.cancelled, { truncation: message.meta?.truncation }); - this.#chatLog.addChild(component); - break; - } - case "hookMessage": - case "custom": - this.#appendCustomMessage(message); - break; - case "compactionSummary": { - const component = new CompactionSummaryMessageComponent(message); - this.#trackExpandable(component); - this.#chatLog.addChild(component); - break; - } - case "branchSummary": { - const component = new BranchSummaryMessageComponent(message); - this.#trackExpandable(component); - this.#chatLog.addChild(component); - break; - } - case "fileMention": { - const block = new TranscriptBlock(); - for (const file of message.files) { - let suffix: string; - if (file.skippedReason === "tooLarge") { - const size = typeof file.byteSize === "number" ? formatBytes(file.byteSize) : "unknown size"; - suffix = `(skipped: ${size})`; - } else { - suffix = file.image - ? "(image)" - : file.lineCount === undefined - ? "(unknown lines)" - : `(${file.lineCount} lines)`; - } - const text = `${theme.fg("dim", `${theme.tree.last} `)}${theme.fg("muted", "Read")} ${theme.fg( - "accent", - file.path, - )} ${theme.fg("dim", suffix)}`; - block.addChild(new Text(text, 0, 0)); - } - if (block.children.length > 0) this.#chatLog.addChild(block); - break; - } - default: - message satisfies never; - } - } - - #appendAssistantMessage(message: Extract): void { - const assistantComponent = new AssistantMessageComponent(message, this.#hideThinkingBlock?.() ?? false, () => - this.#requestRender(), - ); - this.#chatLog.addChild(assistantComponent); - - const hasVisibleAssistantContent = message.content.some( - content => - (content.type === "text" && canonicalizeMessage(content.text)) || - (content.type === "thinking" && canonicalizeMessage(content.thinking)), - ); - if (hasVisibleAssistantContent) { - // New visible turn content closes the current read run (mirrors rebuild). - this.#chatReadGroup?.seal(); - this.#chatReadGroup = null; - } - - const isAbortedSilently = message.stopReason === "aborted" && isSilentAbort(message.errorMessage); - const hasErrorStop = !isAbortedSilently && (message.stopReason === "aborted" || message.stopReason === "error"); - const errorMessage = hasErrorStop - ? message.stopReason === "aborted" - ? resolveAbortLabel(message.errorMessage) - : message.errorMessage || "Error" - : null; - - for (const content of message.content) { - if (content.type !== "toolCall") continue; - this.#resolveWaitingPoll(content.name); - - if ( - content.name === "read" && - readArgsHaveTarget(content.arguments) && - !readArgsTargetInternalUrl(content.arguments) - ) { - if (hasErrorStop && errorMessage) { - const group = this.#ensureReadGroup(); - group.updateArgs(content.arguments, content.id); - group.updateResult( - { content: [{ type: "text", text: errorMessage }], isError: true }, - false, - content.id, - ); - } else { - const normalizedArgs = - content.arguments && typeof content.arguments === "object" && !Array.isArray(content.arguments) - ? (content.arguments as Record) - : {}; - this.#chatReadArgs.set(content.id, normalizedArgs); - } - continue; - } - - this.#chatReadGroup?.seal(); - this.#chatReadGroup = null; - const component = new ToolExecutionComponent( - content.name, - content.arguments, - { - // Images can't be sliced through the scroll viewport; keep them off. - showImages: false, - editFuzzyThreshold: settings.get("edit.fuzzyThreshold"), - editAllowFuzzy: settings.get("edit.fuzzyMatch"), - liveRegion: this.#chatLog, - }, - this.#getTool?.(content.name), - this.#ui, - this.#cwd, - content.id, - ); - this.#trackExpandable(component); - this.#chatLog.addChild(component); - - if (hasErrorStop && errorMessage) { - component.updateResult( - { content: [{ type: "text", text: errorMessage }], isError: true }, - false, - content.id, - ); - } else { - this.#chatPendingTools.set(content.id, component); - } - } - - this.#pendingUsage = settings.get("display.showTokenUsage") ? message.usage : undefined; - } - - #appendToolResult(message: Extract): void { - const pending = this.#chatPendingTools.get(message.toolCallId); - const isReadGroupResult = message.toolName === "read" && (!pending || pending instanceof ReadToolGroupComponent); - if (isReadGroupResult) { - let component = pending; - if (!component) { - const group = this.#ensureReadGroup(); - const args = this.#chatReadArgs.get(message.toolCallId); - if (args) group.updateArgs(args, message.toolCallId); - component = group; - } - component.updateResult(message, false, message.toolCallId); - this.#chatPendingTools.delete(message.toolCallId); - this.#chatReadArgs.delete(message.toolCallId); - return; - } - if (!pending) return; - pending.updateResult(message, false, message.toolCallId); - this.#chatPendingTools.delete(message.toolCallId); - if (message.toolName === "job" && pending instanceof ToolExecutionComponent && pending.isDisplaceableBlock()) { - this.#chatWaitingPoll = pending; - } - } - - #appendCustomMessage(message: Extract): void { - if (!message.display) return; - if (message.customType === "async-result") { - const details = ( - message as CustomMessage<{ - jobId?: string; - type?: "bash" | "task"; - label?: string; - durationMs?: number; - jobs?: Array<{ jobId?: string; type?: "bash" | "task"; label?: string; durationMs?: number }>; - }> - ).details; - const jobs = - details?.jobs && details.jobs.length > 0 - ? details.jobs - : [ - { - jobId: details?.jobId, - type: details?.type, - label: details?.label, - durationMs: details?.durationMs, - }, - ]; - const block = new TranscriptBlock(); - for (const job of jobs) { - const jobId = job.jobId ?? "unknown"; - const typeLabel = job.type ? `[${job.type}]` : "[job]"; - const duration = typeof job.durationMs === "number" ? formatDuration(job.durationMs) : undefined; - const line = [ - theme.fg("success", `${theme.status.done} Background job completed`), - theme.fg("dim", typeLabel), - theme.fg("accent", jobId), - duration ? theme.fg("dim", `(${duration})`) : undefined, - ] - .filter(Boolean) - .join(" "); - block.addChild(new Text(line, 1, 0)); - } - this.#chatLog.addChild(block); - return; - } - if (message.customType === LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE) { - const details = (message as CustomMessage<{ files?: LateDiagnosticsFile[] }>).details; - const component = new LateDiagnosticsMessageComponent(details?.files ?? []); - this.#trackExpandable(component); - this.#chatLog.addChild(component); - return; - } - if (message.customType === COLLAB_PROMPT_MESSAGE_TYPE) { - this.#chatLog.addChild(new CollabPromptMessageComponent(message as CustomMessage)); - return; - } - if (message.customType === SKILL_PROMPT_MESSAGE_TYPE) { - const component = new SkillMessageComponent(message as CustomMessage); - this.#trackExpandable(component); - this.#chatLog.addChild(component); - return; - } - if ( - message.customType === "irc:incoming" || - message.customType === "irc:autoreply" || - message.customType === "irc:relay" - ) { - const details = ( - message as CustomMessage<{ from?: string; to?: string; message?: string; body?: string; replyTo?: string }> - ).details; - const kind = - message.customType === "irc:incoming" - ? ("incoming" as const) - : message.customType === "irc:autoreply" - ? ("autoreply" as const) - : ("relay" as const); - const card = createIrcMessageCard( - { - kind, - from: details?.from, - to: details?.to, - body: kind === "incoming" ? details?.message : details?.body, - replyTo: details?.replyTo, - timestamp: message.timestamp, - }, - () => this.#chatExpanded, - theme, - ); - this.#chatLog.addChild(card); - return; - } - if (message.customType === "advisor") { - const details = (message as CustomMessage).details; - this.#chatLog.addChild(createAdvisorMessageCard(details, () => this.#chatExpanded, theme)); - return; - } - if (message.customType === BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE) { - this.#chatLog.addChild(createBackgroundTanDispatchBlock(message as CustomMessage)); - return; - } - const handoffComponent = createHandoffSummaryMessageComponent( - message as CustomMessage, - this.#chatExpanded, - ); - if (handoffComponent) { - this.#trackExpandable(handoffComponent); - this.#chatLog.addChild(handoffComponent); - return; - } - const component = new CustomMessageComponent( - message as CustomMessage, - this.#getMessageRenderer?.(message.customType), - ); - this.#trackExpandable(component); - this.#chatLog.addChild(component); - } - - #loadTranscript(sessionFile: string): SessionMessageEntry[] | null { - if (this.#transcriptCache && this.#transcriptCache.path !== sessionFile) { - this.#transcriptCache = undefined; - } - - const fromByte = this.#transcriptCache?.bytesRead ?? 0; - const result = readFileIncremental(sessionFile, fromByte); - if (!result) { - logger.debug("Agent hub: failed to read session file", { path: sessionFile }); - return this.#transcriptCache?.entries ?? null; - } - - if (result.newSize < fromByte) { - this.#transcriptCache = undefined; - return this.#loadTranscript(sessionFile); - } - - this.#ingestTranscriptChunk(sessionFile, result.text, fromByte); - return this.#transcriptCache?.entries ?? null; - } - - /** Parse a complete-line JSONL chunk into the transcript cache and advance bytesRead. Shared by the local file and remote paths. */ - #ingestTranscriptChunk(cacheKey: string, text: string, fromByte: number): void { - if (!this.#transcriptCache) { - this.#transcriptCache = { path: cacheKey, bytesRead: 0, entries: [] }; - } - if (text.length === 0) return; - const lastNewline = text.lastIndexOf("\n"); - if (lastNewline < 0) return; - const completeChunk = text.slice(0, lastNewline + 1); - const newEntries = parseSessionEntries(completeChunk); - for (const entry of newEntries) { - if (entry.type === "message") { - this.#transcriptCache.entries.push(entry); - // Extract model from first assistant message - const msg = entry.message; - if (!this.#transcriptCache.model && msg.role === "assistant") { - this.#transcriptCache.model = msg.model; - } - } else if (entry.type === "model_change") { - this.#transcriptCache.model = entry.model; - } - } - this.#transcriptCache.bytesRead = fromByte + Buffer.byteLength(completeChunk, "utf-8"); - } - - /** Kick an incremental transcript fetch from the collab host (single-flight). */ - #fetchRemoteTranscript(id: string): void { - const remote = this.#remote; - if (!remote || this.#remoteFetchInFlight) return; - const cacheKey = `remote:${id}`; - if (this.#transcriptCache && this.#transcriptCache.path !== cacheKey) { - this.#transcriptCache = undefined; - } - const fromByte = this.#transcriptCache?.bytesRead ?? 0; - this.#remoteFetchInFlight = true; - const token = ++this.#remoteFetchToken; - void remote - .readTranscript(id, fromByte) - .then(result => { - if (token !== this.#remoteFetchToken) return; - this.#remoteFetchInFlight = false; - if (this.#chatAgentId !== id) return; - if (!result) { - if (!this.#transcriptCache || this.#transcriptCache.entries.length === 0) { - if (!this.#remoteTranscriptUnavailable) { - this.#remoteTranscriptUnavailable = true; - this.#scheduleChatRefresh(); - } - } - return; - } - if (result.newSize < fromByte) { - // Host transcript truncated/rotated — restart from 0. - this.#transcriptCache = undefined; - this.#fetchRemoteTranscript(id); - return; - } - this.#remoteTranscriptUnavailable = false; - const hadCache = this.#transcriptCache !== undefined; - const before = this.#transcriptCache?.entries.length ?? 0; - this.#ingestTranscriptChunk(cacheKey, result.text, fromByte); - const after = this.#transcriptCache?.entries.length ?? 0; - // Only refresh on new content (or first completed fetch) — an - // unconditional rebuild would re-kick the fetch in a tight loop. - if (after > before || !hadCache) this.#scheduleChatRefresh(); - }) - .catch((error: unknown) => { - if (token === this.#remoteFetchToken) this.#remoteFetchInFlight = false; - logger.warn("Agent hub: remote transcript fetch failed", { id, error: String(error) }); - }); - } -} - -// Sync helper for the render path -function readFileIncremental(filePath: string, fromByte: number): { text: string; newSize: number } | null { - try { - const stat = fs.statSync(filePath); - if (stat.size <= fromByte) return { text: "", newSize: stat.size }; - const buf = Buffer.alloc(stat.size - fromByte); - const fd = fs.openSync(filePath, "r"); - try { - fs.readSync(fd, buf, 0, buf.length, fromByte); - } finally { - fs.closeSync(fd); - } - return { text: buf.toString("utf-8"), newSize: stat.size }; - } catch { - return null; - } } diff --git a/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts b/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts new file mode 100644 index 000000000..00cf9d5b7 --- /dev/null +++ b/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts @@ -0,0 +1,461 @@ +/** + * Fullscreen transcript viewer. + * + * `AgentHubOverlayComponent.openChat` mounts this as a `fullscreen` overlay + * (`ui.showOverlay(..., { fullscreen: true })`), so it borrows the terminal's + * alternate screen buffer (the vim/less idiom) and paints the whole screen — no + * compositing into the live transcript's scrollback. It renders a parked + * subagent / advisor / collab-guest transcript that has no live in-view session. + * + * The transcript is rebuilt from scratch on every refresh ({@link ChatTranscriptBuilder.rebuild}) + * rather than synced incrementally, so a growing file-backed transcript (the + * advisor appends while you watch) can never duplicate or misorder rows. Scroll + * is owned end-to-end by a single {@link ScrollView}; the viewer follows the tail + * until the reader scrolls up. + * + * Local agents re-read the whole session file whenever its size or mtime changes + * (covering SessionManager's in-place rewrites, not just appends). Collab guests + * keep the incremental byte cursor the host's capped `readTranscript` requires + * and rebuild components from the accumulated entries. + */ +import * as fs from "node:fs"; +import type { AgentTool } from "@oh-my-pi/pi-agent-core"; +import { type Component, Editor, matchesKey, parseSgrMouse, ScrollView, type TUI } from "@oh-my-pi/pi-tui"; +import { formatDuration, formatNumber, logger } from "@oh-my-pi/pi-utils"; +import type { KeyId } from "../../config/keybindings"; +import type { MessageRenderer } from "../../extensibility/extensions/types"; +import type { AgentLifecycleManager } from "../../registry/agent-lifecycle"; +import type { AgentRegistry, AgentStatus } from "../../registry/agent-registry"; +import type { FileEntry, SessionMessageEntry } from "../../session/session-entries"; +import { parseSessionEntries } from "../../session/session-loader"; +import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry"; +import { getEditorTheme, theme } from "../theme/theme"; +import { matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers"; +import type { AgentHubRemote } from "./agent-hub"; +import { ChatTranscriptBuilder } from "./chat-transcript-builder"; +import { DynamicBorder } from "./dynamic-border"; +import { formatContextUsage } from "./status-line/context-thresholds"; + +export interface AgentTranscriptViewerDeps { + agentId: string; + registry: AgentRegistry; + /** Collab guest: read transcript from the host instead of a local file. */ + remote?: AgentHubRemote; + /** Progress/cost snapshot source for the stats line. */ + observers?: SessionObserverRegistry; + /** Revive+prompt path for messageable local agents. Lazy to avoid touching the global. */ + lifecycle?: () => AgentLifecycleManager; + ui: TUI; + getTool?: (name: string) => AgentTool | undefined; + getMessageRenderer?: (customType: string) => MessageRenderer | undefined; + cwd: string; + hideThinkingBlock?: () => boolean; + expandKeys: KeyId[]; + /** Keys that toggle the whole hub closed (app.agents.hub + app.session.observe). */ + hubKeys: KeyId[]; + requestRender: () => void; + /** Close just this viewer (Esc), returning to the hub table. */ + onClose: () => void; + /** Close this viewer AND the hub (hub-toggle keys). */ + onHubClose: () => void; +} + +/** How often to re-stat a file-backed transcript for growth (advisor/live tail). */ +const POLL_MS = 250; + +function statusBadge(status: AgentStatus): string { + switch (status) { + case "running": + return theme.fg("success", "running"); + case "idle": + return theme.fg("accent", "idle"); + case "parked": + return theme.fg("muted", "parked"); + case "aborted": + return theme.fg("error", "aborted"); + } +} + +export class AgentTranscriptViewer implements Component { + #builder: ChatTranscriptBuilder; + #scrollView: ScrollView; + #followBottom = true; + #editor: Editor | undefined; + #notice: string | undefined; + #expanded = false; + + // Local file transcript state: re-read when the file size or mtime changes. + #lastSignature = ""; + // Remote transcript state (incremental; the host caps each read). + #remoteEntries: SessionMessageEntry[] = []; + #remoteBytes = 0; + #remoteFetchInFlight = false; + #remoteToken = 0; + #remoteUnavailable = false; + #hasRemoteData = false; + + #model: string | undefined; + #pollTimer: NodeJS.Timeout | undefined; + #disposed = false; + + constructor(private readonly deps: AgentTranscriptViewerDeps) { + this.#builder = new ChatTranscriptBuilder({ + ui: deps.ui, + getTool: deps.getTool, + getMessageRenderer: deps.getMessageRenderer, + cwd: deps.cwd, + hideThinkingBlock: deps.hideThinkingBlock, + requestRender: deps.requestRender, + }); + this.#scrollView = new ScrollView([], { + height: 10, + scrollbar: "auto", + theme: { track: t => theme.fg("dim", t), thumb: t => theme.fg("accent", t) }, + }); + if (this.#sendable) { + this.#editor = new Editor(getEditorTheme()); + this.#editor.setMaxHeight(4); + this.#editor.onSubmit = text => this.#submit(text); + } + this.#refresh(); + this.#pollTimer = setInterval(() => this.#refresh(), POLL_MS); + this.#pollTimer.unref?.(); + } + + /** Advisor transcripts are read-only; everything else may be messaged. */ + get #sendable(): boolean { + const ref = this.deps.registry.get(this.deps.agentId); + if (!ref || ref.kind === "advisor") return false; + return Boolean(this.deps.remote || this.deps.lifecycle); + } + + dispose(): void { + this.#disposed = true; + if (this.#pollTimer) { + clearInterval(this.#pollTimer); + this.#pollTimer = undefined; + } + this.#remoteToken++; + this.#builder.dispose(); + } + + // ======================================================================== + // Transcript loading + // ======================================================================== + + /** Re-read the transcript and rebuild components when it changed. */ + #refresh(): void { + if (this.#disposed) return; + if (this.deps.remote) { + this.#fetchRemote(); + return; + } + const sessionFile = this.deps.registry.get(this.deps.agentId)?.sessionFile; + if (!sessionFile) { + if (this.#lastSignature !== "none") { + this.#lastSignature = "none"; + this.#rebuild([]); + } + return; + } + let signature: string; + try { + const stat = fs.statSync(sessionFile); + // Include the path: a different file with the same size/mtime must not alias. + signature = `${sessionFile}:${stat.size}:${stat.mtimeMs}`; + } catch { + // File deleted/rotated while open (e.g. the owning session was dropped): + // clear stale content once instead of freezing on it forever. + if (this.#lastSignature !== "missing") { + this.#lastSignature = "missing"; + this.#model = undefined; + this.#rebuild([]); + } + return; + } + if (signature === this.#lastSignature) return; + let text: string; + try { + text = fs.readFileSync(sessionFile, "utf-8"); + } catch (err) { + // Leave #lastSignature unchanged so a transient read error retries next poll. + logger.debug("transcript viewer: read failed", { err: String(err) }); + return; + } + this.#lastSignature = signature; + this.#model = undefined; + this.#rebuild(this.#extractMessages(parseSessionEntries(text))); + } + + #fetchRemote(): void { + const remote = this.deps.remote; + if (!remote || this.#remoteFetchInFlight) return; + const id = this.deps.agentId; + const fromByte = this.#remoteBytes; + this.#remoteFetchInFlight = true; + const token = ++this.#remoteToken; + void remote + .readTranscript(id, fromByte) + .then(result => { + if (token !== this.#remoteToken || this.#disposed) return; + this.#remoteFetchInFlight = false; + if (!result) { + if (!this.#hasRemoteData && !this.#remoteUnavailable) { + this.#remoteUnavailable = true; + this.deps.requestRender(); + } + return; + } + if (result.newSize < fromByte) { + // Host transcript rotated/truncated — restart from 0. + this.#remoteBytes = 0; + this.#remoteEntries = []; + this.#fetchRemote(); + return; + } + this.#remoteUnavailable = false; + const firstData = !this.#hasRemoteData; + this.#hasRemoteData = true; + const lastNewline = result.text.lastIndexOf("\n"); + if (lastNewline >= 0) { + const completeChunk = result.text.slice(0, lastNewline + 1); + this.#remoteBytes = fromByte + Buffer.byteLength(completeChunk, "utf-8"); + const parsed = this.#extractMessages(parseSessionEntries(completeChunk)); + if (parsed.length > 0) { + this.#remoteEntries.push(...parsed); + this.#rebuild(this.#remoteEntries); + return; + } + } + // First completed fetch (even empty) clears the "Loading…" placeholder. + if (firstData) this.deps.requestRender(); + }) + .catch((error: unknown) => { + if (token === this.#remoteToken) this.#remoteFetchInFlight = false; + logger.warn("transcript viewer: remote fetch failed", { id, error: String(error) }); + }); + } + + /** Filter to message entries, tracking the model from the first assistant / a model_change. */ + #extractMessages(entries: FileEntry[]): SessionMessageEntry[] { + const messages: SessionMessageEntry[] = []; + for (const entry of entries) { + if (entry.type === "message") { + messages.push(entry); + if (!this.#model && entry.message.role === "assistant") this.#model = entry.message.model; + } else if (entry.type === "model_change") { + this.#model = entry.model; + } + } + return messages; + } + + #rebuild(entries: SessionMessageEntry[]): void { + this.#builder.rebuild(entries); + this.deps.requestRender(); + } + + // ======================================================================== + // Input + // ======================================================================== + + handleInput(data: string): void { + if (data.startsWith("\x1b[<")) { + const event = parseSgrMouse(data); + if (event?.wheel != null) { + this.#scrollView.scroll(event.wheel * 3); + this.#syncFollow(); + this.deps.requestRender(); + } + return; + } + + // The hub/observe toggle keys close the whole hub (matches the table view's + // toggle semantics), not just this viewer. + for (const key of this.deps.hubKeys) { + if (matchesKey(data, key)) { + this.deps.onHubClose(); + return; + } + } + + if (matchesKey(data, "escape")) { + if (this.#editor && this.#editor.getText().trim() !== "") { + this.#editor.setText(""); + this.deps.requestRender(); + return; + } + this.deps.onClose(); + return; + } + + for (const key of this.deps.expandKeys) { + if (matchesKey(data, key)) { + this.#expanded = !this.#expanded; + this.#builder.setExpanded(this.#expanded); + this.deps.requestRender(); + return; + } + } + + // Once the reader starts typing a message, the editor owns every key. + const editorEmpty = !this.#editor || this.#editor.getText().trim() === ""; + if (editorEmpty && this.#handleScroll(data)) return; + + if (this.#editor) { + this.#editor.handleInput(data); + this.deps.requestRender(); + } + } + + /** Returns true when the key was a scroll command. ScrollView owns the offset. */ + #handleScroll(data: string): boolean { + if (this.#scrollView.handleScrollKey(data)) { + this.#syncFollow(); + this.deps.requestRender(); + return true; + } + if (data === "j" || matchesSelectDown(data)) { + this.#scrollView.scroll(1); + } else if (data === "k" || matchesSelectUp(data)) { + this.#scrollView.scroll(-1); + } else if (data === "g") { + this.#scrollView.scrollToTop(); + } else if (data === "G") { + this.#scrollView.scrollToBottom(); + } else { + return false; + } + this.#syncFollow(); + this.deps.requestRender(); + return true; + } + + #syncFollow(): void { + this.#followBottom = this.#scrollView.getScrollOffset() >= this.#scrollView.getMaxScrollOffset(); + } + + #submit(text: string): void { + const trimmed = text.trim(); + this.#editor?.setText(""); + if (!trimmed) return; + this.#notice = undefined; + const id = this.deps.agentId; + if (this.deps.remote) { + this.deps.remote.chat(id, trimmed); + this.deps.requestRender(); + return; + } + const lifecycle = this.deps.lifecycle; + if (!lifecycle) return; + void (async () => { + try { + // Revives a parked agent; returns the live session for running/idle. + const session = await lifecycle().ensureLive(id); + // Steers a mid-turn agent; sends a normal prompt to an idle one. + await session.prompt(trimmed, { streamingBehavior: "steer" }); + } catch (error) { + this.#notice = error instanceof Error ? error.message : String(error); + } + this.deps.requestRender(); + })(); + this.deps.requestRender(); + } + + // ======================================================================== + // Render + // ======================================================================== + + render(width: number): readonly string[] { + const termHeight = process.stdout.rows || 40; + // `innerWidth` widths the editor/notice chrome (gutter-prefixed below). + // `contentWidth` widths the transcript: ScrollView reserves the last column + // for the scrollbar, and the transcript components carry their own 1-col left + // gutter — so body rows are emitted WITHOUT an extra outer space, sharing that + // gutter with the header/footer (which add one). Stacking both shifted the body + // one column right of the title. + const innerWidth = Math.max(20, width - 2); + const contentWidth = Math.max(1, width - 1); + const ref = this.deps.registry.get(this.deps.agentId); + + const headerLines = this.#headerLines(ref?.status, ref?.kind, ref?.parentId); + const footerLines = this.#footerLines(); + const noticeLine = this.#notice ? ` ${theme.fg("error", this.#notice)}` : undefined; + const editorLines = this.#editor ? this.#editor.render(innerWidth) : []; + + // Chrome: top border + header rows + divider border + (notice) + editor + footer + bottom border. + const chrome = headerLines.length + 2 + editorLines.length + footerLines.length + (noticeLine ? 1 : 0) + 1; + const viewportHeight = Math.max(3, termHeight - chrome); + + const contentLines = this.#builder.isEmpty + ? [` ${theme.fg("dim", this.#placeholder())}`] + : this.#builder.container.render(contentWidth); + this.#scrollView.setLines(contentLines); + this.#scrollView.setHeight(viewportHeight); + if (this.#followBottom) this.#scrollView.scrollToBottom(); + + const lines: string[] = []; + lines.push(...new DynamicBorder().render(width)); + for (const headerLine of headerLines) lines.push(` ${headerLine}`); + lines.push(...new DynamicBorder().render(width)); + for (const row of this.#scrollView.render(width)) lines.push(row); + if (noticeLine) lines.push(noticeLine); + for (const editorLine of editorLines) lines.push(` ${editorLine}`); + lines.push(...footerLines); + lines.push(...new DynamicBorder().render(width)); + return lines; + } + + #headerLines(status: AgentStatus | undefined, kind: string | undefined, parentId: string | undefined): string[] { + const lines = [theme.fg("accent", `Agent Hub ${theme.sep.dot} ${this.deps.agentId}`)]; + if (status && kind) { + const kindTag = theme.fg("dim", ` ${parentId ? `${kind} ${theme.sep.dot} of ${parentId}` : kind}`); + const modelLabel = this.#model ? theme.fg("muted", `${theme.sep.dot}${this.#model}`) : ""; + lines.push(`${theme.bold(this.deps.agentId)} ${statusBadge(status)}${kindTag}${modelLabel}`); + } + return lines; + } + + #footerLines(): string[] { + const lines: string[] = []; + const statsLine = this.#statsLine(); + if (statsLine) lines.push(` ${statsLine}`); + const hint = this.#editor + ? `Enter:send Esc:close ${this.deps.expandKeys[0] ?? "ctrl+o"}:expand empty input → j/k:scroll g/G:top/bottom` + : `Esc:close ${this.deps.expandKeys[0] ?? "ctrl+o"}:expand j/k:scroll g/G:top/bottom`; + lines.push(` ${theme.fg("dim", hint)}`); + return lines; + } + + #statsLine(): string { + const observed: ObservableSession | undefined = this.deps.observers + ?.getSessions() + .find(s => s.id === this.deps.agentId); + const progress = observed?.progress; + if (!progress) return ""; + const stats: string[] = []; + if (progress.contextTokens && progress.contextTokens > 0) { + stats.push( + progress.contextWindow && progress.contextWindow > 0 + ? formatContextUsage((progress.contextTokens / progress.contextWindow) * 100, progress.contextWindow) + : formatNumber(progress.contextTokens), + ); + } + if (progress.durationMs > 0) stats.push(formatDuration(progress.durationMs)); + const parts: string[] = []; + if (stats.length > 0 || progress.toolCount > 0) { + const toolStat = + progress.toolCount > 0 ? `${formatNumber(progress.toolCount)} ${theme.icon.extensionTool}` : ""; + parts.push(theme.fg("dim", [toolStat, ...stats].filter(Boolean).join(theme.sep.dot))); + } + if (progress.cost > 0) parts.push(theme.fg("statusLineCost", `$${progress.cost.toFixed(2)}`)); + return parts.join(theme.sep.dot); + } + + #placeholder(): string { + if (this.deps.remote && this.#remoteUnavailable) return "Transcript lives on the host — not available."; + if (this.deps.remote && !this.#hasRemoteData) return "Loading transcript from host…"; + if (!this.deps.registry.get(this.deps.agentId)?.sessionFile) return "No session file available yet."; + return "No messages yet."; + } +} diff --git a/packages/coding-agent/src/modes/components/assistant-message.ts b/packages/coding-agent/src/modes/components/assistant-message.ts index 5a351750e..a993d0e90 100644 --- a/packages/coding-agent/src/modes/components/assistant-message.ts +++ b/packages/coding-agent/src/modes/components/assistant-message.ts @@ -5,6 +5,7 @@ import { getMarkdownTheme, theme } from "../../modes/theme/theme"; import { resolveAbortLabel, shouldRenderAbortReason } from "../../session/messages"; import { getPreviewLines, resolveImageOptions, TRUNCATE_LENGTHS } from "../../tools/render-utils"; import { canonicalizeMessage } from "../../utils/thinking-display"; +import { type CacheInvalidation, CacheInvalidationMarkerComponent } from "./cache-invalidation-marker"; /** * Max lines of a turn-ending provider error rendered inline in the transcript. @@ -29,6 +30,7 @@ const THINKING_DOTS_FRAME_MS = 320; */ export class AssistantMessageComponent extends Container { #contentContainer: Container; + #markerSlot: Container; #lastMessage?: AssistantMessage; #toolImagesByCallId = new Map(); #convertedKittyImages = new Map(); @@ -75,6 +77,11 @@ export class AssistantMessageComponent extends Container { super(); this.#transcriptBlockFinalized = message !== undefined; + // Slim cache-invalidation divider, populated above the content when this + // turn's request lost the prompt cache (see setCacheInvalidation). + this.#markerSlot = new Container(); + this.addChild(this.#markerSlot); + // Container for text/thinking content this.#contentContainer = new Container(); this.addChild(this.#contentContainer); @@ -84,6 +91,20 @@ export class AssistantMessageComponent extends Container { } } + /** + * Show or clear the slim cache-invalidation divider above this turn. Set at + * `message_end` (live) or during rebuild, once the turn's usage is known and + * compared against the previous turn's cache footprint. Bumps the transcript + * block version so the change repaints even after content finalized. + */ + setCacheInvalidation(info: CacheInvalidation | undefined): void { + this.#markerSlot.clear(); + if (info) { + this.#markerSlot.addChild(new CacheInvalidationMarkerComponent(info)); + } + this.#blockVersion++; + } + override invalidate(): void { super.invalidate(); // Theme/symbol changes arrive via invalidate(). Fast-path children captured diff --git a/packages/coding-agent/src/modes/components/branch-summary-message.ts b/packages/coding-agent/src/modes/components/branch-summary-message.ts deleted file mode 100644 index 80ee3515e..000000000 --- a/packages/coding-agent/src/modes/components/branch-summary-message.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Box, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui"; -import { getMarkdownTheme, theme } from "../../modes/theme/theme"; -import type { BranchSummaryMessage } from "../../session/messages"; - -/** - * Component that renders a branch summary message with collapsed/expanded state. - * Uses same background color as hook messages for visual consistency. - */ -export class BranchSummaryMessageComponent extends Box { - #expanded = false; - - constructor(private readonly message: BranchSummaryMessage) { - super(1, 1, t => theme.bg("customMessageBg", t)); - this.setIgnoreTight(true); - this.#updateDisplay(); - } - - setExpanded(expanded: boolean): void { - this.#expanded = expanded; - this.#updateDisplay(); - } - - override invalidate(): void { - super.invalidate(); - this.#updateDisplay(); - } - - #updateDisplay(): void { - this.clear(); - - const label = theme.fg("customMessageLabel", theme.bold("[branch]")); - this.addChild(new Text(label, 0, 0)); - this.addChild(new Spacer(1)); - - if (this.#expanded) { - const header = "**Branch Summary**\n\n"; - this.addChild( - new Markdown(header + this.message.summary, 0, 0, getMarkdownTheme(), { - color: (text: string) => theme.fg("customMessageText", text), - }), - ); - } else { - this.addChild(new Text(theme.fg("customMessageText", "Branch summary (ctrl+o to expand)"), 0, 0)); - } - } -} diff --git a/packages/coding-agent/src/modes/components/cache-invalidation-marker.ts b/packages/coding-agent/src/modes/components/cache-invalidation-marker.ts new file mode 100644 index 000000000..daba03a02 --- /dev/null +++ b/packages/coding-agent/src/modes/components/cache-invalidation-marker.ts @@ -0,0 +1,94 @@ +import type { Usage } from "@oh-my-pi/pi-ai"; +import type { Component } from "@oh-my-pi/pi-tui"; +import { formatNumber } from "@oh-my-pi/pi-utils"; +import { theme } from "../../modes/theme/theme"; + +/** + * Minimum cached prefix (read + write) the previous turn must have established + * before a collapse on the current turn counts as an invalidation. Filters out + * tiny contexts and providers below the cacheable-prefix floor, where a zero + * `cacheRead` is expected rather than a reset. + */ +const MIN_CACHE_FOOTPRINT = 2048; + +/** A prompt-cache invalidation detected from a turn's usage. */ +export interface CacheInvalidation { + /** Prompt tokens the cold turn had to (re)process instead of reading from cache. */ + reprocessedTokens: number; +} + +/** + * Decide whether `current` turn lost the prompt cache that `prev` established. + * + * The provider reports a warm prefix as `cacheRead`; a model/thinking/tool/ + * system-prompt change (or a history rewrite) breaks the prefix, so the next + * request reads nothing from cache and re-pays for the whole prompt. We detect + * that as: the previous turn cached a meaningful prefix, yet this turn's + * `cacheRead` collapsed to zero while it still reprocessed a non-trivial prompt. + * Returns `undefined` (no marker) for the first turn, tiny contexts, turns + * that reused any cache, and — crucially — turns on providers with *implicit* + * best-effort caching. Only an explicit, prefix-controlled cache (Anthropic / + * Bedrock `cache_control`) re-creates the prefix on a cold turn (`cacheWrite > + * 0`); implicit caches (Google / OpenAI / Fireworks) report `cacheWrite: 0` and + * drop `cacheRead` to zero intermittently as routine propagation noise that + * self-heals the next turn, so flagging it would be a false positive. + */ +export function detectCacheInvalidation(prev: Usage | undefined, current: Usage): CacheInvalidation | undefined { + if (!prev) return undefined; + const prevFootprint = prev.cacheRead + prev.cacheWrite; + if (prevFootprint < MIN_CACHE_FOOTPRINT) return undefined; + // Any cache reuse this turn means the prefix survived (at least partly). + if (current.cacheRead > 0) return undefined; + // Only an explicit, prefix-controlled cache re-creates the prefix on a cold + // turn — Anthropic/Bedrock report that as `cacheWrite`. Implicit best-effort + // caches (Google/OpenAI/Fireworks) report `cacheWrite: 0` and drop `cacheRead` + // to zero intermittently as propagation noise, not a real invalidation. + if (current.cacheWrite <= 0) return undefined; + const reprocessedTokens = current.cacheWrite + current.input; + if (reprocessedTokens < MIN_CACHE_FOOTPRINT) return undefined; + return { reprocessedTokens }; +} + +const CACHE_INVALIDATION_RULE_WIDTH = 10; + +/** + * Slim left-aligned divider rendered above an assistant turn whose request lost + * the prompt cache. Mirrors the compaction divider's banner styling but spans + * only a short rule plus label (not the full width) and carries no expandable + * detail: + * + * ────────── ⊘ cache miss · 50.9k tokens + */ +export class CacheInvalidationMarkerComponent implements Component { + #cache?: { width: number; lines: string[] }; + + constructor(private readonly info: CacheInvalidation) {} + + invalidate(): void { + this.#cache = undefined; + } + + render(width: number): readonly string[] { + width = Math.max(1, width); + if (this.#cache?.width === width) { + return this.#cache.lines; + } + const lines = ["", this.#divider(width), ""]; + this.#cache = { width, lines }; + return lines; + } + + #divider(width: number): string { + const icon = theme.icon.cacheMiss; + const head = icon ? `${icon} cache miss` : "cache miss"; + const tokens = this.info.reprocessedTokens; + const label = tokens > 0 ? `${head} ${theme.sep.dot.trim()} ${formatNumber(tokens)} tokens` : head; + const labelWidth = Bun.stringWidth(label, { countAnsiEscapeCodes: false }); + const ruleWidth = Math.min(CACHE_INVALIDATION_RULE_WIDTH, width - labelWidth - 1); + if (ruleWidth < 1) { + // Too narrow to frame — emit the bare label. + return theme.fg("muted", label); + } + return `${theme.fg("dim", theme.tree.horizontal.repeat(ruleWidth))} ${theme.fg("muted", label)}`; + } +} diff --git a/packages/coding-agent/src/modes/components/chat-transcript-builder.ts b/packages/coding-agent/src/modes/components/chat-transcript-builder.ts new file mode 100644 index 000000000..513629dc0 --- /dev/null +++ b/packages/coding-agent/src/modes/components/chat-transcript-builder.ts @@ -0,0 +1,476 @@ +/** + * Builds transcript components from persisted session message entries — the + * file/remote-backed counterpart to {@link UiHelpers.addMessageToChat} (which is + * bound to the live InteractiveModeContext). Used by the fullscreen transcript + * viewer ({@link AgentTranscriptViewer}) to render a parked subagent / advisor / + * collab-guest transcript that has no live session. + * + * Unlike the old incremental hub sync, {@link ChatTranscriptBuilder.rebuild} + * always discards prior components and rebuilds the whole transcript from the + * supplied entries. Re-rendering a growing transcript is therefore O(n) in the + * entry count, but it cannot duplicate or misorder rows the way incremental + * component reuse could. + */ +import type { AgentMessage, AgentTool } from "@oh-my-pi/pi-agent-core"; +import type { Usage } from "@oh-my-pi/pi-ai"; +import { Text, type TUI } from "@oh-my-pi/pi-tui"; +import { formatBytes, formatDuration } from "@oh-my-pi/pi-utils"; +import type { AdvisorMessageDetails } from "../../advisor"; +import { COLLAB_PROMPT_MESSAGE_TYPE, type CollabPromptDetails } from "../../collab/protocol"; +import { settings } from "../../config/settings"; +import type { MessageRenderer } from "../../extensibility/extensions/types"; +import { + BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE, + type CustomMessage, + isSilentAbort, + LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE, + resolveAbortLabel, + SKILL_PROMPT_MESSAGE_TYPE, + type SkillPromptDetails, +} from "../../session/messages"; +import type { SessionMessageEntry } from "../../session/session-entries"; +import { createIrcMessageCard } from "../../tools/irc"; +import { canonicalizeMessage } from "../../utils/thinking-display"; +import { theme } from "../theme/theme"; +import { createAdvisorMessageCard } from "./advisor-message"; +import { AssistantMessageComponent } from "./assistant-message"; +import { createBackgroundTanDispatchBlock } from "./background-tan-message"; +import { BashExecutionComponent } from "./bash-execution"; +import { detectCacheInvalidation } from "./cache-invalidation-marker"; +import { CollabPromptMessageComponent } from "./collab-prompt-message"; +import { + BranchSummaryMessageComponent, + CompactionSummaryMessageComponent, + createHandoffSummaryMessageComponent, +} from "./compaction-summary-message"; +import { CustomMessageComponent } from "./custom-message"; +import { EvalExecutionComponent } from "./eval-execution"; +import { type LateDiagnosticsFile, LateDiagnosticsMessageComponent } from "./late-diagnostics-message"; +import { ReadToolGroupComponent, readArgsHaveTarget, readArgsTargetInternalUrl } from "./read-tool-group"; +import { SkillMessageComponent } from "./skill-message"; +import { ToolExecutionComponent } from "./tool-execution"; +import { TranscriptBlock, TranscriptContainer } from "./transcript-container"; +import { createUsageRowBlock } from "./usage-row"; +import { UserMessageComponent } from "./user-message"; + +export interface ChatTranscriptBuilderDeps { + ui: TUI; + getTool?: (name: string) => AgentTool | undefined; + getMessageRenderer?: (customType: string) => MessageRenderer | undefined; + cwd: string; + hideThinkingBlock?: () => boolean; + requestRender: () => void; +} + +/** Extracts the plain-text content of a user message (string or text blocks). */ +function userMessageText(message: Extract): string { + if (typeof message.content === "string") return message.content; + return message.content + .filter((block): block is { type: "text"; text: string } => block.type === "text") + .map(block => block.text) + .join(""); +} + +export class ChatTranscriptBuilder { + readonly container = new TranscriptContainer(); + #pendingTools = new Map(); + #readArgs = new Map>(); + #readGroup: ReadToolGroupComponent | null = null; + #pendingUsage: Usage | undefined; + #lastAssistantUsage: Usage | undefined; + #waitingPoll: ToolExecutionComponent | null = null; + #expandables: Array<{ setExpanded(expanded: boolean): void }> = []; + #expanded = false; + + constructor(private readonly deps: ChatTranscriptBuilderDeps) {} + + /** Whether the transcript currently holds any rendered rows. */ + get isEmpty(): boolean { + return this.container.children.length === 0; + } + + /** Discard all components and rebuild the whole transcript from `entries`. */ + rebuild(entries: SessionMessageEntry[]): void { + this.reset(); + for (const entry of entries) this.#appendChatMessage(entry.message); + // Flush the trailing turn's usage row only once its tools are materialized + // (a read whose result has not arrived stays pending); otherwise the row + // would sit above its tools. The drain happens here at the end of the pass. + if (this.#readArgs.size === 0 && this.#pendingTools.size === 0) this.#flushPendingUsage(); + } + + /** Toggle tool-output expansion across every expandable component. */ + setExpanded(expanded: boolean): void { + this.#expanded = expanded; + for (const component of this.#expandables) component.setExpanded(expanded); + } + + get expanded(): boolean { + return this.#expanded; + } + + /** Tear down components (sealing pending spinners) and clear build state. */ + reset(): void { + for (const pending of this.#pendingTools.values()) pending.seal(); + this.#pendingTools.clear(); + this.#readArgs.clear(); + this.#readGroup = null; + this.#pendingUsage = undefined; + this.#lastAssistantUsage = undefined; + this.#waitingPoll = null; + this.#expandables = []; + this.container.dispose(); + this.container.clear(); + } + + dispose(): void { + this.reset(); + } + + #trackExpandable(component: { setExpanded(expanded: boolean): void }): void { + component.setExpanded(this.#expanded); + this.#expandables.push(component); + } + + /** A `job` poll showing all-running is displaced by the next `job` call. */ + #resolveWaitingPoll(nextToolName?: string): void { + const previous = this.#waitingPoll; + if (!previous) return; + this.#waitingPoll = null; + if (nextToolName === "job" && previous.isDisplaceableBlock()) { + this.container.removeChild(previous); + } + previous.seal(); + } + + #ensureReadGroup(): ReadToolGroupComponent { + if (!this.#readGroup) { + this.#readGroup = new ReadToolGroupComponent({ + showContentPreview: settings.get("read.toolResultPreview"), + }); + this.#trackExpandable(this.#readGroup); + this.container.addChild(this.#readGroup); + } + return this.#readGroup; + } + + // The per-turn token-usage row must land below the turn's tool blocks, but + // normal `read` calls only materialize their group in #appendToolResult. Defer + // the row: stash it on the assistant message and flush once the turn's tools + // are placed, sealing the read run so the row sits under it. + #flushPendingUsage(): void { + if (!this.#pendingUsage) return; + this.#readGroup?.seal(); + this.#readGroup = null; + this.container.addChild(createUsageRowBlock(this.#pendingUsage)); + this.#pendingUsage = undefined; + } + + #appendChatMessage(message: AgentMessage): void { + if (message.role !== "toolResult") this.#flushPendingUsage(); + switch (message.role) { + case "assistant": + this.#appendAssistantMessage(message); + break; + case "toolResult": + this.#appendToolResult(message); + break; + case "user": + case "developer": { + // A user prompt closes the poll-displacement window, same as the live path. + if (message.role === "user") this.#resolveWaitingPoll(); + const textContent = message.role === "user" ? userMessageText(message) : ""; + if (textContent) { + const isSynthetic = message.role === "developer" ? true : (message.synthetic ?? false); + this.container.addChild(new UserMessageComponent(textContent, isSynthetic)); + } + break; + } + case "bashExecution": { + const component = new BashExecutionComponent(message.command, this.deps.ui, message.excludeFromContext); + if (message.output) component.appendOutput(message.output); + component.setComplete(message.exitCode, message.cancelled, { truncation: message.meta?.truncation }); + this.container.addChild(component); + break; + } + case "pythonExecution": { + const component = new EvalExecutionComponent(message.code, this.deps.ui, message.excludeFromContext); + if (message.output) component.appendOutput(message.output); + component.setComplete(message.exitCode, message.cancelled, { truncation: message.meta?.truncation }); + this.container.addChild(component); + break; + } + case "hookMessage": + case "custom": + this.#appendCustomMessage(message); + break; + case "compactionSummary": { + const component = new CompactionSummaryMessageComponent(message); + this.#trackExpandable(component); + this.container.addChild(component); + break; + } + case "branchSummary": { + const component = new BranchSummaryMessageComponent(message); + this.#trackExpandable(component); + this.container.addChild(component); + break; + } + case "fileMention": { + const block = new TranscriptBlock(); + for (const file of message.files) { + let suffix: string; + if (file.skippedReason === "tooLarge") { + const size = typeof file.byteSize === "number" ? formatBytes(file.byteSize) : "unknown size"; + suffix = `(skipped: ${size})`; + } else { + suffix = file.image + ? "(image)" + : file.lineCount === undefined + ? "(unknown lines)" + : `(${file.lineCount} lines)`; + } + const text = `${theme.fg("dim", `${theme.tree.last} `)}${theme.fg("muted", "Read")} ${theme.fg( + "accent", + file.path, + )} ${theme.fg("dim", suffix)}`; + // Indent one column to match the transcript's other rows (the viewer renders + // body rows without an outer gutter; rows own their left pad). + block.addChild(new Text(text, 1, 0)); + } + if (block.children.length > 0) this.container.addChild(block); + break; + } + default: + message satisfies never; + } + } + + #appendAssistantMessage(message: Extract): void { + const assistantComponent = new AssistantMessageComponent(message, this.deps.hideThinkingBlock?.() ?? false, () => + this.deps.requestRender(), + ); + this.container.addChild(assistantComponent); + + if (settings.get("display.cacheMissMarker")) { + const invalidation = detectCacheInvalidation(this.#lastAssistantUsage, message.usage); + if (invalidation) assistantComponent.setCacheInvalidation(invalidation); + } + if (message.usage.cacheRead + message.usage.cacheWrite + message.usage.input > 0) { + this.#lastAssistantUsage = message.usage; + } + + const hasVisibleAssistantContent = message.content.some( + content => + (content.type === "text" && canonicalizeMessage(content.text)) || + (content.type === "thinking" && canonicalizeMessage(content.thinking)), + ); + if (hasVisibleAssistantContent) { + // New visible turn content closes the current read run (mirrors rebuild). + this.#readGroup?.seal(); + this.#readGroup = null; + } + + const isAbortedSilently = message.stopReason === "aborted" && isSilentAbort(message.errorMessage); + const hasErrorStop = !isAbortedSilently && (message.stopReason === "aborted" || message.stopReason === "error"); + const errorMessage = hasErrorStop + ? message.stopReason === "aborted" + ? resolveAbortLabel(message.errorMessage) + : message.errorMessage || "Error" + : null; + + for (const content of message.content) { + if (content.type !== "toolCall") continue; + this.#resolveWaitingPoll(content.name); + + if ( + content.name === "read" && + readArgsHaveTarget(content.arguments) && + !readArgsTargetInternalUrl(content.arguments) + ) { + if (hasErrorStop && errorMessage) { + const group = this.#ensureReadGroup(); + group.updateArgs(content.arguments, content.id); + group.updateResult( + { content: [{ type: "text", text: errorMessage }], isError: true }, + false, + content.id, + ); + } else { + const normalizedArgs = + content.arguments && typeof content.arguments === "object" && !Array.isArray(content.arguments) + ? (content.arguments as Record) + : {}; + this.#readArgs.set(content.id, normalizedArgs); + } + continue; + } + + this.#readGroup?.seal(); + this.#readGroup = null; + const component = new ToolExecutionComponent( + content.name, + content.arguments, + { + // Images can't be sliced through the scroll viewport; keep them off. + showImages: false, + editFuzzyThreshold: settings.get("edit.fuzzyThreshold"), + editAllowFuzzy: settings.get("edit.fuzzyMatch"), + liveRegion: this.container, + }, + this.deps.getTool?.(content.name), + this.deps.ui, + this.deps.cwd, + content.id, + ); + this.#trackExpandable(component); + this.container.addChild(component); + + if (hasErrorStop && errorMessage) { + component.updateResult( + { content: [{ type: "text", text: errorMessage }], isError: true }, + false, + content.id, + ); + } else { + this.#pendingTools.set(content.id, component); + } + } + + this.#pendingUsage = settings.get("display.showTokenUsage") ? message.usage : undefined; + } + + #appendToolResult(message: Extract): void { + const pending = this.#pendingTools.get(message.toolCallId); + const isReadGroupResult = message.toolName === "read" && (!pending || pending instanceof ReadToolGroupComponent); + if (isReadGroupResult) { + let component = pending; + if (!component) { + const group = this.#ensureReadGroup(); + const args = this.#readArgs.get(message.toolCallId); + if (args) group.updateArgs(args, message.toolCallId); + component = group; + } + component.updateResult(message, false, message.toolCallId); + this.#pendingTools.delete(message.toolCallId); + this.#readArgs.delete(message.toolCallId); + return; + } + if (!pending) return; + pending.updateResult(message, false, message.toolCallId); + this.#pendingTools.delete(message.toolCallId); + if (message.toolName === "job" && pending instanceof ToolExecutionComponent && pending.isDisplaceableBlock()) { + this.#waitingPoll = pending; + } + } + + #appendCustomMessage(message: Extract): void { + if (!message.display) return; + if (message.customType === "async-result") { + const details = ( + message as CustomMessage<{ + jobId?: string; + type?: "bash" | "task"; + label?: string; + durationMs?: number; + jobs?: Array<{ jobId?: string; type?: "bash" | "task"; label?: string; durationMs?: number }>; + }> + ).details; + const jobs = + details?.jobs && details.jobs.length > 0 + ? details.jobs + : [ + { + jobId: details?.jobId, + type: details?.type, + label: details?.label, + durationMs: details?.durationMs, + }, + ]; + const block = new TranscriptBlock(); + for (const job of jobs) { + const jobId = job.jobId ?? "unknown"; + const typeLabel = job.type ? `[${job.type}]` : "[job]"; + const duration = typeof job.durationMs === "number" ? formatDuration(job.durationMs) : undefined; + const line = [ + theme.fg("success", `${theme.status.done} Background job completed`), + theme.fg("dim", typeLabel), + theme.fg("accent", jobId), + duration ? theme.fg("dim", `(${duration})`) : undefined, + ] + .filter(Boolean) + .join(" "); + block.addChild(new Text(line, 1, 0)); + } + this.container.addChild(block); + return; + } + if (message.customType === LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE) { + const details = (message as CustomMessage<{ files?: LateDiagnosticsFile[] }>).details; + const component = new LateDiagnosticsMessageComponent(details?.files ?? []); + this.#trackExpandable(component); + this.container.addChild(component); + return; + } + if (message.customType === COLLAB_PROMPT_MESSAGE_TYPE) { + this.container.addChild(new CollabPromptMessageComponent(message as CustomMessage)); + return; + } + if (message.customType === SKILL_PROMPT_MESSAGE_TYPE) { + const component = new SkillMessageComponent(message as CustomMessage); + this.#trackExpandable(component); + this.container.addChild(component); + return; + } + if ( + message.customType === "irc:incoming" || + message.customType === "irc:autoreply" || + message.customType === "irc:relay" + ) { + const details = ( + message as CustomMessage<{ from?: string; to?: string; message?: string; body?: string; replyTo?: string }> + ).details; + const kind = + message.customType === "irc:incoming" + ? ("incoming" as const) + : message.customType === "irc:autoreply" + ? ("autoreply" as const) + : ("relay" as const); + const card = createIrcMessageCard( + { + kind, + from: details?.from, + to: details?.to, + body: kind === "incoming" ? details?.message : details?.body, + replyTo: details?.replyTo, + timestamp: message.timestamp, + }, + () => this.#expanded, + theme, + ); + this.container.addChild(card); + return; + } + if (message.customType === "advisor") { + const details = (message as CustomMessage).details; + this.container.addChild(createAdvisorMessageCard(details, () => this.#expanded, theme)); + return; + } + if (message.customType === BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE) { + this.container.addChild(createBackgroundTanDispatchBlock(message as CustomMessage)); + return; + } + const handoffComponent = createHandoffSummaryMessageComponent(message as CustomMessage, this.#expanded); + if (handoffComponent) { + this.#trackExpandable(handoffComponent); + this.container.addChild(handoffComponent); + return; + } + const component = new CustomMessageComponent( + message as CustomMessage, + this.deps.getMessageRenderer?.(message.customType), + ); + this.#trackExpandable(component); + this.container.addChild(component); + } +} diff --git a/packages/coding-agent/src/modes/components/compaction-summary-message.ts b/packages/coding-agent/src/modes/components/compaction-summary-message.ts index 9bbfed793..04aeb9ece 100644 --- a/packages/coding-agent/src/modes/components/compaction-summary-message.ts +++ b/packages/coding-agent/src/modes/components/compaction-summary-message.ts @@ -1,6 +1,6 @@ import { Box, type Component, Markdown } from "@oh-my-pi/pi-tui"; import { getMarkdownTheme, theme } from "../../modes/theme/theme"; -import type { CompactionSummaryMessage, CustomMessage } from "../../session/messages"; +import type { BranchSummaryMessage, CompactionSummaryMessage, CustomMessage } from "../../session/messages"; interface SummaryDividerOptions { label: () => string; @@ -156,6 +156,34 @@ export function createHandoffSummaryMessageComponent( return component; } +/** + * A branch summary collapses a side branch back into the main line. Render it + * with the same slim divider as `/compact` and handoff rather than a `[branch]` + * box, so every history-collapse point reads as one consistent banner. + */ +export class BranchSummaryMessageComponent implements Component { + #divider: SummaryDividerComponent; + + constructor(private readonly message: BranchSummaryMessage) { + this.#divider = new SummaryDividerComponent({ + label: () => `${theme.icon.branch} branch`, + detailMarkdown: () => `**Branch summary**\n\n${this.message.summary}`, + }); + } + + setExpanded(expanded: boolean): void { + this.#divider.setExpanded(expanded); + } + + invalidate(): void { + this.#divider.invalidate(); + } + + render(width: number): readonly string[] { + return this.#divider.render(width); + } +} + function getCustomMessageText(message: CustomMessage): string { if (typeof message.content === "string") return message.content; let firstText: string | undefined; diff --git a/packages/coding-agent/src/modes/components/custom-message.ts b/packages/coding-agent/src/modes/components/custom-message.ts index 67f699190..df2630fbe 100644 --- a/packages/coding-agent/src/modes/components/custom-message.ts +++ b/packages/coding-agent/src/modes/components/custom-message.ts @@ -46,12 +46,15 @@ export class CustomMessageComponent extends Container { } this.removeChild(this.#box); + // The transcript dispatch routes both `custom` and legacy `hookMessage` roles here: + // tag hooks with the hook glyph, other injected messages with a neutral package. + const isHook = (this.message.role as string) === "hookMessage"; const custom = renderFramedMessage({ message: this.message, box: this.#box, expanded: this.#expanded, customRenderer: this.customRenderer, - // Extension messages render full content; no collapse-on-fold behaviour. + icon: isHook ? theme.icon.extensionHook : theme.icon.package, }); if (custom) { diff --git a/packages/coding-agent/src/modes/components/diff.ts b/packages/coding-agent/src/modes/components/diff.ts index 33d1c161f..bccbbe874 100644 --- a/packages/coding-agent/src/modes/components/diff.ts +++ b/packages/coding-agent/src/modes/components/diff.ts @@ -1,4 +1,4 @@ -import { getIndentation, sanitizeText } from "@oh-my-pi/pi-utils"; +import { DEFAULT_TAB_WIDTH, sanitizeText } from "@oh-my-pi/pi-utils"; import * as Diff from "diff"; import { getLanguageFromPath, highlightCode, theme } from "../../modes/theme/theme"; import { type CodeFrameMarker, formatCodeFrameLine, replaceTabs } from "../../tools/render-utils"; @@ -13,12 +13,12 @@ const DIM_OFF = "\x1b[22m"; * before the first non-whitespace character; remaining tabs in code * content are replaced with spaces (like replaceTabs). */ -function visualizeIndent(text: string, filePath?: string): string { +function visualizeIndent(text: string): string { const match = text.match(/^([ \t]+)/); - if (!match) return replaceTabs(text, filePath); + if (!match) return replaceTabs(text); const indent = match[1]; const rest = text.slice(indent.length); - const tabWidth = getIndentation(filePath); + const tabWidth = DEFAULT_TAB_WIDTH; const leftPadding = Math.floor(tabWidth / 2); const rightPadding = Math.max(0, tabWidth - leftPadding - 1); const tabMarker = `${DIM}${" ".repeat(leftPadding)}→${" ".repeat(rightPadding)}${DIM_OFF}`; @@ -30,7 +30,7 @@ function visualizeIndent(text: string, filePath?: string): string { visible += `${DIM}·${DIM_OFF}`; } } - return `${visible}${replaceTabs(rest, filePath)}`; + return `${visible}${replaceTabs(rest)}`; } /** @@ -153,7 +153,7 @@ export function renderDiff(diffText: string, options: RenderDiffOptions = {}): s // unicode ellipsis. const trimmed = line.trim(); const isGapRow = trimmed.length === 0 || trimmed === "..." || trimmed === "…"; - result.push(theme.fg("toolDiffContext", isGapRow ? "…" : replaceTabs(line, options.filePath))); + result.push(theme.fg("toolDiffContext", isGapRow ? "…" : replaceTabs(line))); i++; continue; } @@ -184,47 +184,24 @@ export function renderDiff(diffText: string, options: RenderDiffOptions = {}): s replaceTabs(added.content), ); - result.push( - theme.fg( - "toolDiffRemoved", - formatLine("-", removed.lineNum, visualizeIndent(removedLine, options.filePath)), - ), - ); - result.push( - theme.fg("toolDiffAdded", formatLine("+", added.lineNum, visualizeIndent(addedLine, options.filePath))), - ); + result.push(theme.fg("toolDiffRemoved", formatLine("-", removed.lineNum, visualizeIndent(removedLine)))); + result.push(theme.fg("toolDiffAdded", formatLine("+", added.lineNum, visualizeIndent(addedLine)))); } else { for (const removed of removedLines) { result.push( - theme.fg( - "toolDiffRemoved", - formatLine("-", removed.lineNum, visualizeIndent(removed.content, options.filePath)), - ), + theme.fg("toolDiffRemoved", formatLine("-", removed.lineNum, visualizeIndent(removed.content))), ); } for (const added of addedLines) { - result.push( - theme.fg( - "toolDiffAdded", - formatLine("+", added.lineNum, visualizeIndent(added.content, options.filePath)), - ), - ); + result.push(theme.fg("toolDiffAdded", formatLine("+", added.lineNum, visualizeIndent(added.content)))); } } } else if (parsed.prefix === "+") { - result.push( - theme.fg( - "toolDiffAdded", - formatLine("+", parsed.lineNum, visualizeIndent(parsed.content, options.filePath)), - ), - ); + result.push(theme.fg("toolDiffAdded", formatLine("+", parsed.lineNum, visualizeIndent(parsed.content)))); i++; } else { const highlighted = contextHighlights.get(i); - const content = - highlighted !== undefined - ? replaceTabs(highlighted, options.filePath) - : visualizeIndent(parsed.content, options.filePath); + const content = highlighted !== undefined ? replaceTabs(highlighted) : visualizeIndent(parsed.content); result.push(theme.fg("toolDiffContext", formatLine(" ", parsed.lineNum, content))); i++; } diff --git a/packages/coding-agent/src/modes/components/dynamic-border.ts b/packages/coding-agent/src/modes/components/dynamic-border.ts index 17dd0adf0..fb70166eb 100644 --- a/packages/coding-agent/src/modes/components/dynamic-border.ts +++ b/packages/coding-agent/src/modes/components/dynamic-border.ts @@ -26,7 +26,7 @@ export class DynamicBorder implements Component { if (this.#cachedLines && this.#cachedWidth === width) { return this.#cachedLines; } - const lines = [this.#color(theme.boxSharp.horizontal.repeat(Math.max(1, width)))]; + const lines = [this.#color(theme.boxRound.horizontal.repeat(Math.max(1, width)))]; this.#cachedWidth = width; this.#cachedLines = lines; return lines; diff --git a/packages/coding-agent/src/modes/components/extensions/extension-dashboard.ts b/packages/coding-agent/src/modes/components/extensions/extension-dashboard.ts index 0b94d6018..b2dc63640 100644 --- a/packages/coding-agent/src/modes/components/extensions/extension-dashboard.ts +++ b/packages/coding-agent/src/modes/components/extensions/extension-dashboard.ts @@ -380,7 +380,7 @@ class TwoColumnBody implements Component { // Fill the full body height so the dashboard reads as a full-screen view. const numLines = this.maxHeight; const combined: string[] = []; - const separator = theme.fg("dim", ` ${theme.boxSharp.vertical} `); + const separator = theme.fg("dim", ` ${theme.boxRound.vertical} `); for (let i = 0; i < numLines; i++) { const left = truncateToWidth(leftLines[i] ?? "", leftWidth); diff --git a/packages/coding-agent/src/modes/components/extensions/inspector-panel.ts b/packages/coding-agent/src/modes/components/extensions/inspector-panel.ts index ed2185e6c..4211d719b 100644 --- a/packages/coding-agent/src/modes/components/extensions/inspector-panel.ts +++ b/packages/coding-agent/src/modes/components/extensions/inspector-panel.ts @@ -107,7 +107,7 @@ export class InspectorPanel implements Component { #renderFilePreview(raw: unknown, width: number): string[] { const lines: string[] = []; lines.push(theme.fg("muted", "Preview:")); - lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40)))); + lines.push(theme.fg("dim", theme.boxRound.horizontal.repeat(Math.min(width - 2, 40)))); const content = this.#getContextFileContent(raw); if (!content) { @@ -165,7 +165,7 @@ export class InspectorPanel implements Component { #renderToolArgs(raw: unknown, width: number): string[] { const lines: string[] = []; lines.push(theme.fg("muted", "Arguments:")); - lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40)))); + lines.push(theme.fg("dim", theme.boxRound.horizontal.repeat(Math.min(width - 2, 40)))); try { const tool = raw as any; @@ -207,7 +207,7 @@ export class InspectorPanel implements Component { #renderSkillContent(raw: unknown, width: number): string[] { const lines: string[] = []; lines.push(theme.fg("muted", "Instruction:")); - lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40)))); + lines.push(theme.fg("dim", theme.boxRound.horizontal.repeat(Math.min(width - 2, 40)))); try { const skill = raw as any; @@ -236,7 +236,7 @@ export class InspectorPanel implements Component { #renderMcpDetails(raw: unknown, width: number): string[] { const lines: string[] = []; lines.push(theme.fg("muted", "Connection:")); - lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40)))); + lines.push(theme.fg("dim", theme.boxRound.horizontal.repeat(Math.min(width - 2, 40)))); try { const mcp = raw as any; @@ -275,7 +275,7 @@ export class InspectorPanel implements Component { // Show trigger pattern if present if (ext.trigger) { lines.push(theme.fg("muted", "Trigger:")); - lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40)))); + lines.push(theme.fg("dim", theme.boxRound.horizontal.repeat(Math.min(width - 2, 40)))); lines.push(` ${theme.fg("accent", ext.trigger)}`); lines.push(""); } diff --git a/packages/coding-agent/src/modes/components/hook-selector.ts b/packages/coding-agent/src/modes/components/hook-selector.ts index d79a61bda..600907290 100644 --- a/packages/coding-agent/src/modes/components/hook-selector.ts +++ b/packages/coding-agent/src/modes/components/hook-selector.ts @@ -123,7 +123,7 @@ class OutlinedList extends Container { render(width: number): readonly string[] { const borderColor = (text: string) => theme.fg("border", text); - const horizontal = borderColor(theme.boxSharp.horizontal.repeat(Math.max(1, width))); + const horizontal = borderColor(theme.boxRound.horizontal.repeat(Math.max(1, width))); const innerWidth = Math.max(1, width - 2); const content: string[] = []; for (const line of this.#lines) { @@ -134,7 +134,7 @@ class OutlinedList extends Container { const wrappedLine = `${indent}${wrappedBody}`; const pad = Math.max(0, innerWidth - visibleWidth(wrappedLine)); content.push( - `${borderColor(theme.boxSharp.vertical)}${wrappedLine}${padding(pad)}${borderColor(theme.boxSharp.vertical)}`, + `${borderColor(theme.boxRound.vertical)}${wrappedLine}${padding(pad)}${borderColor(theme.boxRound.vertical)}`, ); } } diff --git a/packages/coding-agent/src/modes/components/index.ts b/packages/coding-agent/src/modes/components/index.ts index 67de87b26..fd4c8bd88 100644 --- a/packages/coding-agent/src/modes/components/index.ts +++ b/packages/coding-agent/src/modes/components/index.ts @@ -2,7 +2,6 @@ export * from "./assistant-message"; export * from "./bash-execution"; export * from "./bordered-loader"; -export * from "./branch-summary-message"; export * from "./compaction-summary-message"; export * from "./countdown-timer"; export * from "./custom-editor"; diff --git a/packages/coding-agent/src/modes/components/message-frame.ts b/packages/coding-agent/src/modes/components/message-frame.ts index 57157cfe4..d1893d792 100644 --- a/packages/coding-agent/src/modes/components/message-frame.ts +++ b/packages/coding-agent/src/modes/components/message-frame.ts @@ -34,16 +34,18 @@ export interface RebuildFrameOptions { message: M; box: Box; expanded: boolean; + /** Icon glyph shown before the customType in the default header (e.g. a hook/extension icon). */ + icon?: string; /** Collapse the markdown body to this many lines when `expanded` is false. Omit to never collapse. */ collapseAfterLines?: number; customRenderer?: FramedRenderer; } /** - * Attempt the custom renderer; on failure or undefined return, populate - * `box` with the default `[customType]` label + markdown body and return - * undefined. When the custom renderer succeeds, return its Component so the - * caller can mount it and skip the default box. + * Attempt the custom renderer; on failure or undefined return, populate `box` + * with the default outlined card — an `icon customType` header + markdown body — + * and return undefined. When the custom renderer succeeds, return its Component + * so the caller can mount it and skip the default box. */ export function renderFramedMessage(opts: RebuildFrameOptions): Component | undefined { if (opts.customRenderer) { @@ -56,9 +58,11 @@ export function renderFramedMessage(opts: RebuildFrameO } opts.box.clear(); + // Match the skill card: a subtle rounded outline so injected messages read as cards. + opts.box.setBorder({ chars: theme.boxRound, color: t => theme.fg("borderMuted", t) }); - const label = theme.fg("customMessageLabel", theme.bold(`[${opts.message.customType}]`)); - opts.box.addChild(new Text(label, 0, 0)); + const tag = opts.icon ? `${opts.icon} ${opts.message.customType}` : opts.message.customType; + opts.box.addChild(new Text(theme.fg("customMessageLabel", theme.bold(tag)), 0, 0)); opts.box.addChild(new Spacer(1)); let text: string; diff --git a/packages/coding-agent/src/modes/components/model-selector.ts b/packages/coding-agent/src/modes/components/model-selector.ts index 31d2f24a1..7a9d75de5 100644 --- a/packages/coding-agent/src/modes/components/model-selector.ts +++ b/packages/coding-agent/src/modes/components/model-selector.ts @@ -1120,7 +1120,7 @@ export class ModelSelectorComponent extends Container { const menuWidth = contentWidth + (needsScroll ? 1 : 0); this.#menuContainer.addChild(new Spacer(1)); - this.#menuContainer.addChild(new Text(theme.fg("border", theme.boxSharp.horizontal.repeat(menuWidth)), 0, 0)); + this.#menuContainer.addChild(new Text(theme.fg("border", theme.boxRound.horizontal.repeat(menuWidth)), 0, 0)); if (showingThinking && this.#menuSelectedRole) { this.#menuContainer.addChild( new Text( @@ -1160,7 +1160,7 @@ export class ModelSelectorComponent extends Container { this.#menuContainer.addChild(new Spacer(1)); this.#menuContainer.addChild(new Text(theme.fg("dim", hintText), 0, 0)); - this.#menuContainer.addChild(new Text(theme.fg("border", theme.boxSharp.horizontal.repeat(menuWidth)), 0, 0)); + this.#menuContainer.addChild(new Text(theme.fg("border", theme.boxRound.horizontal.repeat(menuWidth)), 0, 0)); } #getMenuVisibleCount(optionCount: number): number { diff --git a/packages/coding-agent/src/modes/components/oauth-selector.ts b/packages/coding-agent/src/modes/components/oauth-selector.ts index 6a888768b..254cad6d6 100644 --- a/packages/coding-agent/src/modes/components/oauth-selector.ts +++ b/packages/coding-agent/src/modes/components/oauth-selector.ts @@ -10,6 +10,7 @@ import { Spacer, TruncatedText, } from "@oh-my-pi/pi-tui"; +import { settings } from "../../config/settings"; import { theme } from "../../modes/theme/theme"; import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers"; import type { AuthStorage, CredentialOriginKind } from "../../session/auth-storage"; @@ -17,6 +18,20 @@ import { DynamicBorder } from "./dynamic-border"; const OAUTH_SELECTOR_MAX_VISIBLE = 10; +/** + * Provider ids the user has disabled via settings. `/login` (login mode) hides + * these so a disabled provider's models stay out of reach end-to-end, mirroring + * the model picker's `disabledProviders` filtering. Reads the settings singleton + * defensively: it throws before `Settings.init()`, in which case nothing is disabled. + */ +function getDisabledProviderIds(): ReadonlySet { + try { + return new Set(settings.get("disabledProviders")); + } catch { + return new Set(); + } +} + /** * Rendered lines before the provider rows: top border, spacer, title, spacer * (must mirror the constructor's addChild order). @@ -102,8 +117,22 @@ export class OAuthSelectorComponent extends Container { #loadProviders(): void { const providers = getOAuthProviders(); - this.#allProviders = - this.#mode === "logout" ? providers.filter(provider => this.#hasSelectableAuth(provider.id)) : providers; + if (this.#mode === "logout") { + // Logout stays unfiltered by `disabledProviders`: a now-disabled + // provider may still hold stored credentials worth removing. + this.#allProviders = providers.filter(provider => this.#hasSelectableAuth(provider.id)); + } else { + const disabled = getDisabledProviderIds(); + // Hide a login entry when either its own id or the provider id it + // stores credentials under is disabled, so alias logins (e.g. + // `openai-codex-device` ⇒ `openai-codex`) disappear alongside the + // model provider they authenticate. + this.#allProviders = providers.filter( + provider => + !disabled.has(provider.id) && + !(provider.storeCredentialsAs && disabled.has(provider.storeCredentialsAs)), + ); + } this.#filteredProviders = this.#allProviders; } diff --git a/packages/coding-agent/src/modes/components/overlay-box.ts b/packages/coding-agent/src/modes/components/overlay-box.ts index e427e6a25..57bac05b4 100644 --- a/packages/coding-agent/src/modes/components/overlay-box.ts +++ b/packages/coding-agent/src/modes/components/overlay-box.ts @@ -1,7 +1,8 @@ /** * Shared box-drawing chrome for fullscreen overlays (the `/copy` picker, the - * plan-review overlay, …). Every helper paints with `theme.boxSharp` glyphs and - * the `border`/`accent` theme colors so all outlined overlays read identically. + * plan-review overlay, …). Every helper paints with `theme.boxRound` glyphs + * (rounded corners, sharp tee/cross junctions) and the `border`/`accent` theme + * colors so all outlined overlays read identically. */ import { padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui"; import { theme } from "../theme/theme"; @@ -23,7 +24,7 @@ function paint(s: string): string { /** Top border with an optional accent-colored title inset into the rule. */ export function topBorder(width: number, title: string): string { - const box = theme.boxSharp; + const box = theme.boxRound; const inner = Math.max(0, width - 2); if (!title) return paint(box.topLeft + box.horizontal.repeat(inner) + box.topRight); const shown = truncateToWidth(` ${title} `, Math.max(0, inner - 2)); @@ -37,18 +38,18 @@ export function topBorder(width: number, title: string): string { /** A horizontal rule with left/right tees, splitting overlay sections. */ export function divider(width: number): string { - const box = theme.boxSharp; + const box = theme.boxRound; return paint(box.teeRight + box.horizontal.repeat(Math.max(0, width - 2)) + box.teeLeft); } export function bottomBorder(width: number): string { - const box = theme.boxSharp; + const box = theme.boxRound; return paint(box.bottomLeft + box.horizontal.repeat(Math.max(0, width - 2)) + box.bottomRight); } /** Wrap pre-styled content in vertical borders with single-column insets. */ export function row(content: string, width: number): string { - const box = theme.boxSharp; + const box = theme.boxRound; return `${paint(box.vertical)} ${fit(content, Math.max(0, width - 4))} ${paint(box.vertical)}`; } @@ -70,7 +71,7 @@ export function splitBodyWidth(width: number, sidebarWidth: number): number { /** Top border carrying the title, split by a `┬` over the column divider. */ export function topBorderSplit(width: number, title: string, sidebarWidth: number): string { - const box = theme.boxSharp; + const box = theme.boxRound; const dividerCol = splitDividerCol(sidebarWidth); const leftLen = Math.max(0, dividerCol - 1); const rightLen = Math.max(0, width - 2 - dividerCol); @@ -90,7 +91,7 @@ export function topBorderSplit(width: number, title: string, sidebarWidth: numbe /** Section rule that closes the sidebar column with a `┴` over the divider. */ export function dividerSplit(width: number, sidebarWidth: number): string { - const box = theme.boxSharp; + const box = theme.boxRound; const dividerCol = splitDividerCol(sidebarWidth); const leftLen = Math.max(0, dividerCol - 1); const rightLen = Math.max(0, width - 2 - dividerCol); @@ -101,7 +102,7 @@ export function dividerSplit(width: number, sidebarWidth: number): string { /** A two-column content row: `│ sidebar │ body │`, each inset by one column. */ export function splitRow(sidebar: string, body: string, width: number, sidebarWidth: number): string { - const box = theme.boxSharp; + const box = theme.boxRound; const bodyWidth = splitBodyWidth(width, sidebarWidth); const bar = paint(box.vertical); return `${bar} ${fit(sidebar, sidebarWidth)} ${bar} ${fit(body, bodyWidth)} ${bar}`; diff --git a/packages/coding-agent/src/modes/components/settings-defs.ts b/packages/coding-agent/src/modes/components/settings-defs.ts index 5c8e82878..3c9d54577 100644 --- a/packages/coding-agent/src/modes/components/settings-defs.ts +++ b/packages/coding-agent/src/modes/components/settings-defs.ts @@ -76,6 +76,13 @@ export type SettingDef = BooleanSettingDef | EnumSettingDef | SubmenuSettingDef const CONDITIONS: Record boolean> = { hasImageProtocol: () => !!TERMINAL.imageProtocol, + advisorEnabled: () => { + try { + return Settings.instance.get("advisor.enabled") === true; + } catch { + return false; + } + }, hindsightActive: () => { try { return Settings.instance.get("memory.backend") === "hindsight"; diff --git a/packages/coding-agent/src/modes/components/skill-message.ts b/packages/coding-agent/src/modes/components/skill-message.ts index 91cf36871..f054db578 100644 --- a/packages/coding-agent/src/modes/components/skill-message.ts +++ b/packages/coding-agent/src/modes/components/skill-message.ts @@ -3,6 +3,8 @@ import type { Component } from "@oh-my-pi/pi-tui"; import { Box, Container, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui"; import { getMarkdownTheme, theme } from "../../modes/theme/theme"; import type { CustomMessage, SkillPromptDetails } from "../../session/messages"; +import { shortenPath } from "../../tools/render-utils"; +import { fileHyperlink } from "../../tui"; export class SkillMessageComponent extends Container { #box: Box; @@ -38,25 +40,26 @@ export class SkillMessageComponent extends Container { this.removeChild(this.#box); this.addChild(this.#box); this.#box.clear(); - - const label = theme.fg("customMessageLabel", theme.bold("[skill]")); - this.#box.addChild(new Text(label, 0, 0)); - this.#box.addChild(new Spacer(1)); + // Re-read symbols every rebuild so a runtime theme/preset switch refreshes the outline. + this.#box.setBorder({ chars: theme.boxRound, color: t => theme.fg("borderMuted", t) }); const details = this.message.details; - const args = details?.args?.trim(); - const infoLines = [ - `Skill: ${details?.name ?? "unknown"}`, - args ? `Args: ${args}` : undefined, - details?.path ? `Path: ${details.path}` : undefined, - typeof details?.lineCount === "number" ? `Prompt: ${details.lineCount} lines` : undefined, - ].filter((line): line is string => Boolean(line)); + const name = details?.name?.trim() || "unknown"; + // Collapse args to one line: a stray newline/tab in user-supplied args would split the header. + const args = details?.args?.replace(/\s+/g, " ").trim() ?? ""; - this.#box.addChild( - new Markdown(infoLines.join("\n"), 0, 0, getMarkdownTheme(), { - color: (value: string) => theme.fg("customMessageText", value), - }), - ); + // Header: icon-tag + skill name, with the invocation args trailing dimmed. + const tag = theme.fg("customMessageLabel", theme.bold(`${theme.icon.extensionSkill} skill`)); + let header = `${tag} ${theme.fg("customMessageText", theme.bold(name))}`; + if (args) { + header += ` ${theme.fg("dim", args)}`; + } + this.#box.addChild(new Text(header, 0, 0)); + + const meta = this.#metaLine(details); + if (meta) { + this.#box.addChild(new Text(meta, 0, 0)); + } if (!this.#expanded) { return; @@ -68,8 +71,7 @@ export class SkillMessageComponent extends Container { } this.#box.addChild(new Spacer(1)); - const promptHeader = theme.fg("customMessageLabel", theme.bold("Prompt")); - this.#box.addChild(new Text(promptHeader, 0, 0)); + this.#box.addChild(new Text(theme.fg("muted", "prompt"), 0, 0)); this.#box.addChild(new Spacer(1)); this.#contentComponent = new Markdown(text, 0, 0, getMarkdownTheme(), { @@ -78,6 +80,24 @@ export class SkillMessageComponent extends Container { this.#box.addChild(this.#contentComponent); } + /** Sub-line under the header: home-shortened (clickable) accent path · muted prompt size. */ + #metaLine(details: SkillPromptDetails | undefined): string | undefined { + const parts: string[] = []; + + const filePath = details?.path; + if (filePath) { + parts.push(fileHyperlink(filePath, theme.fg("accent", shortenPath(filePath)), { line: 1 })); + } + if (typeof details?.lineCount === "number") { + parts.push(theme.fg("muted", `${details.lineCount} ${details.lineCount === 1 ? "line" : "lines"}`)); + } + + if (parts.length === 0) { + return undefined; + } + return ` ${parts.join(theme.fg("muted", theme.sep.dot))}`; + } + #extractText(): string { if (typeof this.message.content === "string") { return this.message.content; diff --git a/packages/coding-agent/src/modes/components/tiny-title-download-progress.ts b/packages/coding-agent/src/modes/components/tiny-title-download-progress.ts index 4a5683182..9109e73bb 100644 --- a/packages/coding-agent/src/modes/components/tiny-title-download-progress.ts +++ b/packages/coding-agent/src/modes/components/tiny-title-download-progress.ts @@ -74,7 +74,7 @@ export class TinyTitleDownloadProgressComponent implements Component { render(width: number): readonly string[] { width = Math.max(1, width); const spec = getTinyTitleModelSpec(this.#modelKey); - const border = theme.fg("border", theme.boxSharp.horizontal.repeat(width)); + const border = theme.fg("border", theme.boxRound.horizontal.repeat(width)); const status = statusLabel(this.#event); const file = currentFile(this.#event); const pct = diff --git a/packages/coding-agent/src/modes/components/welcome.ts b/packages/coding-agent/src/modes/components/welcome.ts index 557de7ad1..0c9a2b2d2 100644 --- a/packages/coding-agent/src/modes/components/welcome.ts +++ b/packages/coding-agent/src/modes/components/welcome.ts @@ -308,7 +308,7 @@ export class WelcomeComponent implements Component { } // Bottom border if (showRightColumn) { - lines.push(bl + h.repeat(leftCol) + theme.fg("dim", theme.boxSharp.teeUp) + h.repeat(rightCol) + br); + lines.push(bl + h.repeat(leftCol) + theme.fg("dim", theme.boxRound.teeUp) + h.repeat(rightCol) + br); } else { lines.push(bl + h.repeat(leftCol) + br); } diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index 6a1da7771..774fd9623 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -38,6 +38,7 @@ import { buildHotkeysMarkdown } from "../../modes/utils/hotkeys-markdown"; import { buildToolsMarkdown } from "../../modes/utils/tools-markdown"; import type { AsyncJobSnapshotItem } from "../../session/agent-session"; import type { AuthStorage, OAuthAccountIdentity } from "../../session/auth-storage"; +import type { CompactMode } from "../../session/compact-modes"; import type { NewSessionOptions } from "../../session/session-entries"; import { formatShakeSummary, type ShakeMode, type ShakeResult } from "../../session/shake-types"; import { limitMatchesActiveAccount } from "../../slash-commands/helpers/active-oauth-account"; @@ -1034,6 +1035,7 @@ export class CommandController { async handleCompactCommand( customInstructions?: string, + mode?: CompactMode, beforeFlush?: (outcome: CompactionOutcome) => void | Promise, ): Promise { const entries = this.ctx.sessionManager.getEntries(); @@ -1044,7 +1046,7 @@ export class CommandController { return "ok"; } - return this.executeCompaction(customInstructions, false, beforeFlush); + return this.executeCompaction(customInstructions, false, beforeFlush, mode); } /** @@ -1090,6 +1092,7 @@ export class CommandController { customInstructionsOrOptions?: string | CompactOptions, isAuto = false, beforeFlush?: (outcome: CompactionOutcome) => void | Promise, + mode?: CompactMode, ): Promise { if (this.ctx.loadingAnimation) { this.ctx.loadingAnimation.stop(); @@ -1111,10 +1114,17 @@ export class CommandController { let outcome: CompactionOutcome = "ok"; try { const instructions = typeof customInstructionsOrOptions === "string" ? customInstructionsOrOptions : undefined; - const options = + const baseOptions = customInstructionsOrOptions && typeof customInstructionsOrOptions === "object" ? customInstructionsOrOptions : undefined; + // The slash path passes `mode` positionally; the extension path carries + // it inside the options object. Either source wins over no mode. + const effectiveMode = mode ?? baseOptions?.mode; + const options = + baseOptions || effectiveMode + ? { ...baseOptions, ...(effectiveMode ? { mode: effectiveMode } : {}) } + : undefined; await this.ctx.session.compact(instructions, options); compactingLoader.stop(); diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index e11289a7d..aa2580f8f 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -1,10 +1,11 @@ -import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core"; import type { ImageContent } from "@oh-my-pi/pi-ai"; import { type Component, Loader, TERMINAL } from "@oh-my-pi/pi-tui"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import { extractTextContent } from "../../commit/utils"; import { settings } from "../../config/settings"; import { getFileSnapshotStore } from "../../edit/file-snapshot-store"; import { AssistantMessageComponent } from "../../modes/components/assistant-message"; +import { detectCacheInvalidation } from "../../modes/components/cache-invalidation-marker"; import { ReadToolGroupComponent, readArgsHaveTarget, @@ -659,6 +660,16 @@ export class EventController { // waiting poll cannot be displaced anymore — freeze it in place. this.#resolveDisplaceablePoll(); } + // Surface a prompt-cache invalidation: if the previous turn cached a + // meaningful prefix and this request read none of it back, flag the turn. + const usage = event.message.usage; + if (usage.cacheRead + usage.cacheWrite + usage.input > 0) { + if (settings.get("display.cacheMissMarker")) { + const invalidation = detectCacheInvalidation(this.ctx.lastAssistantUsage, usage); + if (invalidation) this.ctx.streamingComponent.setCacheInvalidation(invalidation); + } + this.ctx.lastAssistantUsage = usage; + } this.#lastAssistantComponent = this.ctx.streamingComponent; this.#lastAssistantComponent.markTranscriptBlockFinalized(); if (settings.get("display.showTokenUsage")) { @@ -969,12 +980,14 @@ export class EventController { } this.ctx.showWarning(event.errorMessage); } else if (!event.skipped) { + this.ctx.lastAssistantUsage = undefined; this.ctx.rebuildChatFromMessages(); this.ctx.statusLine.invalidate(); this.ctx.updateEditorTopBorder(); this.ctx.showStatus("Auto-shake completed"); } } else if (event.result) { + this.ctx.lastAssistantUsage = undefined; this.ctx.rebuildChatFromMessages(); this.ctx.statusLine.invalidate(); this.ctx.updateEditorTopBorder(); @@ -982,6 +995,7 @@ export class EventController { this.ctx.showWarning(event.errorMessage); } else if (isHandoffAction) { this.ctx.chatContainer.clear(); + this.ctx.lastAssistantUsage = undefined; this.ctx.rebuildChatFromMessages(); this.ctx.statusLine.invalidate(); this.ctx.updateEditorTopBorder(); diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 5ec88340d..cd21684f5 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -1529,7 +1529,6 @@ export class InputController { for (const child of this.ctx.chatContainer.children) { if (child instanceof AssistantMessageComponent) { child.setHideThinkingBlock(this.ctx.hideThinkingBlock); - child.invalidate(); } } @@ -1538,6 +1537,14 @@ export class InputController { this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage); } + // Every block now carries the new flag, but on ED3-risk terminals the + // blocks that scrolled past the live region are frozen snapshots in + // committed scrollback — a plain repaint replays them stale, so scrolling + // up still shows the old thinking expanded. resetDisplay() retires those + // snapshots (it invalidates every block) and forces a full clear + replay + // of the whole transcript, matching setToolsExpanded()'s redraw. + this.ctx.ui.resetDisplay(); + this.ctx.showStatus(`Thinking blocks: ${this.ctx.hideThinkingBlock ? "hidden" : "visible"}`); } diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index 985211971..f4d39dff5 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -324,9 +324,19 @@ export class SelectorController { for (const child of this.ctx.chatContainer.children) { if (child instanceof AssistantMessageComponent) { child.setHideThinkingBlock(value as boolean); - child.invalidate(); } } + // Full clear + replay so blocks frozen in committed scrollback on + // ED3-risk terminals retire their stale snapshots too (see + // InputController.toggleThinkingBlockVisibility). + this.ctx.ui.resetDisplay(); + break; + case "display.cacheMissMarker": + // Rebuild re-runs the usage-based detection under the new setting so + // markers appear/disappear; full reset retires any already committed + // to native scrollback (mirrors hideThinking). + this.ctx.rebuildChatFromMessages(); + this.ctx.ui.resetDisplay(); break; case "tui.tight": setTuiTight(value as boolean); diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index d4109fff3..776b45d1f 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -12,7 +12,7 @@ import { ThinkingLevel, } from "@oh-my-pi/pi-agent-core"; import type { CompactionOutcome } from "@oh-my-pi/pi-agent-core/compaction"; -import type { AssistantMessage, ImageContent, Message, Model, UsageReport } from "@oh-my-pi/pi-ai"; +import type { AssistantMessage, ImageContent, Message, Model, Usage, UsageReport } from "@oh-my-pi/pi-ai"; import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models"; import type { Component, @@ -82,6 +82,7 @@ import planModeCompactInstructionsPrompt from "../prompts/system/plan-mode-compa type: "text", }; import type { AgentSession, AgentSessionEvent, ResolvedRoleModel } from "../session/agent-session"; +import type { CompactMode } from "../session/compact-modes"; import { HistoryStorage } from "../session/history-storage"; import type { SessionContext } from "../session/session-context"; import { getRecentSessions } from "../session/session-listing"; @@ -411,6 +412,7 @@ export class InteractiveMode implements InteractiveModeContext { isPythonMode = false; streamingComponent: AssistantMessageComponent | undefined = undefined; streamingMessage: AssistantMessage | undefined = undefined; + lastAssistantUsage: Usage | undefined = undefined; loadingAnimation: Loader | undefined = undefined; autoCompactionLoader: Loader | undefined = undefined; retryLoader: Loader | undefined = undefined; @@ -511,6 +513,7 @@ export class InteractiveMode implements InteractiveModeContext { this.compactionQueuedMessages = []; this.streamingComponent = undefined; this.streamingMessage = undefined; + this.lastAssistantUsage = undefined; this.pendingTools.clear(); } readonly #uiHelpers: UiHelpers; @@ -1857,6 +1860,9 @@ export class InteractiveMode implements InteractiveModeContext { this.#planModePreviousTools = previousTools; this.planModePlanFilePath = planFilePath; this.planModeEnabled = true; + // Suppress cache-miss marker on the next turn: plan mode changes the system + // prompt, which predictably invalidates the cache. + this.lastAssistantUsage = undefined; await this.session.setActiveToolsByName(uniquePlanTools); this.session.setPlanModeState({ @@ -1974,6 +1980,9 @@ export class InteractiveMode implements InteractiveModeContext { this.session.setStandingResolveHandler?.(null); this.session.setPlanModeState(undefined); this.planModeEnabled = false; + // Suppress cache-miss marker on the next turn: plan exit changes the system + // prompt, which predictably invalidates the cache. + this.lastAssistantUsage = undefined; this.planModePaused = options?.paused ?? false; this.planModePlanFilePath = undefined; this.#planModePreviousTools = undefined; @@ -2350,7 +2359,7 @@ export class InteractiveMode implements InteractiveModeContext { // the try/finally is idempotent and kept for the !compactBeforeExecute // branch. this.session.setPlanReferencePath(options.planFilePath); - compactOutcome = await this.handleCompactCommand(compactionPrompt, outcome => + compactOutcome = await this.handleCompactCommand(compactionPrompt, undefined, outcome => this.#applyDeferredPlanModelTransition(outcome, options.executionModel), ); } @@ -3616,9 +3625,10 @@ export class InteractiveMode implements InteractiveModeContext { handleCompactCommand( customInstructions?: string, + mode?: CompactMode, beforeFlush?: (outcome: CompactionOutcome) => void | Promise, ): Promise { - return this.#commandController.handleCompactCommand(customInstructions, beforeFlush); + return this.#commandController.handleCompactCommand(customInstructions, mode, beforeFlush); } handleHandoffCommand(customInstructions?: string): Promise { diff --git a/packages/coding-agent/src/modes/theme/theme.ts b/packages/coding-agent/src/modes/theme/theme.ts index 4736771da..faa70af0f 100644 --- a/packages/coding-agent/src/modes/theme/theme.ts +++ b/packages/coding-agent/src/modes/theme/theme.ts @@ -111,6 +111,7 @@ export type SymbolKey = | "icon.agents" | "icon.job" | "icon.cache" + | "icon.cacheMiss" | "icon.input" | "icon.output" | "icon.host" @@ -310,6 +311,7 @@ const UNICODE_SYMBOLS: SymbolMap = { "icon.agents": "👥", "icon.job": "⚙", "icon.cache": "💾", + "icon.cacheMiss": "⊘", "icon.input": "⤵", "icon.output": "⤴", "icon.host": "🖥", @@ -579,6 +581,8 @@ const NERD_SYMBOLS: SymbolMap = { "icon.job": "\uf013", // pick:  | alt:   "icon.cache": "\uf1c0", + // pick: (fa-ban) | alt: ⊘ + "icon.cacheMiss": "\uf05e", // pick:  | alt:  → "icon.input": "\uf090", // pick:  | alt:  → @@ -810,6 +814,7 @@ const ASCII_SYMBOLS: SymbolMap = { "icon.agents": "AG", "icon.job": "bg", "icon.cache": "cache", + "icon.cacheMiss": "!", "icon.input": "in:", "icon.output": "out:", "icon.host": "host", @@ -1711,6 +1716,14 @@ export class Theme { bottomRight: this.#symbols["boxRound.bottomRight"], horizontal: this.#symbols["boxRound.horizontal"], vertical: this.#symbols["boxRound.vertical"], + // Junctions have no rounded Unicode variant, so a rounded box reuses the + // sharp tee/cross glyphs. Sourcing them from the boxSharp.* tokens keeps a + // theme's `boxSharp.tee*` overrides effective for rounded-box dividers. + cross: this.#symbols["boxSharp.cross"], + teeDown: this.#symbols["boxSharp.teeDown"], + teeUp: this.#symbols["boxSharp.teeUp"], + teeRight: this.#symbols["boxSharp.teeRight"], + teeLeft: this.#symbols["boxSharp.teeLeft"], }; } @@ -1770,6 +1783,7 @@ export class Theme { agents: this.#symbols["icon.agents"], job: this.#symbols["icon.job"], cache: this.#symbols["icon.cache"], + cacheMiss: this.#symbols["icon.cacheMiss"], input: this.#symbols["icon.input"], output: this.#symbols["icon.output"], host: this.#symbols["icon.host"], diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index aa4ee1695..22d137f1b 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -1,6 +1,6 @@ import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import type { CompactionOutcome } from "@oh-my-pi/pi-agent-core/compaction"; -import type { AssistantMessage, ImageContent, Message, UsageReport } from "@oh-my-pi/pi-ai"; +import type { AssistantMessage, ImageContent, Message, Usage, UsageReport } from "@oh-my-pi/pi-ai"; import type { Component, Container, EditorTheme, Loader, Spacer, Text, TUI } from "@oh-my-pi/pi-tui"; import type { CollabGuestLink } from "../collab/guest"; import type { CollabHost } from "../collab/host"; @@ -17,6 +17,7 @@ import type { CompactOptions } from "../extensibility/extensions/types"; import type { MCPManager } from "../mcp"; import type { PlanApprovalDetails } from "../plan-mode/approved-plan"; import type { AgentSession } from "../session/agent-session"; +import type { CompactMode } from "../session/compact-modes"; import type { HistoryStorage } from "../session/history-storage"; import type { SessionContext } from "../session/session-context"; import type { SessionManager } from "../session/session-manager"; @@ -158,6 +159,12 @@ export interface InteractiveModeContext { isPythonMode: boolean; streamingComponent: AssistantMessageComponent | undefined; streamingMessage: AssistantMessage | undefined; + /** + * Usage of the most recently rendered assistant turn, used to detect a + * prompt-cache invalidation on the next turn (cache footprint collapse). + * Reseeded by `renderSessionContext` on every rebuild/session switch. + */ + lastAssistantUsage: Usage | undefined; loadingAnimation: Loader | undefined; autoCompactionLoader: Loader | undefined; retryLoader: Loader | undefined; @@ -293,7 +300,7 @@ export interface InteractiveModeContext { handlePythonCommand(code: string, excludeFromContext?: boolean): Promise; handleMCPCommand(text: string): Promise; handleSSHCommand(text: string): Promise; - handleCompactCommand(customInstructions?: string): Promise; + handleCompactCommand(customInstructions?: string, mode?: CompactMode): Promise; handleHandoffCommand(customInstructions?: string): Promise; handleShakeCommand(mode: ShakeMode): Promise; handleMoveCommand(targetPath: string): Promise; diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts index 948643b8b..f9ecd4e4e 100644 --- a/packages/coding-agent/src/modes/utils/ui-helpers.ts +++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts @@ -9,9 +9,10 @@ import { createAdvisorMessageCard } from "../../modes/components/advisor-message import { AssistantMessageComponent } from "../../modes/components/assistant-message"; import { createBackgroundTanDispatchBlock } from "../../modes/components/background-tan-message"; import { BashExecutionComponent } from "../../modes/components/bash-execution"; -import { BranchSummaryMessageComponent } from "../../modes/components/branch-summary-message"; +import { detectCacheInvalidation } from "../../modes/components/cache-invalidation-marker"; import { CollabPromptMessageComponent } from "../../modes/components/collab-prompt-message"; import { + BranchSummaryMessageComponent, CompactionSummaryMessageComponent, createHandoffSummaryMessageComponent, } from "../../modes/components/compaction-summary-message"; @@ -358,6 +359,9 @@ export class UiHelpers { ): void { // Preserved: message_start handler owns this lifecycle (see #783) this.ctx.pendingTools.clear(); + // Reseed the cache-invalidation baseline: this rebuild re-derives every + // turn's marker from usage, and the last turn becomes the live baseline. + this.ctx.lastAssistantUsage = undefined; if (options.updateFooter) { this.ctx.statusLine.invalidate(); @@ -399,13 +403,27 @@ export class UiHelpers { // updateResult armed. previous.seal(); }; - for (const message of sessionContext.messages) { + const messages = sessionContext.messages; + const count = messages.length; + for (let i = 0; i < count; i++) { + const message = messages[i]!; if (message.role !== "toolResult") flushPendingUsage(); // Assistant messages need special handling for tool calls if (message.role === "assistant") { this.ctx.addMessageToChat(message); const lastChild = this.ctx.chatContainer.children[this.ctx.chatContainer.children.length - 1]; const assistantComponent = lastChild instanceof AssistantMessageComponent ? lastChild : undefined; + if (assistantComponent) { + const usage = message.usage; + const explained = sessionContext.cacheMissExplainedAt?.[i] ?? false; + if (this.ctx.settings.get("display.cacheMissMarker") && !explained) { + const invalidation = detectCacheInvalidation(this.ctx.lastAssistantUsage, usage); + if (invalidation) assistantComponent.setCacheInvalidation(invalidation); + } + if (usage.cacheRead + usage.cacheWrite + usage.input > 0) { + this.ctx.lastAssistantUsage = usage; + } + } const hasVisibleAssistantContent = message.content.some( content => (content.type === "text" && canonicalizeMessage(content.text)) || diff --git a/packages/coding-agent/src/prompts/steering/user-interjection.md b/packages/coding-agent/src/prompts/steering/user-interjection.md index 8f2dea64d..40deaecec 100644 --- a/packages/coding-agent/src/prompts/steering/user-interjection.md +++ b/packages/coding-agent/src/prompts/steering/user-interjection.md @@ -1,8 +1,7 @@ -The user sent this message while you were working on the current task. It takes -priority and supersedes your earlier plan wherever they conflict. Stop work that no -longer matches their intent, re-read the request below, and adjust what you are doing -now. +The user sent this message as an interjection while you were working. It takes +priority and supersedes earlier instructions wherever they conflict — re-read it +and make sure your current work reflects their intent. {{message}} diff --git a/packages/coding-agent/src/prompts/tools/inspect-image.md b/packages/coding-agent/src/prompts/tools/inspect-image.md index 0badcff33..3c328cd88 100644 --- a/packages/coding-agent/src/prompts/tools/inspect-image.md +++ b/packages/coding-agent/src/prompts/tools/inspect-image.md @@ -2,7 +2,7 @@ Inspects an image file with a vision-capable model and returns compact text anal - Use this for image understanding tasks (OCR, UI/screenshot debugging, scene/object questions) -- Provide `path` to the local image file +- Provide `path` as a local image file path, `Image #N` attachment label, or `attachment://N` URI - Write a specific `question`: - what to inspect - constraints (for example: "quote visible text verbatim", "only report confirmed findings") diff --git a/packages/coding-agent/src/prompts/tools/read.md b/packages/coding-agent/src/prompts/tools/read.md index b452f59a4..1782559dc 100644 --- a/packages/coding-agent/src/prompts/tools/read.md +++ b/packages/coding-agent/src/prompts/tools/read.md @@ -33,7 +33,7 @@ Append `:` to `path`; bare path = default mode. - File with explicit selector → lines prefixed with numbers: `41|def alpha():`. {{/if}} {{/if}} -- Parseable code without selector → **structural summary**: declarations kept, bodies collapsed to `..` (merged brace pair) or `…` (standalone). The footer shows the recovery selector: `[NN lines elided; re-read needed ranges, e.g. :5-16,40-80]`. Re-issue ONLY the ranges you need via the multi-range selector. `..`/`…` carry no content — NEVER guess what's inside; NEVER re-read the whole file or `:raw` when ranges suffice. +- Parseable code without selector → **structural summary**: declarations kept, body elided with `…`. The footer shows the recovery selector. Re-issue ONLY the ranges you need via the multi-range selector. # Documents & Notebooks diff --git a/packages/coding-agent/src/registry/agent-registry.ts b/packages/coding-agent/src/registry/agent-registry.ts index b5d52847e..84849bc9a 100644 --- a/packages/coding-agent/src/registry/agent-registry.ts +++ b/packages/coding-agent/src/registry/agent-registry.ts @@ -22,7 +22,13 @@ export const MAIN_AGENT_ID = "Main"; * - `aborted`: hard-killed, terminal. */ export type AgentStatus = "running" | "idle" | "parked" | "aborted"; -export type AgentKind = "main" | "sub"; +/** + * - `main`/`sub`: the user-facing agent tree (driving agent + task subagents). + * - `advisor`: a passive review transcript persisted like a subagent for usage + * attribution and Agent Hub observability, but never a peer — hidden from + * agent-facing rosters (`irc`, `history://`) and not messageable/revivable. + */ +export type AgentKind = "main" | "sub" | "advisor"; export interface AgentRef { id: string; @@ -157,11 +163,14 @@ export class AgentRegistry { } /** - * Returns every alive agent (running | idle) except the caller. - * Flat namespace: every agent can see every other agent. + * Returns every alive agent (running | idle) except the caller. Advisor refs + * are observability-only transcripts, never peers, so they are excluded. + * Flat namespace: every other agent is visible. */ listVisibleTo(id: string): AgentRef[] { - return this.list().filter(ref => ref.id !== id && (ref.status === "running" || ref.status === "idle")); + return this.list().filter( + ref => ref.id !== id && ref.kind !== "advisor" && (ref.status === "running" || ref.status === "idle"), + ); } onChange(listener: RegistryListener): () => void { diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index eb5bf0ff1..2dc50ac31 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -5,7 +5,6 @@ import { type AgentTelemetryConfig, type AgentTool, AppendOnlyContextManager, - INTENT_FIELD, type ThinkingLevel, } from "@oh-my-pi/pi-agent-core"; import { @@ -35,6 +34,7 @@ import { prompt, Snowflake, } from "@oh-my-pi/pi-utils"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import { ADVISOR_READONLY_TOOL_NAMES, discoverWatchdogFiles } from "./advisor"; import { type AsyncJob, AsyncJobManager } from "./async"; import { AutoLearnController, buildAutoLearnInstructions } from "./autolearn/controller"; @@ -144,6 +144,7 @@ import { AgentOutputManager } from "./task/output-manager"; import { AUTO_THINKING, type ConfiguredThinkingLevel, + parseConfiguredThinkingLevel, parseThinkingLevel, resolveProvisionalAutoLevel, resolveThinkingLevelForModel, @@ -837,7 +838,7 @@ export interface BuildSystemPromptOptions { contextFiles?: Array<{ path: string; content: string }>; cwd?: string; appendPrompt?: string; - repeatToolDescriptions?: boolean; + inlineToolDescriptors?: boolean; } /** @@ -852,7 +853,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): skills: options.skills, contextFiles: options.contextFiles, appendSystemPrompt: options.appendPrompt, - repeatToolDescriptions: options.repeatToolDescriptions, + inlineToolDescriptors: options.inlineToolDescriptors, }); } @@ -1266,12 +1267,16 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} ? getRestorableSessionModels(existingSession.models, sessionManager.getLastModelChangeRole()) : []; let restoredSessionModelIndex = -1; + let restoredSessionThinkingLevel: ThinkingLevel | undefined; if (!hasExplicitModel && !model && sessionModelStrings.length > 0) { logger.time("restoreSessionModel", () => { let failedSessionModel: string | undefined; for (let i = 0; i < sessionModelStrings.length; i++) { const sessionModelStr = sessionModelStrings[i]; - const parsedModel = parseModelString(sessionModelStr); + const parsedModel = parseModelString(sessionModelStr, { + allowMaxAlias: true, + isLiteralModelId: (provider, id) => modelRegistry.find(provider, id) !== undefined, + }); if (!parsedModel) { failedSessionModel ??= sessionModelStr; continue; @@ -1281,6 +1286,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} if (restoredModel && hasModelAuth(restoredModel)) { model = restoredModel; restoredSessionModelIndex = i; + restoredSessionThinkingLevel = parsedModel.thinkingLevel; break; } failedSessionModel ??= sessionModelStr; @@ -1305,15 +1311,19 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const taskDepth = options.taskDepth ?? 0; // Resolves the session/agent thinking level using the same precedence we - // apply at startup: explicit option → persisted session entry → default - // role's explicit selector → selected model's defaultLevel → global - // settings default. Run again after extension role reclaim so the final - // model's own defaults aren't masked by an earlier fallback model's. + // apply at startup: explicit option → persisted session entry → restored + // model selector suffix → default role's explicit selector → selected + // model's defaultLevel → global settings default. Run again after extension + // role reclaim so the final model's own defaults aren't masked by an earlier + // fallback model's. const pickInitialThinkingLevel = (selectedModel: Model | undefined): ConfiguredThinkingLevel | undefined => { let level = options.thinkingLevel; if (level === undefined && hasExistingSession && hasThinkingEntry) { level = parseThinkingLevel(existingSession.thinkingLevel); } + if (level === undefined && !hasThinkingEntry && restoredSessionThinkingLevel !== undefined) { + level = restoredSessionThinkingLevel; + } if (level === undefined && !hasExplicitModel && !hasThinkingEntry && defaultRoleSpec.explicitThinkingLevel) { level = defaultRoleSpec.thinkingLevel; } @@ -1321,7 +1331,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} level = selectedModel.thinking.defaultLevel; } if (level === undefined) { - level = settings.get("defaultThinkingLevel"); + level = parseConfiguredThinkingLevel(settings.get("defaultThinkingLevel")); } return level; }; @@ -1533,6 +1543,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} getModelString: () => (hasExplicitModel && model ? formatModelString(model) : undefined), getActiveModelString, getActiveModel: () => agent?.state.model ?? model, + getImageAttachments: () => session?.getImageAttachments() ?? [], getPlanModeState: () => session?.getPlanModeState(), getPlanReferencePath: () => session?.getPlanReferencePath() ?? "local://PLAN.md", getGoalModeState: () => session?.getGoalModeState(), @@ -1905,13 +1916,17 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} if (!hasExplicitModel && sessionRetryLimit > 0) { for (let i = 0; i < sessionRetryLimit; i++) { const sessionModelStr = sessionModelStrings[i]; - const parsedModel = parseModelString(sessionModelStr); + const parsedModel = parseModelString(sessionModelStr, { + allowMaxAlias: true, + isLiteralModelId: (provider, id) => modelRegistry.find(provider, id) !== undefined, + }); if (!parsedModel) continue; const restoredModel = modelRegistry.find(parsedModel.provider, parsedModel.id); if (restoredModel && hasModelAuth(restoredModel)) { model = restoredModel; modelFallbackMessage = undefined; restoredSessionModelIndex = i; + restoredSessionThinkingLevel = parsedModel.thinkingLevel; // Recompute thinking-level from scratch against the reclaimed // model: any value derived from the earlier fallback model's // `thinking.defaultLevel` must not become sticky. @@ -2115,7 +2130,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} emitEvent: event => cursorEventEmitter?.(event), }); - const repeatToolDescriptions = settings.get("repeatToolDescriptions"); + const inlineToolDescriptors = settings.get("inlineToolDescriptors"); const eagerTasks = settings.get("task.eager") !== "default"; const eagerTasksAlways = settings.get("task.eager") === "always"; const intentField = $flag("PI_INTENT_TRACING", settings.get("tools.intentTracing")) ? INTENT_FIELD : undefined; @@ -2183,7 +2198,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} } appendPrompt = parts.join("\n\n"); } - // Owned/in-band tool dialect (non-native) repeats the catalog as `# Tool:` + // Owned/in-band tool dialects (non-native) require the catalog as `# Tool:` // sections; native tool calling lets the compact name list suffice. const nativeTools = resolveDialect(settings.get("tools.format"), agent?.state.model ?? model) === undefined; const defaultPrompt = await buildSystemPromptInternal({ @@ -2196,7 +2211,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} alwaysApplyRules, skillsSettings: settings.getGroup("skills"), appendSystemPrompt: appendPrompt, - repeatToolDescriptions, + inlineToolDescriptors, nativeTools, intentField, mcpDiscoveryMode: hasDiscoverableTools, @@ -2521,9 +2536,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} return result; }, intentTracing: !!intentField, + pruneToolDescriptions: inlineToolDescriptors, dialect: resolveDialect(settings.get("tools.format"), model), abortOnFabricatedToolResult: settings.get("tools.abortOnFabricatedResult"), - getToolChoice: () => session?.nextToolChoice(), + getToolChoice: () => session?.nextToolChoiceDirective(), telemetry: options.telemetry, appendOnlyContext: model ? shouldEnableAppendOnlyContext(settings.get("provider.appendOnlyContext"), model) @@ -2585,9 +2601,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} if (watchdogFiles && watchdogFiles.length > 0) { advisorWatchdogPrompt = watchdogFiles.join("\n\n"); } + // Owned only when this session created the manager; subagents receive a + // parent's manager via `options.mcpManager` and MUST NOT disconnect it. + const ownedMcpManager = options.mcpManager ? undefined : mcpManager; session = new AgentSession({ advisorWatchdogPrompt, agent, + pruneToolDescriptions: inlineToolDescriptors, thinkingLevel: autoThinking ? AUTO_THINKING : effectiveThinkingLevel, sessionManager, settings, @@ -2630,6 +2650,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} return out; } : undefined, + disconnectOwnedMcpManager: ownedMcpManager ? () => ownedMcpManager.disconnectAll() : undefined, mcpDiscoveryEnabled, initialSelectedMCPToolNames, defaultSelectedMCPToolNames, diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 3fef85f17..ed1f23319 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -35,6 +35,7 @@ import { countTokens, resolveTelemetry, ThinkingLevel, + type ToolChoiceDirective, } from "@oh-my-pi/pi-agent-core"; import { AGGRESSIVE_SHAKE_CONFIG, @@ -62,6 +63,7 @@ import { type ShakeRegion, type SummaryOptions, shouldCompact, + shouldUseOpenAiRemoteCompaction, } from "@oh-my-pi/pi-agent-core/compaction"; import { DEFAULT_PRUNE_CONFIG, @@ -101,6 +103,7 @@ import { resolveServiceTier, streamSimple, } from "@oh-my-pi/pi-ai"; +import { stripToolDescriptions } from "@oh-my-pi/pi-ai/utils/schema"; import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking"; import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models"; import { MacOSPowerAssertion } from "@oh-my-pi/pi-natives"; @@ -116,6 +119,7 @@ import { prompt, relativePathWithinRoot, Snowflake, + withTimeout, } from "@oh-my-pi/pi-utils"; import * as snapcompact from "@oh-my-pi/snapcompact"; import { @@ -125,6 +129,7 @@ import { type AdvisorNote, AdvisorRuntime, type AdvisorSeverity, + AdvisorTranscriptRecorder, formatAdvisorBatchContent, isAdvisorInterruptImmuneTurnActive, isInterruptingSeverity, @@ -235,6 +240,7 @@ import { AUTO_THINKING, type ConfiguredThinkingLevel, clampAutoThinkingEffort, + parseConfiguredThinkingLevel, resolveProvisionalAutoLevel, resolveThinkingLevelForModel, shouldDisableReasoning, @@ -256,6 +262,7 @@ import type { CheckpointState } from "../tools/checkpoint"; import { outputMeta, wrapToolWithMetaNotice } from "../tools/output-meta"; import { normalizeLocalScheme, resolveToCwd } from "../tools/path-utils"; import { isAutoQaEnabled } from "../tools/report-tool-issue"; +import { buildResolveReminderMessage } from "../tools/resolve"; import { getLatestTodoPhasesFromEntries, type TodoItem, type TodoPhase } from "../tools/todo"; import { ToolAbortError, ToolError } from "../tools/tool-errors"; import { clampTimeout } from "../tools/tool-timeouts"; @@ -275,6 +282,7 @@ import { shouldEvaluateCodexAutoRedeem, shouldPromptCodexAutoRedeem, } from "./codex-auto-reset"; +import { findCompactMode } from "./compact-modes"; import { type BashExecutionMessage, type CustomMessage, @@ -362,6 +370,23 @@ const COMPACTION_CHECK_CONTINUATION: CompactionCheckResult = { deferredHandoff: false, continuationScheduled: true, }; + +/** + * Per-turn prune cache window. A tool result whose all-message suffix exceeds + * this is in the warm, already-sent prompt-cache prefix: re-writing it costs the + * cacheWrite premium on the whole suffix. Per-turn passes only reclaim inside + * this tail (matches the supersede pass's default `suffixTokenLimit`); deeper + * stale/age victims are left to compaction/shake, which rebuild the cache anyway. + */ +const PRUNE_CACHE_WARM_SUFFIX_TOKENS = 8_000; + +/** + * Idle gap after which the supersede pass may flush the whole sent region (the + * provider cache is cold, so re-writing it is free). MUST exceed the maximum + * Anthropic prompt-cache TTL — "long" retention (the OAuth default) is 1h — or a + * still-warm prefix is busted by the flush. 90 min leaves margin over the 1h TTL. + */ +const PRUNE_IDLE_FLUSH_MS = 90 * 60_000; export type CommandMetadataChangedListener = () => void | Promise; export type AsyncJobSnapshotItem = Pick; @@ -511,6 +536,19 @@ export interface AgentSessionConfig { advisorReadOnlyTools?: AgentTool[]; /** Preloaded watchdog prompt content for the advisor. */ advisorWatchdogPrompt?: string; + /** + * Strip tool descriptions from provider-bound tool specs on side requests + * (handoff). Must match the session-start value used to build the system + * prompt so inline descriptors are not also sent through provider schemas. + */ + pruneToolDescriptions?: boolean; + /** + * Disconnect this session's OWNED MCP manager on dispose. Provided only when + * the session created the manager (top-level sessions); subagents reuse a + * parent's manager via `options.mcpManager` and omit this so a child's + * teardown never tears down the shared servers. + */ + disconnectOwnedMcpManager?: () => Promise; } /** Options for AgentSession.prompt() */ @@ -664,10 +702,16 @@ interface ActiveRetryFallbackState { pinned: boolean; } -function parseRetryFallbackSelector(selector: string): RetryFallbackSelector | undefined { +function parseRetryFallbackSelector( + selector: string, + modelLookup?: { find(provider: string, id: string): Model | undefined }, +): RetryFallbackSelector | undefined { const trimmed = selector.trim(); if (!trimmed) return undefined; - const parsed = parseModelString(trimmed); + const parsed = parseModelString(trimmed, { + allowMaxAlias: true, + isLiteralModelId: (provider, id) => modelLookup?.find(provider, id) !== undefined, + }); if (!parsed) return undefined; return { raw: trimmed, @@ -1095,6 +1139,13 @@ export class AgentSession { #advisorReadOnlyTools?: AgentTool[]; #advisorWatchdogPrompt?: string; #advisorYieldQueueUnsubscribe?: () => void; + /** Persists the advisor agent's turns to `/__advisor.jsonl` for stats + * attribution and Agent Hub observability. Undefined when no advisor is active. */ + #advisorTranscriptRecorder?: AdvisorTranscriptRecorder; + /** Unsubscribe for the advisor agent's event stream feeding the recorder. */ + #advisorAgentUnsubscribe?: () => void; + /** Latest advisor-recorder close, awaited by dispose() so the final turn lands on disk. */ + #advisorRecorderClosed: Promise = Promise.resolve(); #goalTurnCounter = 0; #planReferenceSent = false; #planReferencePath = "local://PLAN.md"; @@ -1195,6 +1246,7 @@ export class AgentSession { | undefined; #getMcpServerInstructions: (() => Map | undefined) | undefined; #reloadSshTool: (() => Promise) | undefined; + #disconnectOwnedMcpManager: (() => Promise) | undefined; #requestedToolNames: ReadonlySet | undefined; #baseSystemPrompt: string[]; /** @@ -1279,6 +1331,8 @@ export class AgentSession { // unchanged — otherwise a mid-turn estimate would survive into idle. #contextUsageRevision = 0; #obfuscator: SecretObfuscator | undefined; + /** Session-start value of `inlineToolDescriptors`; drives handoff tool pruning. */ + #pruneToolDescriptions = false; #checkpointState: CheckpointState | undefined = undefined; #pendingRewindReport: string | undefined = undefined; #lastSuccessfulYieldToolCallId: string | undefined = undefined; @@ -1290,19 +1344,15 @@ export class AgentSession { if (process.platform !== "darwin") return; if (isBunTestRuntime()) return; if (this.#powerAssertion) return; - const idle = this.settings.get("power.preventIdleSleep"); - const system = this.settings.get("power.preventSystemSleep"); - const user = this.settings.get("power.declareUserActive"); - const display = this.settings.get("power.preventDisplaySleep"); - // All four off → user opted out; do nothing. - if (!idle && !system && !user && !display) return; + const mode = this.settings.get("power.sleepPrevention"); + if (mode === "off") return; try { this.#powerAssertion = MacOSPowerAssertion.start({ reason: "Oh My Pi agent session", - idle, - system, - user, - display, + idle: true, + display: mode === "display" || mode === "system", + system: mode === "system", + user: mode === "system", }); } catch (error) { logger.warn("Failed to acquire macOS power assertion", { error: String(error) }); @@ -1487,6 +1537,7 @@ export class AgentSession { this.#modelRegistry = config.modelRegistry; this.#advisorReadOnlyTools = config.advisorReadOnlyTools; this.#advisorWatchdogPrompt = config.advisorWatchdogPrompt; + this.#pruneToolDescriptions = config.pruneToolDescriptions === true; this.#validateRetryFallbackChains(); this.#toolRegistry = config.toolRegistry ?? new Map(); this.#requestedToolNames = config.requestedToolNames; @@ -1561,6 +1612,7 @@ export class AgentSession { this.#rebuildSystemPrompt = config.rebuildSystemPrompt; this.#getMcpServerInstructions = config.getMcpServerInstructions; this.#reloadSshTool = config.reloadSshTool; + this.#disconnectOwnedMcpManager = config.disconnectOwnedMcpManager; this.#baseSystemPrompt = this.agent.state.systemPrompt; this.#promptModelKey = this.#currentPromptModelKey(); this.#mcpDiscoveryEnabled = config.mcpDiscoveryEnabled ?? false; @@ -1691,7 +1743,13 @@ export class AgentSession { * so none of them inject into the new conversation. */ #resetAdvisorSessionState(): void { + // Mute the recorder across the re-prime: AdvisorRuntime.reset() aborts the advisor + // loop, and that abort can emit an `aborted` message_end we must not attribute to + // either session's transcript. Detach, reset, then re-attach the live agent's feed. + this.#advisorAgentUnsubscribe?.(); + this.#advisorAgentUnsubscribe = undefined; this.#advisorRuntime?.reset(); + this.#attachAdvisorRecorderFeed(); this.#advisorPrimaryTurnsCompleted = 0; this.#advisorInterruptImmuneTurnStart = undefined; this.#advisorAutoResumeSuppressed = false; @@ -1824,6 +1882,18 @@ export class AgentSession { }; this.#advisorAgent = advisorAgent; + // Persist the advisor's turns to `/__advisor.jsonl` (resolved lazily + // so it follows session switches) so its model usage is attributed in stats + // and its transcript shows in the Agent Hub — without registering it as a peer. + const recorder = new AdvisorTranscriptRecorder( + () => this.sessionManager.getSessionFile(), + () => this.sessionManager.getCwd(), + // On the advisor on→off→on toggle, wait for the prior recorder's close so + // two SessionManagers never hold the same __advisor.jsonl at once. + this.#advisorRecorderClosed, + ); + this.#advisorTranscriptRecorder = recorder; + this.#attachAdvisorRecorderFeed(); this.#advisorRuntime = new AdvisorRuntime(advisorAgentFacade, { snapshotMessages: () => this.agent.state.messages, enqueueAdvice, @@ -1854,10 +1924,21 @@ export class AgentSession { } #stopAdvisorRuntime(): void { + // Detach the recorder feed BEFORE aborting the advisor agent: dispose() aborts + // the loop, and an abort emits a final `message_end` we must not enqueue against + // a closing recorder (it would reopen and resurrect an already-released file). + this.#advisorAgentUnsubscribe?.(); + this.#advisorAgentUnsubscribe = undefined; if (this.#advisorRuntime) { this.#advisorRuntime.dispose(); this.#advisorRuntime = undefined; } + if (this.#advisorTranscriptRecorder) { + // Capture the close so dispose()/`/drop` can await the queued open+append+close — + // the last advisor turn would otherwise be lost on a fast process exit. + this.#advisorRecorderClosed = this.#advisorTranscriptRecorder.close(); + this.#advisorTranscriptRecorder = undefined; + } if (this.#advisorAgent) { this.#advisorAgent = undefined; } @@ -1865,6 +1946,18 @@ export class AgentSession { this.#advisorYieldQueueUnsubscribe = undefined; } + /** Subscribe the advisor agent's finalized messages into the transcript recorder. + * Idempotent-by-replacement: callers detach the prior feed first. Kept separate + * so the re-prime path can mute the feed across an abort-driven reset. */ + #attachAdvisorRecorderFeed(): void { + const agent = this.#advisorAgent; + const recorder = this.#advisorTranscriptRecorder; + if (!agent || !recorder) return; + this.#advisorAgentUnsubscribe = agent.subscribe(event => { + if (event.type === "message_end") recorder.record(event.message); + }); + } + async #promoteAdvisorContextModel(currentModel: Model): Promise { const promotionSettings = this.settings.getGroup("contextPromotion"); if (!promotionSettings.enabled) return false; @@ -2056,6 +2149,36 @@ export class AgentSession { return undefined; } + /** + * The per-turn tool-choice directive for the agent loop's `getToolChoice`. Priority: + * 1. a HARD forced choice from the queue (genuine forces: user-force, eager-todo, …) — + * consuming, unchanged from `nextToolChoice`; + * 2. else, when a non-forcing preview is pending, a {@link SoftToolRequirement} — a + * PEEK (advances/pops nothing), so the agent-loop injects the reminder once per head + * and escalates to a forced `resolve` only if the model declines. A compliant turn + * pays ZERO tool_choice change (no prompt-cache messages-cache invalidation); + * 3. else undefined. + */ + nextToolChoiceDirective(): ToolChoiceDirective | undefined { + const hard = this.nextToolChoice(); + if (hard !== undefined) return hard; + const head = this.#toolChoiceQueue.peekPendingHead(); + if (head !== undefined) { + return { + soft: true, + id: head.id, + toolName: "resolve", + reminder: [buildResolveReminderMessage(head.sourceToolName)], + }; + } + return undefined; + } + + /** Peek the head non-forcing pending preview invoker, for the `resolve` tool's dispatch. */ + peekPendingInvoker(): ((input: unknown) => Promise | unknown) | undefined { + return this.#toolChoiceQueue.peekPendingInvoker(); + } + /** * Force the next model call to target a specific active tool, then terminate * the agent loop. Pushes a two-step sequence [forced, "none"] so the model @@ -4043,7 +4166,34 @@ export class AgentSession { await shutdownTinyTitleClient(); this.#releasePowerAssertion(); await this.sessionManager.close(); + // beginDispose() stopped the advisor and captured its recorder close; await + // it so the final advisor turn is flushed before the process may exit. + await this.#advisorRecorderClosed; this.#closeAllProviderSessions("dispose"); + // Disconnect the MCP manager this session OWNS so its stdio servers are + // not orphaned at exit. Best-effort: a failure here must never throw out + // of dispose. Only owning (top-level) sessions provide this callback; + // subagents reuse a parent's manager and must not tear it down. Idempotent + // with the deferred-discovery disconnect in `createAgentSession`. + // + // BOUNDED: an owned manager may hold an HTTP/SSE server whose session- + // termination DELETE blocks up to the MCP request timeout (30s default, + // unbounded when OMP_MCP_TIMEOUT_MS=0), so awaiting `disconnectAll()` + // unbounded would stall /exit and print-mode shutdown on a broken remote + // endpoint. Race it against a short deadline — stdio close (the subprocess + // reap this targets) completes well within the bound; a slow transport + // close is left to finish detached. Mirrors the bounded async-job teardown. + if (this.#disconnectOwnedMcpManager) { + try { + await withTimeout( + this.#disconnectOwnedMcpManager(), + 3_000, + "Timed out disconnecting owned MCP manager during dispose", + ); + } catch (error) { + logger.warn("Failed to disconnect owned MCP manager during dispose", { error: String(error) }); + } + } // Flush the retain queue BEFORE clearing the session's pointer so // `HindsightRetainQueue.#doFlush` still sees `session.getHindsightSessionState() === state`. // Reversed, the spliced batch survives just long enough to fail the @@ -4756,7 +4906,7 @@ export class AgentSession { * cache per-tool strings without preserving this property. * * Inputs NOT covered: tool input schemas; memory instructions read from disk; - * and SDK-init-time closure constants in `sdk.ts` (`repeatToolDescriptions`, + * and SDK-init-time closure constants in `sdk.ts` (`inlineToolDescriptors`, * `eagerTasks`, `intentField`, `mcpDiscoveryEnabled`, `secretsEnabled`). The * closure-captured ones cannot change at runtime regardless of skip behavior. * For everything else, callers must explicitly call `refreshBaseSystemPrompt()` @@ -4938,6 +5088,24 @@ export class AgentSession { return this.agent.state.messages; } + /** Latest image attachments addressable by tools as `Image #N` or `attachment://N`. */ + getImageAttachments(): { label: string; uri: string; image: ImageContent }[] { + for (let i = this.agent.state.messages.length - 1; i >= 0; i--) { + const message = this.agent.state.messages[i]; + if (!message || (message.role !== "user" && message.role !== "developer") || !Array.isArray(message.content)) { + continue; + } + const images = message.content.filter((part): part is ImageContent => part.type === "image"); + if (images.length === 0) continue; + return images.map((image, index) => ({ + label: `Image #${index + 1}`, + uri: `attachment://${index + 1}`, + image, + })); + } + return []; + } + buildDisplaySessionContext(): SessionContext { return deobfuscateSessionContext(this.sessionManager.buildSessionContext(), this.#obfuscator); } @@ -6538,6 +6706,14 @@ export class AgentSession { this.#closeAllProviderSessions("new session"); this.agent.reset(); if (options?.drop && previousSessionFile) { + // Detach the advisor recorder feed and drain its writer BEFORE deleting the + // old artifacts dir: `await this.abort()` only stops the primary, so a still- + // running advisor turn could otherwise finish, emit `message_end`, and recreate + // `/__advisor.jsonl`. #resetAdvisorSessionState (after newSession) re-primes + // the advisor and re-attaches the feed at the new session's path. + this.#advisorAgentUnsubscribe?.(); + this.#advisorAgentUnsubscribe = undefined; + if (this.#advisorTranscriptRecorder) await this.#advisorTranscriptRecorder.close(); try { await this.sessionManager.dropSession(previousSessionFile); } catch (err) { @@ -7178,11 +7354,16 @@ export class AgentSession { async #pruneToolOutputs(): Promise<{ prunedCount: number; tokensSaved: number } | undefined> { const branchEntries = this.sessionManager.getBranch(); + const keepBoundaryId = getLatestCompactionEntry(branchEntries)?.firstKeptEntryId; const result = pruneToolOutputs( branchEntries, this.#withPlanProtection({ ...DEFAULT_PRUNE_CONFIG, pruneUseless: this.settings.getGroup("compaction").dropUseless, + // Cache-stable boundary: never re-write the warm, already-sent prefix + // (deep stale/age victims) or summarized-away entries every turn. + keepBoundaryId, + cacheWarmSuffixTokens: PRUNE_CACHE_WARM_SUFFIX_TOKENS, }), ); if (result.prunedCount === 0) { @@ -7210,12 +7391,17 @@ export class AgentSession { const { supersedeReads, dropUseless } = this.settings.getGroup("compaction"); if (!supersedeReads && !dropUseless) return undefined; const branchEntries = this.sessionManager.getBranch(); + const keepBoundaryId = getLatestCompactionEntry(branchEntries)?.firstKeptEntryId; const result = pruneSupersededToolResults( branchEntries, this.#withPlanProtection({ supersedeKey: supersedeReads ? readToolSupersedeKey : undefined, pruneUseless: dropUseless, protectedTools: [...DEFAULT_PRUNE_CONFIG.protectedTools], + // Never re-write summarized-away entries; only flush the whole sent + // region once the cache is genuinely cold (idle exceeds the 1h TTL). + keepBoundaryId, + idleFlushMs: PRUNE_IDLE_FLUSH_MS, }), ); if (result.prunedCount === 0) { @@ -7299,8 +7485,14 @@ export class AgentSession { return { mode, toolResultsDropped: 0, blocksDropped: 0, imagesDropped: removed, tokensFreed: 0 }; } - const config = this.#withPlanProtection(opts.config ?? AGGRESSIVE_SHAKE_CONFIG); - const regions = collectShakeRegions(this.sessionManager.getBranch(), config); + const branchEntries = this.sessionManager.getBranch(); + const config = this.#withPlanProtection({ + ...(opts.config ?? AGGRESSIVE_SHAKE_CONFIG), + // Skip entries summarized away by the latest compaction — shaking them + // only churns persisted history with no prompt/cache effect. + keepBoundaryId: getLatestCompactionEntry(branchEntries)?.firstKeptEntryId, + }); + const regions = collectShakeRegions(branchEntries, config); if (regions.length === 0) { return { mode, toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 }; } @@ -7374,6 +7566,15 @@ export class AgentSession { if (this.#compactionAbortController) { throw new Error("Compaction already in progress"); } + // Resolve the `/compact ` subcommand up front so input validation + // runs before we disconnect/abort the active agent operation below. + const compactMode = options?.mode ? findCompactMode(options.mode) : undefined; + // Modes that produce no LLM summary (snapcompact) have nothing to focus. + // Reject focus text loudly so programmatic callers don't silently lose + // instructions (the slash path pre-validates via parseCompactArgs). + if (compactMode?.rejectsFocus && customInstructions) { + throw new Error(`/compact ${compactMode.name} does not take focus instructions.`); + } this.#disconnectFromAgent(); await this.abort({ goalReason: "internal" }); const compactionAbortController = new AbortController(); @@ -7385,8 +7586,26 @@ export class AgentSession { } const compactionSettings = this.settings.getGroup("compaction"); + // The `/compact ` override (resolved above) replaces the configured + // strategy/remote flags for this one invocation. Merged before + // prepareCompaction so the remote gating (preparation.settings. + // remoteEnabled/endpoint) and the snapcompact decision below both see it. + const effectiveSettings = compactMode + ? { ...compactionSettings, ...compactMode.overrides } + : compactionSettings; + if (compactMode?.requiresRemote) { + const remoteReady = + Boolean(effectiveSettings.remoteEndpoint) || shouldUseOpenAiRemoteCompaction(this.model); + if (!remoteReady) { + this.emitNotice( + "warning", + `remote compaction is unavailable for ${this.model.id} (no remote endpoint configured) — using a local summary instead`, + "compaction", + ); + } + } const pathEntries = this.sessionManager.getBranch(); - const preparation = prepareCompaction(pathEntries, compactionSettings); + const preparation = prepareCompaction(pathEntries, effectiveSettings); if (!preparation) { // Check why we can't compact const lastEntry = pathEntries[pathEntries.length - 1]; @@ -7425,7 +7644,7 @@ export class AgentSession { // directed LLM summary; a text-only model cannot read the frames back — // both take the summarizer path (the latter loudly). const wantsSnapcompact = - compactionPrep.kind !== "fromHook" && compactionSettings.strategy === "snapcompact" && !customInstructions; + compactionPrep.kind !== "fromHook" && effectiveSettings.strategy === "snapcompact" && !customInstructions; const snapcompactReady = wantsSnapcompact && this.model.input.includes("image"); if (wantsSnapcompact && !snapcompactReady) { this.emitNotice( @@ -7450,14 +7669,11 @@ export class AgentSession { convertToLlm, model: this.model, shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")), - // Providers with hard image caps (OpenRouter: 8) silently drop - // frames past the cap — keep the archive within budget. - maxFrames: snapcompact.providerFrameBudget(this.model?.provider), }); const ctxWindow = this.model?.contextWindow ?? 0; const budget = ctxWindow > 0 - ? ctxWindow - effectiveReserveTokens(ctxWindow, compactionSettings) + ? ctxWindow - effectiveReserveTokens(ctxWindow, effectiveSettings) : Number.POSITIVE_INFINITY; if (this.#projectSnapcompactContextTokens(preparation, snapcompactResult) > budget) { logger.warn("Snapcompact still overflows the window; falling back to an LLM summary", { @@ -7701,7 +7917,10 @@ export class AgentSession { this.#modelRegistry.resolver(model, this.sessionId), { systemPrompt: this.#obfuscateForProvider(this.#baseSystemPrompt), - tools: obfuscateProviderTools(this.#obfuscator, this.agent.state.tools), + tools: obfuscateProviderTools( + this.#obfuscator, + this.#pruneToolDescriptions ? stripToolDescriptions(this.agent.state.tools) : this.agent.state.tools, + ), customInstructions: this.#obfuscateTextForProvider(customInstructions), convertToLlm: messages => this.#convertToLlmForSideRequest(messages), initiatorOverride: "agent", @@ -7729,7 +7948,17 @@ export class AgentSession { await this.sessionManager.flush(); this.#cancelOwnAsyncJobs(); await this.sessionManager.newSession(previousSessionFile ? { parentSession: previousSessionFile } : undefined); + // agent.reset() clears the core steering/follow-up queues. Preserve any queued + // steers/follow-ups (RPC/SDK steer()/followUp() issued during the handoff, or a + // pre-loader TUI steer) so they survive into the post-handoff session instead of + // being silently dropped. Capture is synchronous immediately before reset and + // restore is synchronous immediately after — no await gap — so a steer arriving + // later (during ensureOnDisk/Bun.write below) appends to the restored queue + // rather than being clobbered. + const preservedSteering = this.agent.peekSteeringQueue().slice(); + const preservedFollowUp = this.agent.peekFollowUpQueue().slice(); this.agent.reset(); + this.agent.replaceQueues(preservedSteering, preservedFollowUp); this.#freshProviderSessionId = undefined; this.#syncAgentSessionId(); this.#rekeyHindsightMemoryForCurrentSessionId(); @@ -8785,14 +9014,20 @@ export class AgentSession { const existingRoleValue = this.settings.getModelRole(role); if (!existingRoleValue) return modelKey; - const thinkingLevel = extractExplicitThinkingSelector(existingRoleValue, this.settings); + const thinkingLevel = extractExplicitThinkingSelector(existingRoleValue, this.settings, { + isLiteralModelId: (provider, id) => this.#modelRegistry.find(provider, id) !== undefined, + }); return formatModelSelectorValue(modelKey, thinkingLevel); } #resolveContextPromotionConfiguredTarget(currentModel: Model, availableModels: Model[]): Model | undefined { const configuredTarget = currentModel.contextPromotionTarget?.trim(); if (!configuredTarget) return undefined; - const parsed = parseModelString(configuredTarget); + const parsed = parseModelString(configuredTarget, { + allowMaxAlias: true, + isLiteralModelId: (provider, id) => + availableModels.some(model => model.provider === provider && model.id === id), + }); if (parsed) { const explicitModel = availableModels.find(m => m.provider === parsed.provider && m.id === parsed.id); if (explicitModel) return explicitModel; @@ -8990,14 +9225,15 @@ export class AgentSession { */ #projectSnapcompactContextTokens(preparation: CompactionPreparation, result: snapcompact.CompactionResult): number { const archive = snapcompact.getPreservedArchive(result.preserveData); - const frames = archive ? snapcompact.images(archive) : undefined; + const blocks = archive ? snapcompact.historyBlocks(archive) : undefined; const summaryMessage = createCompactionSummaryMessage( result.summary, result.tokensBefore, new Date().toISOString(), result.shortSummary, undefined, - frames, + undefined, + blocks, ); let tokens = computeNonMessageTokens(this) + estimateTokens(summaryMessage); for (const message of preparation.recentMessages) { @@ -9087,7 +9323,6 @@ export class AgentSession { ); } } - await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action }); // Abort any older auto-compaction before installing this run's controller. this.#autoCompactionAbortController?.abort(); const autoCompactionAbortController = new AbortController(); @@ -9095,11 +9330,16 @@ export class AgentSession { const autoCompactionSignal = autoCompactionAbortController.signal; try { + // Emit start AFTER the controller is installed so isCompacting is already true + // for any listener — and for input routed during this emit's event-loop yield: + // a message typed as the compaction loader appears must land in the compaction + // queue, not the core steering queue (which handoff's agent.reset() would wipe). + await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action }); if (compactionSettings.strategy === "handoff" && reason !== "overflow") { const handoffFocus = AUTO_HANDOFF_THRESHOLD_FOCUS; const handoffResult = await this.handoff(handoffFocus, { autoTriggered: true, - signal: this.#autoCompactionAbortController.signal, + signal: autoCompactionSignal, }); if (!handoffResult) { const aborted = autoCompactionSignal.aborted; @@ -9221,15 +9461,15 @@ export class AgentSession { let details: unknown; // Snapcompact runs locally first; if its frame archive plus the kept - // history still overflows the model window (frames are capped by the - // image budget and cost ~FRAME_TOKEN_ESTIMATE each), an LLM summary is - // far cheaper — downgrade to context-full and take the summarizer path. + // history still overflows the model window (frames default to + // MAX_FRAMES_DEFAULT and cost ~FRAME_TOKEN_ESTIMATE each), an LLM + // summary is far cheaper — downgrade to context-full and take the + // summarizer path. let snapcompactResult: snapcompact.CompactionResult | undefined; if (action === "snapcompact" && compactionPrep.kind !== "fromHook") { snapcompactResult = await snapcompact.compact(preparation, { convertToLlm, model: this.model, - maxFrames: snapcompact.providerFrameBudget(this.model?.provider), }); const ctxWindow = this.model?.contextWindow ?? 0; const budget = @@ -9531,12 +9771,12 @@ export class AgentSession { triggerContextTokens?: number, ): Promise { const action = "shake"; - await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action }); this.#autoCompactionAbortController?.abort(); const controller = new AbortController(); this.#autoCompactionAbortController = controller; const signal = controller.signal; try { + await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action }); const result = await this.shake("elide", { config: DEFAULT_SHAKE_CONFIG, signal }); if (signal.aborted) { await this.#emitSessionEvent({ @@ -9834,7 +10074,7 @@ export class AgentSession { this.configWarnings.push(msg); continue; } - const parsed = parseRetryFallbackSelector(selectorStr); + const parsed = parseRetryFallbackSelector(selectorStr, this.#modelRegistry); if (!parsed) { const msg = `Invalid fallback selector format in role '${role}': ${selectorStr}`; logger.warn(msg); @@ -9857,7 +10097,7 @@ export class AgentSession { #getRetryFallbackPrimarySelector(role: string): RetryFallbackSelector | undefined { const configuredSelector = this.settings.getModelRole(role); - return configuredSelector ? parseRetryFallbackSelector(configuredSelector) : undefined; + return configuredSelector ? parseRetryFallbackSelector(configuredSelector, this.#modelRegistry) : undefined; } #clearActiveRetryFallback(): void { @@ -9878,7 +10118,7 @@ export class AgentSession { } #resolveRetryFallbackRole(currentSelector: string): string | undefined { - const parsedCurrent = parseRetryFallbackSelector(currentSelector); + const parsedCurrent = parseRetryFallbackSelector(currentSelector, this.#modelRegistry); if (!parsedCurrent) return undefined; const currentBaseSelector = formatRetryFallbackBaseSelector(parsedCurrent); const currentPlainSelector = this.model @@ -9910,7 +10150,7 @@ export class AgentSession { const chain = [primarySelector]; const seen = new Set([primarySelector.raw]); for (const selector of this.#getRetryFallbackChains()[role] ?? []) { - const parsed = parseRetryFallbackSelector(selector); + const parsed = parseRetryFallbackSelector(selector, this.#modelRegistry); if (!parsed || seen.has(parsed.raw)) continue; seen.add(parsed.raw); chain.push(parsed); @@ -9921,7 +10161,7 @@ export class AgentSession { #findRetryFallbackCandidates(role: string, currentSelector: string): RetryFallbackSelector[] { const chain = this.#getRetryFallbackEffectiveChain(role); if (chain.length <= 1) return []; - const parsedCurrent = parseRetryFallbackSelector(currentSelector); + const parsedCurrent = parseRetryFallbackSelector(currentSelector, this.#modelRegistry); const currentBaseSelector = parsedCurrent ? formatRetryFallbackBaseSelector(parsedCurrent) : undefined; const currentPlainSelector = this.model && parsedCurrent @@ -10018,7 +10258,7 @@ export class AgentSession { originalThinkingLevel, lastAppliedFallbackThinkingLevel, } = this.#activeRetryFallback; - const originalSelector = parseRetryFallbackSelector(originalSelectorRaw); + const originalSelector = parseRetryFallbackSelector(originalSelectorRaw, this.#modelRegistry); if (!originalSelector) { this.#clearActiveRetryFallback(); return; @@ -11070,7 +11310,7 @@ export class AgentSession { const hasServiceTierEntry = this.sessionManager .getBranch() .some(entry => entry.type === "service_tier_change"); - const defaultThinkingLevel = this.settings.get("defaultThinkingLevel"); + const defaultThinkingLevel = parseConfiguredThinkingLevel(this.settings.get("defaultThinkingLevel")); const configuredServiceTier = this.settings.get("serviceTier"); // Session log entries store only concrete levels. When `auto` has resolved // for a turn, the persisted context may already carry that concrete level @@ -12068,6 +12308,7 @@ export class AgentSession { model: this.agent.state.model, thinkingLevel: this.#thinkingLevel, tools: this.agent.state.tools, + inlineToolDescriptors: this.#pruneToolDescriptions, }); } diff --git a/packages/coding-agent/src/session/compact-modes.ts b/packages/coding-agent/src/session/compact-modes.ts new file mode 100644 index 000000000..346566055 --- /dev/null +++ b/packages/coding-agent/src/session/compact-modes.ts @@ -0,0 +1,105 @@ +/** + * Manual `/compact` subcommands. Kept in a dependency-free leaf module so the + * slash-command registry, the interactive controllers, and `AgentSession` + * can all import the mode metadata + parser without pulling in the heavy + * `agent-session` module graph (which would form an import cycle through the + * slash-command registry) — same rationale as `shake-types.ts`. + * + * Each mode is a one-off override layered on top of the configured + * `compaction.*` settings for a single invocation; it never mutates settings. + * Adding a mode is a single entry here: the command surface (autocomplete + + * ACP hint), the parser, and the engine override all read this table. + */ + +/** Subcommand selecting a one-off compaction mode for manual `/compact`. */ +export type CompactMode = "soft" | "remote" | "snapcompact"; + +/** + * Per-invocation overrides merged over the configured `compaction.*` settings. + * Narrowed to the two knobs the modes actually flip; the result stays + * assignable to the full `CompactionSettings`. + */ +export interface CompactionOverride { + strategy?: "context-full" | "snapcompact"; + remoteEnabled?: boolean; +} + +export interface CompactModeDef { + readonly name: CompactMode; + /** One-line description surfaced in autocomplete + help. */ + readonly description: string; + /** Settings overrides applied on top of `compaction.*` for this run. */ + readonly overrides: CompactionOverride; + /** + * When true, the mode produces no LLM summary, so trailing focus text is + * meaningless and rejected by the parser (snapcompact archives history into + * images without a directed summary). + */ + readonly rejectsFocus?: boolean; + /** + * When true, the mode explicitly demands a remote path; the engine warns and + * falls back to a local summary if neither a remote endpoint nor a + * provider-native compaction path is available. + */ + readonly requiresRemote?: boolean; +} + +export const COMPACT_MODES: readonly CompactModeDef[] = [ + { + name: "soft", + description: "Summarize locally with the active model (skip remote endpoints)", + overrides: { strategy: "context-full", remoteEnabled: false }, + }, + { + name: "remote", + description: "Summarize via the remote endpoint / provider-native compaction", + overrides: { strategy: "context-full", remoteEnabled: true }, + requiresRemote: true, + }, + { + name: "snapcompact", + description: "Archive history onto dense bitmap images the model reads back (no LLM call)", + overrides: { strategy: "snapcompact" }, + rejectsFocus: true, + }, +]; + +/** Resolve a subcommand token (case-insensitive) to its mode definition. */ +export function findCompactMode(name: string): CompactModeDef | undefined { + const key = name.trim().toLowerCase(); + return COMPACT_MODES.find(mode => mode.name === key); +} + +/** Parsed `/compact` arguments: an optional mode plus optional focus text. */ +export interface ParsedCompactArgs { + mode?: CompactMode; + instructions?: string; +} + +/** + * Split `/compact` args into a leading mode subcommand + focus instructions. + * + * Backward compatible: when the first token is not a known mode, the entire + * argument string is treated as focus instructions (the historical behavior). + * A recognized mode with `rejectsFocus` and trailing text is an error. + */ +export function parseCompactArgs(args: string): ParsedCompactArgs | { error: string } { + const trimmed = args.trim(); + if (!trimmed) return {}; + + const spaceIndex = trimmed.search(/\s/); + const firstToken = spaceIndex === -1 ? trimmed : trimmed.slice(0, spaceIndex); + const mode = findCompactMode(firstToken); + if (!mode) { + // No recognized mode prefix — keep the whole thing as focus instructions. + return { instructions: trimmed }; + } + + const focus = spaceIndex === -1 ? "" : trimmed.slice(spaceIndex + 1).trim(); + if (mode.rejectsFocus && focus) { + return { + error: `/compact ${mode.name} does not take focus instructions (it archives history without an LLM summary).`, + }; + } + return { mode: mode.name, instructions: focus || undefined }; +} diff --git a/packages/coding-agent/src/session/messages.ts b/packages/coding-agent/src/session/messages.ts index f111e51f7..991c2c94a 100644 --- a/packages/coding-agent/src/session/messages.ts +++ b/packages/coding-agent/src/session/messages.ts @@ -204,16 +204,14 @@ function wrapSteeringUserMessage(message: UserMessage): UserMessage { } export function wrapSteeringForModel(messages: AgentMessage[]): AgentMessage[] { - const last = messages[messages.length - 1]; - if (!isSteeringUserMessage(last)) return messages; - - let firstSteer = messages.length - 1; - while (firstSteer > 0 && isSteeringUserMessage(messages[firstSteer - 1])) { - firstSteer--; - } - + // Wrap EVERY steering message, not just a trailing run. The wire bytes of a + // steering message must be a pure function of the message itself, independent + // of its position in the array. When only the trailing steer was wrapped, the + // same persisted message was sent enveloped while it was the tail and raw once + // the assistant's reply buried it — rewriting already-cached prefix bytes and + // busting the provider prompt cache from that message onward on the next turn. let wrappedMessages: AgentMessage[] | undefined; - for (let i = firstSteer; i < messages.length; i++) { + for (let i = 0; i < messages.length; i++) { const message = messages[i]; if (!isSteeringUserMessage(message)) continue; const wrappedMessage = wrapSteeringUserMessage(message); diff --git a/packages/coding-agent/src/session/session-context.ts b/packages/coding-agent/src/session/session-context.ts index dd46f079d..08d4339e7 100644 --- a/packages/coding-agent/src/session/session-context.ts +++ b/packages/coding-agent/src/session/session-context.ts @@ -20,6 +20,13 @@ export interface SessionContext { mode: string; /** Mode-specific data from the last mode_change entry */ modeData?: Record; + /** + * Array parallel to messages, indicating which assistant turns should + * have their prompt-cache misses suppressed/explained (because a model, + * compaction, or plan-mode transition directly preceded them). + * Only populated in transcript mode. + */ + cacheMissExplainedAt?: boolean[]; } /** Lists session model strings to try when restoring, in fallback order. */ @@ -191,12 +198,45 @@ export function buildSessionContext( // 2. Emit kept messages (from firstKeptEntryId up to compaction) // 3. Emit messages after compaction const messages: AgentMessage[] = []; + const cacheMissExplainedAt: boolean[] = []; + let pendingReset = false; + let currentMode = "none"; + let lastAssistantModel: string | undefined; + + const handleEntryResetTracking = (entry: SessionEntry) => { + if (entry.type === "compaction") { + pendingReset = true; + } else if (entry.type === "model_change") { + pendingReset = true; + } else if (entry.type === "mode_change") { + const isPlanTransition = (entry.mode === "plan") !== (currentMode === "plan"); + if (isPlanTransition) { + pendingReset = true; + } + currentMode = entry.mode; + } + }; + + const pushMessage = (msg: AgentMessage) => { + messages.push(msg); + if (!options?.transcript) return; + if (msg.role === "assistant") { + const currentModel = `${msg.provider}/${msg.model}`; + const modelChanged = lastAssistantModel !== undefined && lastAssistantModel !== currentModel; + lastAssistantModel = currentModel; + cacheMissExplainedAt.push(pendingReset || modelChanged); + pendingReset = false; + } else { + cacheMissExplainedAt.push(false); + } + }; const appendMessage = (entry: SessionEntry) => { + handleEntryResetTracking(entry); if (entry.type === "message") { - messages.push(entry.message); + pushMessage(entry.message); } else if (entry.type === "custom_message") { - messages.push( + pushMessage( createCustomMessage( entry.customType, entry.content, @@ -207,7 +247,7 @@ export function buildSessionContext( ), ); } else if (entry.type === "branch_summary" && entry.summary) { - messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)); + pushMessage(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)); } }; @@ -217,16 +257,18 @@ export function buildSessionContext( // TUI) at the point it fired, with any snapcompact frames re-attached so // the component can report them. for (const entry of path) { + handleEntryResetTracking(entry); if (entry.type === "compaction") { const snapcompactArchive = snapcompact.getPreservedArchive(entry.preserveData); - messages.push( + pushMessage( createCompactionSummaryMessage( entry.summary, entry.tokensBefore, entry.timestamp, entry.shortSummary, undefined, - snapcompactArchive ? snapcompact.images(snapcompactArchive) : undefined, + undefined, + snapcompactArchive ? snapcompact.historyBlocks(snapcompactArchive) : undefined, ), ); } else { @@ -251,14 +293,15 @@ export function buildSessionContext( // Emit summary first; re-attach any archived snapcompact frames so the // model can keep reading the archived history after every context rebuild. const snapcompactArchive = snapcompact.getPreservedArchive(compaction.preserveData); - messages.push( + pushMessage( createCompactionSummaryMessage( compaction.summary, compaction.tokensBefore, compaction.timestamp, compaction.shortSummary, providerPayload, - snapcompactArchive ? snapcompact.images(snapcompactArchive) : undefined, + undefined, + snapcompactArchive ? snapcompact.historyBlocks(snapcompactArchive) : undefined, ), ); @@ -333,6 +376,9 @@ export function buildSessionContext( ); if (normalized.length === 0) { messages.splice(i, 1); + if (options?.transcript) { + cacheMissExplainedAt.splice(i, 1); + } } else { messages[i] = { ...message, content: normalized }; } @@ -340,6 +386,7 @@ export function buildSessionContext( return { messages, + cacheMissExplainedAt: options?.transcript ? cacheMissExplainedAt : undefined, thinkingLevel, serviceTier, models, diff --git a/packages/coding-agent/src/session/session-dump-format.ts b/packages/coding-agent/src/session/session-dump-format.ts index a985e5b96..b6bd8cd37 100644 --- a/packages/coding-agent/src/session/session-dump-format.ts +++ b/packages/coding-agent/src/session/session-dump-format.ts @@ -7,9 +7,9 @@ * args), `### Tool Result: `, and the execution/summary sections. */ import type { AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; -import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, Model, ToolExample, TSchema } from "@oh-my-pi/pi-ai"; import { renderDelimitedThinking, renderToolInventory } from "@oh-my-pi/pi-ai/dialect"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import { YAML } from "bun"; import { canonicalizeMessage } from "../utils/thinking-display"; import { @@ -38,6 +38,7 @@ export interface FormatSessionDumpTextOptions { model?: Model | null; thinkingLevel?: ThinkingLevel | string | null; tools?: readonly SessionDumpToolInfo[]; + inlineToolDescriptors?: boolean; } interface InventoryTool { @@ -78,7 +79,8 @@ function renderDumpHeader(options: FormatSessionDumpTextOptions, inventoryTools: lines.push(`Thinking Level: ${options.thinkingLevel ?? ""}`); lines.push("\n"); - if (inventoryTools.length > 0) { + const hasSystemPromptToolInventory = options.inlineToolDescriptors === true; + if (inventoryTools.length > 0 && !hasSystemPromptToolInventory) { lines.push("## Available Tools\n"); lines.push(renderToolInventory(inventoryTools, model?.id ?? "")); lines.push("\n"); diff --git a/packages/coding-agent/src/session/session-history-format.ts b/packages/coding-agent/src/session/session-history-format.ts index 71682a0a8..f41f6f20a 100644 --- a/packages/coding-agent/src/session/session-history-format.ts +++ b/packages/coding-agent/src/session/session-history-format.ts @@ -7,8 +7,8 @@ * as one-liners. No system prompt, no tool catalog, no config sections. */ import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; -import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, ImageContent, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import type { BashExecutionMessage, BranchSummaryMessage, diff --git a/packages/coding-agent/src/session/snapcompact-inline.ts b/packages/coding-agent/src/session/snapcompact-inline.ts index a18417f60..10f8588ac 100644 --- a/packages/coding-agent/src/session/snapcompact-inline.ts +++ b/packages/coding-agent/src/session/snapcompact-inline.ts @@ -46,8 +46,8 @@ export type SnapcompactSavingsSink = ( // Per-provider image-count budgets live in @oh-my-pi/snapcompact // (`providerImageBudget`): snapcompact frames are 1568px (<2000px) so // dimension/size limits never bind; only COUNT does. Once the budget is -// spent (e.g. OpenRouter's hard 8-image cap, already consumed by archive -// frames), tool results ship verbatim as text. +// spent by already-attached archive/system-prompt images, tool results ship +// verbatim as text. const MAX_SYSTEM_PROMPT_FRAMES = 6; /** Tool results under this many tokens are never rasterized — the swap can't * save enough to justify trading crisp text for an image. */ diff --git a/packages/coding-agent/src/session/streaming-output.ts b/packages/coding-agent/src/session/streaming-output.ts index b4aa18ab4..5eafeb528 100644 --- a/packages/coding-agent/src/session/streaming-output.ts +++ b/packages/coding-agent/src/session/streaming-output.ts @@ -495,8 +495,10 @@ export function truncateTail(content: string, options: TruncationOptions = {}): * Returned without surrounding newlines so callers can position it freely. */ export function formatMiddleElisionMarker(elidedLines: number, elidedBytes: number): string { - const linesPart = `${elidedLines.toLocaleString()} line${elidedLines === 1 ? "" : "s"}`; - return `[… ${linesPart} elided (${formatBytes(elidedBytes)}) …]`; + // A 0/1-line elision (e.g. one giant single line) would read as + // "[…0ln elided…]"; fall back to a byte count there. + if (elidedLines <= 1) return `[…${elidedBytes}B elided…]`; + return `[…${elidedLines}ln elided…]`; } /** @@ -578,8 +580,6 @@ export function truncateMiddle(content: string, options: TruncationOptions = {}) export interface InlineByteCapOptions { /** Inline byte budget. Defaults to {@link DEFAULT_MAX_BYTES}. */ maxBytes?: number; - /** What the text is, for the elision marker (e.g. "bash output"). */ - label: string; /** * Persist the full text as a session artifact. When an artifact id is * returned, a `[raw output: artifact://]` footer is appended so the @@ -619,7 +619,7 @@ export async function enforceInlineByteCap(text: string, options: InlineByteCapO const head = trimHeadToLineBoundary(truncateHeadBytes(text, Math.floor(maxBytes * 0.6)).text); const tail = trimTailToLineBoundary(truncateTailBytes(text, Math.floor(maxBytes * 0.25)).text); const elidedBytes = Math.max(0, totalBytes - Buffer.byteLength(head, "utf-8") - Buffer.byteLength(tail, "utf-8")); - const marker = `[… elided ${elidedBytes} bytes of ${options.label} …]`; + const marker = `[…${elidedBytes}B elided…]`; let composed = `${head}\n${marker}\n${tail}`; const artifactId = await options.saveArtifact?.(text); diff --git a/packages/coding-agent/src/session/tool-choice-queue.ts b/packages/coding-agent/src/session/tool-choice-queue.ts index 76b22d8cd..6bab82316 100644 --- a/packages/coding-agent/src/session/tool-choice-queue.ts +++ b/packages/coding-agent/src/session/tool-choice-queue.ts @@ -65,6 +65,20 @@ interface InFlight { invoked: boolean; } +/** + * A non-forcing pending preview invoker. Registered by `queueResolveHandler` + * (resolve previews) so the `resolve` tool can dispatch to a staged action + * WITHOUT this queue forcing `tool_choice`. The agent-loop's + * SoftToolRequirement lifecycle (remind-then-escalate) owns any forcing. + */ +interface PendingInvoker { + /** Unique id for this staged preview; never reused (never clobbered by label). */ + id: string; + /** Source tool that staged the preview (e.g. "ast_edit"), for the reminder. */ + sourceToolName: string; + onInvoked: (input: unknown) => Promise | unknown; +} + // ── Queue ─────────────────────────────────────────────────────────────────── export class ToolChoiceQueue { @@ -75,6 +89,12 @@ export class ToolChoiceQueue { * Consumers (e.g. todo reminder suppression) read via consumeLastServedLabel(). */ #lastResolvedLabel: string | undefined; + /** + * Non-forcing pending preview invokers, stacked by UNIQUE id. The `resolve` + * tool dispatches to the head; the agent-loop's soft-tool-requirement + * lifecycle drives resolution without this queue forcing `tool_choice`. + */ + #pendingInvokers: PendingInvoker[] = []; // ── Push ────────────────────────────────────────────────────────────── @@ -190,6 +210,44 @@ export class ToolChoiceQueue { }; } + // ── Non-forcing pending invokers ────────────────────────────────────── + // Preview producers (queueResolveHandler) register here so `resolve` can + // dispatch to a staged action WITHOUT a forced tool_choice (no messages-cache + // bust). Stacked by UNIQUE id: a re-register replaces only the same id, so + // concurrent/sequential previews each survive and resolve independently. + + /** Register (or replace by exact id) a non-forcing pending preview invoker. */ + registerPendingInvoker( + id: string, + sourceToolName: string, + onInvoked: (input: unknown) => Promise | unknown, + ): void { + this.removePendingInvoker(id); + this.#pendingInvokers.push({ id, sourceToolName, onInvoked }); + } + + /** Drop the pending invoker with this id (e.g. after it resolves). */ + removePendingInvoker(id: string): void { + this.#pendingInvokers = this.#pendingInvokers.filter(p => p.id !== id); + } + + /** True when at least one non-forcing pending preview is registered. */ + get hasPendingInvoker(): boolean { + return this.#pendingInvokers.length > 0; + } + + /** The head (most-recently registered) pending invoker's handler, for resolve dispatch. */ + peekPendingInvoker(): ((input: unknown) => Promise | unknown) | undefined { + return this.#pendingInvokers.at(-1)?.onInvoked; + } + + /** The head pending preview's stable id + source tool, for building the agent-level + * SoftToolRequirement (the id drives reminder re-injection when the head changes). */ + peekPendingHead(): { id: string; sourceToolName: string } | undefined { + const head = this.#pendingInvokers.at(-1); + return head ? { id: head.id, sourceToolName: head.sourceToolName } : undefined; + } + // ── Cleanup ─────────────────────────────────────────────────────────── /** Remove all directives with the given label. Rejects in-flight if it matches. */ @@ -206,6 +264,7 @@ export class ToolChoiceQueue { this.reject("cleared"); } this.#queue = []; + this.#pendingInvokers = []; this.#lastResolvedLabel = undefined; } diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index 2a2f1aeab..074f6238e 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -3,7 +3,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth"; import { setNextRequestDebugPath } from "@oh-my-pi/pi-ai/utils/request-debug"; -import type { AutocompleteItem } from "@oh-my-pi/pi-tui"; +import { type AutocompleteItem, Spacer } from "@oh-my-pi/pi-tui"; import { APP_NAME, setProjectDir } from "@oh-my-pi/pi-utils"; import { COLLAB_GUEST_ALLOWED_COMMANDS, CollabGuestLink } from "../collab/guest"; import { CollabHost } from "../collab/host"; @@ -27,9 +27,11 @@ import { resolveMemoryBackend } from "../memory-backend"; import { theme } from "../modes/theme/theme"; import type { InteractiveModeContext } from "../modes/types"; import type { AgentSession, FreshSessionResult } from "../session/agent-session"; +import { COMPACT_MODES, parseCompactArgs } from "../session/compact-modes"; import { formatShakeSummary, type ShakeMode } from "../session/shake-types"; import { urlHyperlinkAlways } from "../tui"; import { getChangelogPath, parseChangelog } from "../utils/changelog"; +import { CollabQrCodeComponent } from "./helpers/collab-qrcode"; import { buildContextReportText } from "./helpers/context-report"; import { formatDuration } from "./helpers/format"; import { createMarketplaceManager } from "./helpers/marketplace-manager"; @@ -99,6 +101,19 @@ function collabLinkHint(host: CollabHost, heading: string, view = false): string ].join("\n"); } +function showCollabQrCode(ctx: InteractiveModeContext, webLink: string): void { + try { + ctx.present([new Spacer(1), new CollabQrCodeComponent(webLink)]); + } catch (err) { + ctx.showError(`Failed to render collab QR code: ${errorMessage(err)}`); + } +} + +function showCollabLink(ctx: InteractiveModeContext, host: CollabHost, heading: string, view = false): void { + ctx.showStatus(collabLinkHint(host, heading, view), { dim: false }); + showCollabQrCode(ctx, view ? host.webViewLink : host.webLink); +} + function formatFreshSessionResult(result: FreshSessionResult): string { const stateLabel = result.closedProviderSessions === 1 ? "provider state" : "provider states"; return `Fresh provider session started (${result.closedProviderSessions} ${stateLabel} pruned).`; @@ -589,8 +604,8 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ const ctx = runtime.ctx; ctx.editor.setText(""); const args = command.args.trim(); - const [first = ""] = args.split(/\s+/, 1); - if (first === "stop") { + const { verb, rest } = parseSubcommand(args); + if (verb === "stop") { if (!ctx.collabHost) { ctx.showStatus("Not hosting a collab session"); return; @@ -599,7 +614,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ ctx.showStatus("Collab stopped"); return; } - if (first === "status") { + if (verb === "status") { if (ctx.collabHost) { const names = ctx.collabHost.participants.map(p => p.role === "host" ? `${p.name} (host)` : p.readOnly ? `${p.name} (view-only)` : p.name, @@ -620,15 +635,18 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ ctx.showError("Already in a collab session as a guest (/leave first)"); return; } - const view = first === "view"; + const knownStartVerb = verb === "start" || verb === "view"; + const view = verb === "view"; if (ctx.collabHost) { - ctx.showStatus( - collabLinkHint(ctx.collabHost, view ? "Read-only collab link" : "Collab session active", view), - { dim: false }, + showCollabLink( + ctx, + ctx.collabHost, + view ? "Read-only collab session active" : "Collab session active", + view, ); return; } - const explicitUrl = first === "start" || view ? args.slice(first.length).trim() : args; + const explicitUrl = knownStartVerb ? rest : args; const relayInput = explicitUrl || ctx.settings.get("collab.relayUrl") || ""; if (!relayInput) { ctx.showError( @@ -638,15 +656,16 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ } // Scheme-less relay args default to wss (ws:// must be spelled out for localhost). const relayUrl = relayInput.includes("://") ? relayInput : `wss://${relayInput}`; + const webUrl = ctx.settings.get("collab.webUrl") || ""; const host = new CollabHost(ctx); try { - await host.start(relayUrl); + await host.start(relayUrl, webUrl); } catch (err) { ctx.showError(`Failed to start collab session: ${errorMessage(err)}`); return; } ctx.collabHost = host; - ctx.showStatus(collabLinkHint(host, "Collab session started!", view), { dim: false }); + showCollabLink(ctx, host, "Collab session started!", view); }, }, { @@ -1239,13 +1258,20 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ name: "compact", description: "Manually compact the session context", acpDescription: "Compact the conversation", - inlineHint: "[focus instructions]", + subcommands: COMPACT_MODES.map(mode => ({ + name: mode.name, + description: mode.description, + usage: mode.rejectsFocus ? undefined : "[focus]", + })), + acpInputHint: `[${COMPACT_MODES.map(mode => mode.name).join("|")}] [focus]`, allowArgs: true, handle: async (command, runtime) => { + const parsed = parseCompactArgs(command.args); + if ("error" in parsed) return usage(parsed.error, runtime); const before = runtime.session.getContextUsage?.(); const beforeTokens = before?.tokens; try { - await runtime.session.compact(command.args || undefined); + await runtime.session.compact(parsed.instructions, parsed.mode ? { mode: parsed.mode } : undefined); } catch (err) { // Compaction precondition failures (no model, already compacted, too // small) and provider errors propagate as plain Errors; surface them @@ -1263,9 +1289,13 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ return commandConsumed(); }, handleTui: async (command, runtime) => { - const customInstructions = command.args || undefined; + const parsed = parseCompactArgs(command.args); runtime.ctx.editor.setText(""); - await runtime.ctx.handleCompactCommand(customInstructions); + if ("error" in parsed) { + runtime.ctx.showWarning(parsed.error); + return; + } + await runtime.ctx.handleCompactCommand(parsed.instructions, parsed.mode); }, }, { diff --git a/packages/coding-agent/src/slash-commands/helpers/collab-qrcode.ts b/packages/coding-agent/src/slash-commands/helpers/collab-qrcode.ts new file mode 100644 index 000000000..388886599 --- /dev/null +++ b/packages/coding-agent/src/slash-commands/helpers/collab-qrcode.ts @@ -0,0 +1,28 @@ +import { type Component, visibleWidth } from "@oh-my-pi/pi-tui"; +import { fgOrPlain } from "../../modes/theme/theme"; +import { QrCode, renderQrHalfBlocks } from "../../utils/qrcode"; + +/** + * One-shot transcript block that prints a collab browser-join URL as a + * scannable QR code. The symbol is encoded once at construction (byte mode, + * EC level M) and rendered as ANSI half-blocks; on terminals too narrow for + * the symbol it degrades to a one-line hint pointing at the printed URL. + */ +export class CollabQrCodeComponent implements Component { + readonly #lines: readonly string[]; + readonly #minWidth: number; + + constructor(readonly url: string) { + const rows = renderQrHalfBlocks(QrCode.encodeText(url, "M")); + this.#lines = rows.map(row => ` ${row}`); + this.#minWidth = rows.reduce((max, row) => Math.max(max, visibleWidth(row)), 0) + 1; + } + + render(width: number): readonly string[] { + if (width < this.#minWidth) { + const warning = `QR code hidden: terminal width ${width}; need ${this.#minWidth}. Use the browser URL above.`; + return [` ${fgOrPlain("warning", warning)}`]; + } + return this.#lines; + } +} diff --git a/packages/coding-agent/src/system-prompt.ts b/packages/coding-agent/src/system-prompt.ts index 0a485216c..225690b2c 100644 --- a/packages/coding-agent/src/system-prompt.ts +++ b/packages/coding-agent/src/system-prompt.ts @@ -373,11 +373,11 @@ export interface BuildSystemPromptOptions { toolNames?: string[]; /** Text to append to system prompt. */ appendSystemPrompt?: string; - /** Repeat full tool descriptions in system prompt. Default: false */ - repeatToolDescriptions?: boolean; + /** Inline full tool descriptors in the system prompt. Default: true */ + inlineToolDescriptors?: boolean; /** * Whether provider-native tool calling is active (no owned/in-band syntax). - * When true and `repeatToolDescriptions` is false, the inventory renders as a + * When true and `inlineToolDescriptors` is false, the inventory renders as a * compact tool-name list; otherwise it renders full `# Tool:` sections. Default: true */ nativeTools?: boolean; @@ -433,7 +433,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): customPrompt, tools, appendSystemPrompt, - repeatToolDescriptions = false, + inlineToolDescriptors: providedInlineToolDescriptors, nativeTools = true, skillsSettings, toolNames: providedToolNames, @@ -454,6 +454,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): model, personality = "default", } = options; + const inlineToolDescriptors = providedInlineToolDescriptors ?? true; const resolvedCwd = cwd ?? getProjectDir(); const prepDefaults = { @@ -599,10 +600,10 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): examples: meta?.examples, }; }); - // List mode shows a compact tool-name list; it only applies when descriptions - // are not repeated AND native tool calling is active (the model already has the - // schemas). Otherwise render full `# Tool:` sections. - const toolListMode = !repeatToolDescriptions && nativeTools; + // List mode shows a compact tool-name list; it only applies when descriptors + // stay in provider-native tool schemas AND native tool calling is active. + // Otherwise render full `# Tool:` sections inline in the system prompt. + const toolListMode = !inlineToolDescriptors && nativeTools; const toolInventory = toolListMode ? "" : renderToolInventory(inventoryTools, model ?? ""); // Filter skills for the rendered system prompt: @@ -632,7 +633,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): tools: toolNames, toolInfo, toolInventory, - repeatToolDescriptions, + inlineToolDescriptors, toolListMode, toolRefs, environment, diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 551518467..514973193 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -196,7 +196,7 @@ function installSubagentRetryFallbackChain(args: { function renderIrcPeerRoster(selfId: string): string { const peers = AgentRegistry.global() .list() - .filter(ref => ref.id !== selfId && ref.status !== "aborted"); + .filter(ref => ref.id !== selfId && ref.status !== "aborted" && ref.kind !== "advisor"); if (peers.length === 0) return "- (no other agents)"; const lines = peers.map( peer => diff --git a/packages/coding-agent/src/task/output-manager.ts b/packages/coding-agent/src/task/output-manager.ts index 74fba7832..a4954d91f 100644 --- a/packages/coding-agent/src/task/output-manager.ts +++ b/packages/coding-agent/src/task/output-manager.ts @@ -11,6 +11,7 @@ * collisions across repeated or nested task invocations. */ import * as fs from "node:fs/promises"; +import { ADVISOR_TRANSCRIPT_STEM } from "../advisor/transcript-recorder"; /** * Manages agent output ID allocation to ensure uniqueness. @@ -29,6 +30,10 @@ export class AgentOutputManager { constructor(getArtifactsDir: () => string | null, options?: { parentPrefix?: string }) { this.#getArtifactsDir = getArtifactsDir; this.#parentPrefix = options?.parentPrefix; + // Reserve the advisor transcript stem: a subagent allocated this id would + // write `.jsonl`, clobbering the advisor's `__advisor.jsonl` in the same + // artifacts dir. Reserving bumps such a request to `__advisor-2`. + this.#taken.add(ADVISOR_TRANSCRIPT_STEM); } /** diff --git a/packages/coding-agent/src/thinking.ts b/packages/coding-agent/src/thinking.ts index f7361dd63..6f571e93d 100644 --- a/packages/coding-agent/src/thinking.ts +++ b/packages/coding-agent/src/thinking.ts @@ -32,26 +32,45 @@ const THINKING_LEVEL_METADATA: Record = { [ThinkingLevel.High]: { value: ThinkingLevel.High, label: "high", description: "Deep reasoning (~16k tokens)" }, [ThinkingLevel.XHigh]: { value: ThinkingLevel.XHigh, - label: "xhigh", + label: "max", description: "Maximum reasoning (~32k tokens)", }, }; -const THINKING_LEVELS = new Set([ThinkingLevel.Inherit, ThinkingLevel.Off, ...THINKING_EFFORTS]); -const EFFORT_LEVELS = new Set(THINKING_EFFORTS); +const EFFORT_BY_SELECTOR: Readonly> = { + [Effort.Minimal]: Effort.Minimal, + [Effort.Low]: Effort.Low, + [Effort.Medium]: Effort.Medium, + [Effort.High]: Effort.High, + [Effort.XHigh]: Effort.XHigh, + max: Effort.XHigh, +}; +const THINKING_LEVEL_BY_SELECTOR: Readonly> = { + [ThinkingLevel.Inherit]: ThinkingLevel.Inherit, + [ThinkingLevel.Off]: ThinkingLevel.Off, + [ThinkingLevel.Minimal]: ThinkingLevel.Minimal, + [ThinkingLevel.Low]: ThinkingLevel.Low, + [ThinkingLevel.Medium]: ThinkingLevel.Medium, + [ThinkingLevel.High]: ThinkingLevel.High, + [ThinkingLevel.XHigh]: ThinkingLevel.XHigh, +}; + +function getOwnSelector(selectors: Readonly>, value: string | null | undefined): T | undefined { + return value === undefined || value === null || !Object.hasOwn(selectors, value) ? undefined : selectors[value]; +} /** * Parses a provider-facing effort value. */ export function parseEffort(value: string | null | undefined): Effort | undefined { - return value !== undefined && value !== null && EFFORT_LEVELS.has(value) ? (value as Effort) : undefined; + return getOwnSelector(EFFORT_BY_SELECTOR, value); } /** * Parses an agent-local thinking selector. */ export function parseThinkingLevel(value: string | null | undefined): ThinkingLevel | undefined { - return value !== undefined && value !== null && THINKING_LEVELS.has(value) ? (value as ThinkingLevel) : undefined; + return getOwnSelector(THINKING_LEVEL_BY_SELECTOR, value); } /** @@ -125,6 +144,7 @@ const AUTO_THINKING_METADATA: ConfiguredThinkingLevelMetadata = { */ export function parseConfiguredThinkingLevel(value: string | null | undefined): ConfiguredThinkingLevel | undefined { if (value === AUTO_THINKING) return AUTO_THINKING; + if (value === "max") return ThinkingLevel.XHigh; return parseThinkingLevel(value); } diff --git a/packages/coding-agent/src/tools/__tests__/json-tree.test.ts b/packages/coding-agent/src/tools/__tests__/json-tree.test.ts new file mode 100644 index 000000000..1d0c1e4bc --- /dev/null +++ b/packages/coding-agent/src/tools/__tests__/json-tree.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; +import { formatArgsInline } from "../json-tree"; + +describe("formatArgsInline", () => { + test("a trailing scalar grows into the available width instead of a fixed cap", () => { + // Regression: the value used to be hard-capped at 24 columns, so a long + // note was truncated even when the card had plenty of room (issue: advise + // preview cut to `note="Your “stric…"`). + const note = "x".repeat(200); + const narrow = formatArgsInline({ severity: "concern", note }, 40); + const wide = formatArgsInline({ severity: "concern", note }, 120); + expect(Bun.stringWidth(wide)).toBeGreaterThan(Bun.stringWidth(narrow) + 40); + // Both stay within their budget. + expect(Bun.stringWidth(narrow)).toBeLessThanOrEqual(40); + expect(Bun.stringWidth(wide)).toBeLessThanOrEqual(120); + }); + + test("every key stays visible even when a leading value is long", () => { + const out = formatArgsInline({ path: "x".repeat(200), pattern: "needle", limit: 5 }, 80); + expect(out).toContain("path="); + expect(out).toContain("pattern="); + expect(out).toContain("limit="); + expect(Bun.stringWidth(out)).toBeLessThanOrEqual(80); + }); + + test("short values render fully without truncation markers", () => { + expect(formatArgsInline({ a: "x", b: 5, c: true }, 80)).toBe('a="x", b=5, c=true'); + }); + + test("hidden meta keys are skipped", () => { + const out = formatArgsInline({ [INTENT_FIELD]: "noise", __partialJson: "{}", path: "src/foo.ts" }, 80); + expect(out).toBe('path="src/foo.ts"'); + }); +}); diff --git a/packages/coding-agent/src/tools/approval.ts b/packages/coding-agent/src/tools/approval.ts index 90d56369b..c1d53fbe8 100644 --- a/packages/coding-agent/src/tools/approval.ts +++ b/packages/coding-agent/src/tools/approval.ts @@ -159,7 +159,7 @@ export function requiresApproval( export function truncateForPrompt(value: string, maxChars = DEFAULT_PROMPT_TRUNCATE_CHARS): string { if (value.length <= maxChars) return value; const omitted = value.length - maxChars; - return `${value.slice(0, maxChars)}… (${omitted} chars truncated)`; + return `${value.slice(0, maxChars)}[…${omitted}ch elided…]`; } /** diff --git a/packages/coding-agent/src/tools/bash-interactive.ts b/packages/coding-agent/src/tools/bash-interactive.ts index fc34c7296..3c1a57e6c 100644 --- a/packages/coding-agent/src/tools/bash-interactive.ts +++ b/packages/coding-agent/src/tools/bash-interactive.ts @@ -274,16 +274,16 @@ class BashInteractiveOverlayComponent implements Component { : truncateToWidth(this.uiTheme.fg("dim", "session finished"), innerWidth); const visibleLines = this.#readViewport(innerWidth, maxContentRows); const content = visibleLines.length > 0 ? visibleLines : [padding(innerWidth)]; - const borderHorizontal = this.uiTheme.fg("border", this.uiTheme.boxSharp.horizontal.repeat(innerWidth)); - const borderVertical = this.uiTheme.fg("border", this.uiTheme.boxSharp.vertical); + const borderHorizontal = this.uiTheme.fg("border", this.uiTheme.boxRound.horizontal.repeat(innerWidth)); + const borderVertical = this.uiTheme.fg("border", this.uiTheme.boxRound.vertical); const boxLine = (line: string) => `${borderVertical}${line}${padding(Math.max(0, innerWidth - visibleWidth(line)))}${borderVertical}`; return [ - `${this.uiTheme.fg("border", this.uiTheme.boxSharp.topLeft)}${borderHorizontal}${this.uiTheme.fg("border", this.uiTheme.boxSharp.topRight)}`, + `${this.uiTheme.fg("border", this.uiTheme.boxRound.topLeft)}${borderHorizontal}${this.uiTheme.fg("border", this.uiTheme.boxRound.topRight)}`, boxLine(header), ...content.map(boxLine), boxLine(footer), - `${this.uiTheme.fg("border", this.uiTheme.boxSharp.bottomLeft)}${borderHorizontal}${this.uiTheme.fg("border", this.uiTheme.boxSharp.bottomRight)}`, + `${this.uiTheme.fg("border", this.uiTheme.boxRound.bottomLeft)}${borderHorizontal}${this.uiTheme.fg("border", this.uiTheme.boxRound.bottomRight)}`, ]; } diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index 327dabd13..141707f3a 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -485,7 +485,6 @@ export class BashTool implements AgentTool saveBashOriginalArtifact(this.session, full), }); diff --git a/packages/coding-agent/src/tools/browser.ts b/packages/coding-agent/src/tools/browser.ts index d703620d8..070bc99ee 100644 --- a/packages/coding-agent/src/tools/browser.ts +++ b/packages/coding-agent/src/tools/browser.ts @@ -332,7 +332,6 @@ export class BrowserTool implements AgentTool saveBrowserOutputArtifact(this.session, full), }); details.result = cappedText; diff --git a/packages/coding-agent/src/tools/eval.ts b/packages/coding-agent/src/tools/eval.ts index a775254ad..c7a24b209 100644 --- a/packages/coding-agent/src/tools/eval.ts +++ b/packages/coding-agent/src/tools/eval.ts @@ -64,7 +64,7 @@ function formatDisplayJsonForText(value: unknown): string { text = String(value); } if (text.length > MAX_DISPLAY_TEXT_BYTES) { - text = `${text.slice(0, MAX_DISPLAY_TEXT_BYTES)}\n… (${text.length - MAX_DISPLAY_TEXT_BYTES} chars truncated)`; + text = `${text.slice(0, MAX_DISPLAY_TEXT_BYTES)}\n[…${text.length - MAX_DISPLAY_TEXT_BYTES}ch elided…]`; } return text; } diff --git a/packages/coding-agent/src/tools/gh.ts b/packages/coding-agent/src/tools/gh.ts index 229b96471..8f179f042 100644 --- a/packages/coding-agent/src/tools/gh.ts +++ b/packages/coding-agent/src/tools/gh.ts @@ -2179,7 +2179,7 @@ function formatPrFiles(files: GhPrFile[] | undefined): string[] { } if (files.length > FILE_PREVIEW_LIMIT) { - lines.push(`- ... ${files.length - FILE_PREVIEW_LIMIT} more files`); + lines.push(`[…${files.length - FILE_PREVIEW_LIMIT} files elided…]`); } return lines; diff --git a/packages/coding-agent/src/tools/index.ts b/packages/coding-agent/src/tools/index.ts index cb328c09a..21fcaaef1 100644 --- a/packages/coding-agent/src/tools/index.ts +++ b/packages/coding-agent/src/tools/index.ts @@ -1,6 +1,6 @@ import type { InMemorySnapshotStore } from "@oh-my-pi/hashline"; import type { AgentTelemetryConfig, AgentTool } from "@oh-my-pi/pi-agent-core"; -import type { FetchImpl, Model, ToolChoice } from "@oh-my-pi/pi-ai"; +import type { FetchImpl, ImageContent, Model, ToolChoice } from "@oh-my-pi/pi-ai"; import { logger } from "@oh-my-pi/pi-utils"; import type { AsyncJobManager } from "../async/job-manager"; import type { Rule } from "../capability/rule"; @@ -113,6 +113,13 @@ export type ContextFileEntry = { depth?: number; }; +/** Image attachment handle exposed to tools for user-facing labels such as `Image #1`. */ +export type ImageAttachmentEntry = { + label: string; + uri: string; + image: ImageContent; +}; + export type { DiscoverableTool, DiscoverableToolSearchIndex, @@ -305,6 +312,10 @@ export interface ToolSession { steer?(message: { customType: string; content: string; details?: unknown }): void; /** Peek the currently in-flight tool-choice queue directive's invocation handler. Used by the `resolve` tool to dispatch to the pending action. */ peekQueueInvoker?(): ((input: unknown) => Promise | unknown) | undefined; + /** Peek the most-recently registered non-forcing pending preview invoker. The `resolve` + * tool dispatches to it so a staged preview resolves WITHOUT forcing tool_choice — the + * agent-loop's SoftToolRequirement lifecycle owns reminder injection and escalation. */ + peekPendingInvoker?(): ((input: unknown) => Promise | unknown) | undefined; /** Peek the long-lived "standing" resolve handler registered by a mode (e.g. plan mode). * Consulted by the `resolve` tool as a fallback when no queue invoker is in flight, * letting modes accept `resolve` invocations without forcing the tool choice every turn. */ @@ -353,6 +364,8 @@ export interface ToolSession { /** Get the active OpenTelemetry config so subagent dispatch can forward * the parent's tracer/hooks with the subagent's own identity stamped. */ getTelemetry?: () => AgentTelemetryConfig | undefined; + /** Return image attachments visible to tools for resolving labels such as `Image #1`. */ + getImageAttachments?: () => ImageAttachmentEntry[]; } export type ToolFactory = (session: ToolSession) => Tool | null | Promise; diff --git a/packages/coding-agent/src/tools/inspect-image.ts b/packages/coding-agent/src/tools/inspect-image.ts index faee2c998..4d3a67faf 100644 --- a/packages/coding-agent/src/tools/inspect-image.ts +++ b/packages/coding-agent/src/tools/inspect-image.ts @@ -1,6 +1,6 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import { instrumentedCompleteSimple, resolveTelemetry } from "@oh-my-pi/pi-agent-core"; -import { type Api, completeSimple, type Model, type ToolExample } from "@oh-my-pi/pi-ai"; +import { type Api, completeSimple, type ImageContent, type Model, type ToolExample } from "@oh-my-pi/pi-ai"; import { prompt } from "@oh-my-pi/pi-utils"; import { type } from "arktype"; import { extractTextContent } from "../commit/utils"; @@ -11,6 +11,7 @@ import inspectImageSystemPromptTemplate from "../prompts/tools/inspect-image-sys import { ImageInputTooLargeError, type LoadedImageInput, + loadImageAttachmentInput, loadImageInput, MAX_IMAGE_INPUT_BYTES, webpExclusionForModel, @@ -19,13 +20,62 @@ import type { ToolSession } from "./index"; import { ToolError } from "./tool-errors"; const inspectImageSchema = type({ - path: type("string").describe("image path"), + path: type("string").describe("image file path, Image #N label, or attachment://N URI"), question: type("string").describe("question about image"), "+": "reject", }); export type InspectImageParams = typeof inspectImageSchema.infer; +interface ImageAttachmentReference { + index: number; +} + +const IMAGE_ATTACHMENT_REFERENCE_REGEX = + /^\s*(?:\[?Image #([1-9]\d*)(?:,[^\]\n]*)?\]?|(?:attachment|image):\/\/([1-9]\d*))\s*$/i; + +function parseImageAttachmentReference(path: string): ImageAttachmentReference | null { + const match = IMAGE_ATTACHMENT_REFERENCE_REGEX.exec(path); + if (!match) return null; + const rawIndex = match[1] ?? match[2]; + if (!rawIndex) return null; + return { index: Number(rawIndex) }; +} + +function formatAvailableImageAttachments(attachments: readonly { label: string; uri: string }[]): string { + if (attachments.length === 0) return "none"; + return attachments.map(attachment => `${attachment.label} -> ${attachment.uri}`).join(", "); +} + +async function loadAttachmentReferenceInput(options: { + path: string; + reference: ImageAttachmentReference; + attachments: readonly { label: string; uri: string; image: ImageContent }[]; + autoResize: boolean; + excludeWebP: boolean | undefined; +}): Promise { + const attachment = options.attachments[options.reference.index - 1]; + if (!attachment) { + const available = formatAvailableImageAttachments(options.attachments); + if (options.attachments.length === 0) { + throw new ToolError( + `No image attachments are available in this turn. path="${options.path}" must be a readable file path or attachment URI.`, + ); + } + throw new ToolError( + `Could not resolve image attachment '${options.path}'. Available image attachments: ${available}. Pass an attachment URI or a readable filesystem path.`, + ); + } + return loadImageAttachmentInput({ + image: attachment.image, + label: attachment.label, + uri: attachment.uri, + autoResize: options.autoResize, + maxBytes: MAX_IMAGE_INPUT_BYTES, + excludeWebP: options.excludeWebP, + }); +} + export interface InspectImageToolDetails { model: string; imagePath: string; @@ -129,14 +179,27 @@ export class InspectImageTool implements AgentTool { const bus = IrcBus.global(); const peers = registry .list() - .filter(ref => ref.id !== senderId && ref.status !== "aborted") + .filter(ref => ref.id !== senderId && ref.status !== "aborted" && ref.kind !== "advisor") .map(ref => ({ id: ref.id, displayName: ref.displayName, diff --git a/packages/coding-agent/src/tools/json-tree.ts b/packages/coding-agent/src/tools/json-tree.ts index f83ada48a..6e86b5a29 100644 --- a/packages/coding-agent/src/tools/json-tree.ts +++ b/packages/coding-agent/src/tools/json-tree.ts @@ -1,7 +1,7 @@ /** * JSON tree rendering utilities shared across tool renderers. */ -import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import type { Theme } from "../modes/theme/theme"; import { truncateToWidth } from "./render-utils"; @@ -19,6 +19,8 @@ const ARGS_INLINE_PAIR_SEP = ", "; const ARGS_INLINE_PAIR_SEP_WIDTH = Bun.stringWidth(ARGS_INLINE_PAIR_SEP); const ARGS_INLINE_MORE = "…"; const ARGS_INLINE_MORE_WIDTH = Bun.stringWidth(ARGS_INLINE_MORE); +/** Minimal value footprint (quotes + a couple chars) reserved for each not-yet-rendered key. */ +const ARGS_INLINE_TAIL_VALUE_RESERVE = 4; function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); @@ -49,10 +51,15 @@ export function formatScalar(value: unknown, maxLen: number): string { * Format args inline for collapsed view. */ export function formatArgsInline(args: Record, maxWidth: number): string { - let result = ""; - let width = 0; + const keys: string[] = []; for (const key in args) { if (key in HIDDEN_ARG_KEYS) continue; + keys.push(key); + } + let result = ""; + let width = 0; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; const value = args[key]; const sep = width > 0 ? ARGS_INLINE_PAIR_SEP : ""; const sepW = width > 0 ? ARGS_INLINE_PAIR_SEP_WIDTH : 0; @@ -61,11 +68,21 @@ export function formatArgsInline(args: Record, maxWidth: number if (cap <= 0) { return `${result}${ARGS_INLINE_MORE}`; } - const valueMaxLen = Math.min(maxWidth - current, 24); + // Reserve each still-pending key's minimal footprint (sep + name + `=` + + // a short value) so a long value can't starve the keys that follow it. + let tailReserve = 0; + for (let j = i + 1; j < keys.length; j++) { + tailReserve += ARGS_INLINE_PAIR_SEP_WIDTH + Bun.stringWidth(keys[j]) + 1 + ARGS_INLINE_TAIL_VALUE_RESERVE; + } + // Budget the whole `key=value` piece against the width left after the + // tail reserve, then back out the value's share. The last key reserves + // nothing and fills the line. + const pieceBudget = Math.min(cap, maxWidth - current - tailReserve); + const valueMaxLen = Math.max(1, pieceBudget - Bun.stringWidth(key) - 3); const valueStr = formatScalar(value, valueMaxLen); const piece = `${key}=${valueStr}`; const pieceW = Bun.stringWidth(piece); - if (pieceW > cap) { + if (pieceW > pieceBudget) { return `${result}${sep}${truncateToWidth(piece, cap)}`; } result += sep + piece; diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index 1aa84fdd9..30791e8f1 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -275,7 +275,7 @@ function formatMergedBraceLine( shouldAddHashLines: boolean, shouldAddLineNumbers: boolean, ): { model: string; display: string } { - const merged = `${headText.trimEnd()} .. ${tailText.trim()}`; + const merged = `${headText.trimEnd()} … ${tailText.trim()}`; if (shouldAddHashLines) { return { model: `${startLine}-${endLine}:${merged}`, display: merged }; } @@ -315,7 +315,7 @@ const FOOTER_RANGE_SAMPLES = 2; /** * Footer appended to summarized reads telling the model how to recover the - * elided body. Without this hint, agents either ignore the `...`/`{ .. }` + * elided body. Without this hint, agents either ignore the `…`/`{ … }` * markers or burn a turn guessing the right selector (see issue #1046). The * footer demonstrates the multi-range selector syntax with concrete sample * ranges drawn from the actual elision so the model re-reads only what it @@ -327,7 +327,6 @@ function formatSummaryElisionFooter( elidedLines: number, ): string { if (elidedRanges.length === 0) return ""; - const lineWord = elidedLines === 1 ? "line" : "lines"; const sampleCount = Math.min(elidedRanges.length, FOOTER_RANGE_SAMPLES); const selector = elidedRanges .slice(0, sampleCount) @@ -335,7 +334,7 @@ function formatSummaryElisionFooter( .join(","); const example = `${readPath}:${selector}`; const tail = elidedRanges.length > sampleCount ? `, e.g. ${example}` : ` with ${example}`; - return `[${elidedLines} ${lineWord} elided; re-read needed ranges${tail}]`; + return `[…${elidedLines}ln elided; re-read needed ranges${tail}]`; } const READ_CHUNK_SIZE = 8 * 1024; @@ -1904,8 +1903,8 @@ export class ReadTool implements AgentTool { let elidedLines = 0; for (const unit of units) { if (unit.kind === "elided") { - modelParts.push("..."); - displayParts.push("..."); + modelParts.push("…"); + displayParts.push("…"); elidedRanges.push({ start: unit.startLine, end: unit.endLine }); elidedLines += unit.endLine - unit.startLine + 1; continue; diff --git a/packages/coding-agent/src/tools/resolve.ts b/packages/coding-agent/src/tools/resolve.ts index fb5fad8e2..8cbcc80bb 100644 --- a/packages/coding-agent/src/tools/resolve.ts +++ b/packages/coding-agent/src/tools/resolve.ts @@ -1,4 +1,10 @@ -import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; +import type { + AgentTool, + AgentToolContext, + AgentToolResult, + AgentToolUpdateCallback, + CustomMessage, +} from "@oh-my-pi/pi-agent-core"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; import { prompt, untilAborted } from "@oh-my-pi/pi-utils"; @@ -28,11 +34,18 @@ export interface ResolveToolDetails { sourceResultDetails?: unknown; } +/** Monotonic suffix making each staged preview's pending-invoker id UNIQUE, so + * stacked previews never clobber one another by label. */ +let pendingPreviewSeq = 0; + /** - * Queue a resolve-protocol handler on the tool-choice queue. Forces the next - * LLM call to invoke the hidden `resolve` tool, wraps the caller's apply/reject - * callbacks into an onInvoked closure that matches the resolve schema, and - * steers a preview reminder so the model understands why. + * Register a non-forcing resolve-protocol handler for a staged preview. Wraps the + * caller's apply/reject into an onInvoked closure (matching the resolve schema) and + * stores it on the tool-choice queue's pending-invoker registry under a UNIQUE id. + * The `resolve` tool dispatches to it; the agent-loop's SoftToolRequirement + * lifecycle injects the preview reminder and escalates to a forced `resolve` only + * if the model declines — so a compliant turn pays ZERO tool_choice change (no + * prompt-cache messages-cache invalidation). * * This is the canonical entry point for any tool that wants preview/apply * semantics. No session-level abstraction is needed: callers pass their @@ -48,46 +61,55 @@ export function queueResolveHandler( }, ): void { const queue = session.getToolChoiceQueue?.(); - const forced = session.buildToolChoice?.("resolve"); - if (!queue || !forced || typeof forced === "string") return; + if (!queue) return; - const steerReminder = (): void => { - session.steer?.({ - customType: "resolve-reminder", - content: [ - "", - "This is a preview. Call the `resolve` tool to apply or discard these changes.", - "", - ].join("\n"), - details: { toolName: options.sourceToolName }, + // Unique per preview: stacked/sequential previews each get their own entry. + const id = `pending-action:${options.sourceToolName}:${pendingPreviewSeq++}`; + + const onInvoked = async (input: unknown): Promise> => { + const result = await runResolveInvocation(input as ResolveParams, { + sourceToolName: options.sourceToolName, + label: options.label, + apply: options.apply, + reject: options.reject, + onApplyError: () => { + // Apply threw (e.g. ast_edit overlapping replacements). Keep the preview + // pending under the SAME id so the model can `discard` or fix-and-retry; + // runResolveInvocation rethrows, so the success-path removal below is skipped. + queue.registerPendingInvoker(id, options.sourceToolName, onInvoked); + }, }); + // Resolved (apply succeeded, or discard): consume the staged action exactly once. + queue.removePendingInvoker(id); + return result; }; - const pushDirective = (): void => { - queue.pushOnce(forced, { - label: `pending-action:${options.sourceToolName}`, - now: true, - onRejected: () => "requeue", - onInvoked: async (input: unknown) => - runResolveInvocation(input as ResolveParams, { - sourceToolName: options.sourceToolName, - label: options.label, - apply: options.apply, - reject: options.reject, - onApplyError: () => { - // Apply threw (e.g. ast_edit overlapping replacements). Re-push the - // same directive so the preview remains pending and the model can - // `discard` or fix-and-retry on the next turn instead of being - // stranded with no pending action to address. - pushDirective(); - steerReminder(); - }, - }), - }); - }; + // NON-FORCING: register so `resolve` can dispatch here WITHOUT changing + // tool_choice. The agent-loop injects the reminder (from the SoftToolRequirement + // the session builds) and forces a resolve turn only on non-compliance. + queue.registerPendingInvoker(id, options.sourceToolName, onInvoked); +} - pushDirective(); - steerReminder(); +/** + * The canonical preview reminder. The resolve mechanism owns the wording; the + * agent-loop delivers it via the session's `SoftToolRequirement.reminder` (injected + * once per pending-preview head) instead of a host-side steer, so it lands as a + * stable mid-history append and never churns the cached prefix. + */ +export function buildResolveReminderMessage(sourceToolName: string): CustomMessage { + return { + role: "custom", + customType: "resolve-reminder", + content: [ + "", + "This is a preview. Call the `resolve` tool to apply or discard these changes.", + "", + ].join("\n"), + display: false, + details: { toolName: sourceToolName }, + attribution: "agent", + timestamp: Date.now(), + }; } /** @@ -185,7 +207,10 @@ export class ResolveTool implements AgentTool> { return untilAborted(signal, async () => { - const invoker = this.session.peekQueueInvoker?.() ?? this.session.peekStandingResolveHandler?.(); + const invoker = + this.session.peekQueueInvoker?.() ?? + this.session.peekPendingInvoker?.() ?? + this.session.peekStandingResolveHandler?.(); if (!invoker) { // `discard` is a request to cancel/abort a staged action. When nothing is // pending, the desired end-state (no staged change) already holds, so honor diff --git a/packages/coding-agent/src/tui/output-block.ts b/packages/coding-agent/src/tui/output-block.ts index c08b0792e..970a96f14 100644 --- a/packages/coding-agent/src/tui/output-block.ts +++ b/packages/coding-agent/src/tui/output-block.ts @@ -48,8 +48,8 @@ function normalizeContentPaddingLeft(value: number | undefined): number { export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): string[] { const { header, headerMeta, state, sections = [], width, applyBg = true } = options; - const h = theme.boxSharp.horizontal; - const v = theme.boxSharp.vertical; + const h = theme.boxRound.horizontal; + const v = theme.boxRound.vertical; const cap = h.repeat(3); const lineWidth = Math.max(0, width); // Border colors: running/pending use accent, success uses dim (gray), error/warning keep their colors @@ -84,8 +84,8 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st const rows: BlockRow[] = []; rows.push({ kind: "bar", - leftChar: theme.boxSharp.topLeft, - rightChar: theme.boxSharp.topRight, + leftChar: theme.boxRound.topLeft, + rightChar: theme.boxRound.topRight, label: header, meta: headerMeta, }); @@ -99,15 +99,15 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st if (section.label) { rows.push({ kind: "bar", - leftChar: theme.boxSharp.teeRight, - rightChar: theme.boxSharp.teeLeft, + leftChar: theme.boxRound.teeRight, + rightChar: theme.boxRound.teeLeft, label: section.label, }); } else if (section.separator && sectionIndex > 0) { rows.push({ kind: "bar", - leftChar: theme.boxSharp.teeRight, - rightChar: theme.boxSharp.teeLeft, + leftChar: theme.boxRound.teeRight, + rightChar: theme.boxRound.teeLeft, }); } const allLines = section.lines.flatMap(l => l.split("\n")); @@ -126,7 +126,7 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st } } - rows.push({ kind: "bottom", leftChar: theme.boxSharp.bottomLeft, rightChar: theme.boxSharp.bottomRight }); + rows.push({ kind: "bottom", leftChar: theme.boxRound.bottomLeft, rightChar: theme.boxRound.bottomRight }); const H = rows.length; diff --git a/packages/coding-agent/src/utils/file-mentions.ts b/packages/coding-agent/src/utils/file-mentions.ts index f42326ffc..87ad2bfa1 100644 --- a/packages/coding-agent/src/utils/file-mentions.ts +++ b/packages/coding-agent/src/utils/file-mentions.ts @@ -24,7 +24,7 @@ import { resolveReadPath } from "../tools/path-utils"; import { formatDimensionNote, resizeImage } from "./image-resize"; /** Regex to match @filepath patterns in text */ -const FILE_MENTION_REGEX = /@([^\s@]+)/g; +const FILE_MENTION_REGEX = /@(?:"([^"]+)"|'([^']+)'|([^\s@]+))/g; const LEADING_PUNCTUATION_REGEX = /^[`"'([{<]+/; const TRAILING_PUNCTUATION_REGEX = /[)\]}>.,;:!?"'`]+$/; const MENTION_BOUNDARY_REGEX = /[\s([{<"'`]/; @@ -168,7 +168,10 @@ export function extractFileMentions(text: string): string[] { const index = match.index ?? 0; if (!isMentionBoundary(text, index)) continue; - const cleaned = sanitizeMentionPath(match[1]); + const rawPath = match[1] ?? match[2] ?? match[3]; + if (!rawPath) continue; + + const cleaned = match[1] !== undefined || match[2] !== undefined ? rawPath.trim() : sanitizeMentionPath(rawPath); if (!cleaned) continue; mentions.push(cleaned); diff --git a/packages/coding-agent/src/utils/image-loading.ts b/packages/coding-agent/src/utils/image-loading.ts index d2b33b509..a537ffcba 100644 --- a/packages/coding-agent/src/utils/image-loading.ts +++ b/packages/coding-agent/src/utils/image-loading.ts @@ -38,6 +38,17 @@ export interface LoadImageInputOptions { excludeWebP?: boolean; } +/** Options for loading an in-memory chat image attachment as a vision-model input. */ +export interface LoadImageAttachmentInputOptions { + image: ImageContent; + label: string; + uri: string; + autoResize: boolean; + maxBytes?: number; + /** Force non-WebP output (e.g. for Ollama). Leave unset to honor `OMP_NO_WEBP`. */ + excludeWebP?: boolean; +} + export interface LoadedImageInput { resolvedPath: string; mimeType: string; @@ -161,3 +172,50 @@ export async function loadImageInput(options: LoadImageInputOptions): Promise { + const maxBytes = options.maxBytes ?? MAX_IMAGE_INPUT_BYTES; + if (!SUPPORTED_INPUT_IMAGE_MIME_TYPES.has(options.image.mimeType)) { + return null; + } + + const inputBytes = Buffer.byteLength(options.image.data, "base64"); + if (inputBytes > maxBytes) { + throw new ImageInputTooLargeError(inputBytes, maxBytes); + } + + let outputData = options.image.data; + let outputMimeType = options.image.mimeType; + let outputBytes = inputBytes; + let dimensionNote: string | undefined; + + const shouldReencodeWebP = options.excludeWebP === true && options.image.mimeType === "image/webp"; + if (options.autoResize || shouldReencodeWebP) { + try { + const resized = await resizeImage(options.image, { excludeWebP: options.excludeWebP }); + outputData = resized.data; + outputMimeType = resized.mimeType; + outputBytes = resized.buffer.byteLength; + dimensionNote = formatDimensionNote(resized); + } catch { + // keep original image when resize fails + } + } + + let textNote = `Read image attachment ${options.label} [${outputMimeType}]`; + if (dimensionNote) { + textNote += `\n${dimensionNote}`; + } + + return { + resolvedPath: options.uri, + mimeType: outputMimeType, + data: outputData, + textNote, + dimensionNote, + bytes: outputBytes, + }; +} diff --git a/packages/coding-agent/src/utils/qrcode.ts b/packages/coding-agent/src/utils/qrcode.ts new file mode 100644 index 000000000..05558a908 --- /dev/null +++ b/packages/coding-agent/src/utils/qrcode.ts @@ -0,0 +1,535 @@ +/** + * Self-contained QR Code generator (byte mode, versions 1-40, EC levels + * L/M/Q/H) with a half-block ANSI terminal renderer. + * + * Pure TypeScript, zero dependencies: the collab `/collab qrcode` command uses + * it to print scannable browser-join codes without pulling a runtime QR + * package into the bundle. The algorithm follows ISO/IEC 18004; the two + * error-correction tables below are direct transcriptions of that spec. + */ + +export type QrEcLevel = "L" | "M" | "Q" | "H"; + +/** Per-EC-level metadata: index into the spec tables and the 2-bit format code. */ +const EC_LEVELS: Record = { + L: { table: 0, formatBits: 1 }, + M: { table: 1, formatBits: 0 }, + Q: { table: 2, formatBits: 3 }, + H: { table: 3, formatBits: 2 }, +}; + +const MIN_VERSION = 1; +const MAX_VERSION = 40; + +// ISO/IEC 18004 Table 9 — error-correction codewords per block, indexed +// [ecTable][version]. Index 0 of each row pads the 1-based version axis. +// biome-ignore format: spec table, one row per EC level +const ECC_CODEWORDS_PER_BLOCK: readonly (readonly number[])[] = [ + [-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], + [-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28], + [-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], + [-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], +]; + +// ISO/IEC 18004 Table 9 — number of error-correction blocks, indexed +// [ecTable][version]. +// biome-ignore format: spec table, one row per EC level +const NUM_EC_BLOCKS: readonly (readonly number[])[] = [ + [-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25], + [-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49], + [-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68], + [-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81], +]; + +const BYTE_MODE_INDICATOR = 0x4; +const PAD_BYTES = [0xec, 0x11] as const; + +const PENALTY_N1 = 3; +const PENALTY_N2 = 3; +const PENALTY_N3 = 40; +const PENALTY_N4 = 10; + +function getBit(value: number, index: number): boolean { + return ((value >>> index) & 1) !== 0; +} + +/** Whether mask `m` flips the module at (x, y); the 8 data-mask conditions from the spec. */ +function maskBit(m: number, x: number, y: number): boolean { + switch (m) { + case 0: + return (x + y) % 2 === 0; + case 1: + return y % 2 === 0; + case 2: + return x % 3 === 0; + case 3: + return (x + y) % 3 === 0; + case 4: + return (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0; + case 5: + return ((x * y) % 2) + ((x * y) % 3) === 0; + case 6: + return (((x * y) % 2) + ((x * y) % 3)) % 2 === 0; + default: + return (((x + y) % 2) + ((x * y) % 3)) % 2 === 0; + } +} + +/** GF(256) multiply under the QR primitive polynomial x^8 + x^4 + x^3 + x^2 + 1 (0x11D). */ +function gfMultiply(x: number, y: number): number { + let z = 0; + for (let i = 7; i >= 0; i--) { + z = (z << 1) ^ ((z >>> 7) * 0x11d); + z ^= ((y >>> i) & 1) * x; + } + return z & 0xff; +} + +/** Reed-Solomon generator polynomial coefficients for `degree` EC codewords. */ +function rsDivisor(degree: number): Uint8Array { + const result = new Uint8Array(degree); + result[degree - 1] = 1; + let root = 1; + for (let i = 0; i < degree; i++) { + for (let j = 0; j < result.length; j++) { + result[j] = gfMultiply(result[j]!, root); + if (j + 1 < result.length) result[j] ^= result[j + 1]!; + } + root = gfMultiply(root, 0x02); + } + return result; +} + +/** Reed-Solomon remainder (the EC codewords) of `data` divided by `divisor`. */ +function rsRemainder(data: Uint8Array, divisor: Uint8Array): Uint8Array { + const result = new Uint8Array(divisor.length); + for (const b of data) { + const factor = b ^ result[0]!; + result.copyWithin(0, 1); + result[result.length - 1] = 0; + for (let i = 0; i < divisor.length; i++) result[i] ^= gfMultiply(divisor[i]!, factor); + } + return result; +} + +/** Total data modules (bits available before EC) for a version. */ +function rawDataModules(version: number): number { + let result = (16 * version + 128) * version + 64; + if (version >= 2) { + const numAlign = Math.floor(version / 7) + 2; + result -= (25 * numAlign - 10) * numAlign - 55; + if (version >= 7) result -= 36; + } + return result; +} + +/** Number of usable data codewords (8-bit) at a given version + EC level. */ +function dataCodewords(version: number, ecTable: number): number { + return ( + Math.floor(rawDataModules(version) / 8) - + ECC_CODEWORDS_PER_BLOCK[ecTable]![version]! * NUM_EC_BLOCKS[ecTable]![version]! + ); +} + +/** Byte-mode character-count indicator width in bits for a version. */ +function charCountBits(version: number): number { + return version <= 9 ? 8 : 16; +} + +export interface QrEncodeOptions { + /** Lowest version to consider (default 1). */ + minVersion?: number; + /** Highest version to consider (default 40). */ + maxVersion?: number; + /** Force a mask 0-7; -1 (default) auto-selects the lowest-penalty mask. */ + mask?: number; +} + +/** + * A finished QR symbol: a square grid of dark/light modules plus the chosen + * version, EC level, and mask. `module(x, y)` is the only access path the + * renderers need. + */ +export class QrCode { + readonly size: number; + /** Selected mask pattern (0-7). */ + readonly mask: number; + readonly #modules: boolean[][]; + readonly #isFunction: boolean[][]; + + private constructor( + readonly version: number, + readonly ecLevel: QrEcLevel, + dataCodewordsInterleaved: Uint8Array, + mask: number, + ) { + this.size = version * 4 + 17; + this.#modules = Array.from({ length: this.size }, () => new Array(this.size).fill(false)); + this.#isFunction = Array.from({ length: this.size }, () => new Array(this.size).fill(false)); + + this.#drawFunctionPatterns(); + this.#drawCodewords(dataCodewordsInterleaved); + this.mask = this.#selectMask(mask); + } + + module(x: number, y: number): boolean { + return this.#modules[y]![x]!; + } + + /** Encode a string in byte mode (UTF-8). Throws if it exceeds version 40. */ + static encodeText(text: string, ecLevel: QrEcLevel = "M", options?: QrEncodeOptions): QrCode { + return QrCode.encodeBytes(new TextEncoder().encode(text), ecLevel, options); + } + + /** Encode raw bytes in byte mode. Throws if they exceed version 40 at this EC level. */ + static encodeBytes(data: Uint8Array, ecLevel: QrEcLevel = "M", options?: QrEncodeOptions): QrCode { + const ec = EC_LEVELS[ecLevel]; + const minVersion = Math.max(MIN_VERSION, options?.minVersion ?? MIN_VERSION); + const maxVersion = Math.min(MAX_VERSION, options?.maxVersion ?? MAX_VERSION); + + let version = minVersion; + for (; ; version++) { + const capacityBits = dataCodewords(version, ec.table) * 8; + const usedBits = 4 + charCountBits(version) + data.length * 8; + if (usedBits <= capacityBits) break; + if (version >= maxVersion) { + throw new Error(`data too long for a QR code (${data.length} bytes, EC ${ecLevel})`); + } + } + + const bits = new BitBuffer(); + bits.append(BYTE_MODE_INDICATOR, 4); + bits.append(data.length, charCountBits(version)); + for (const b of data) bits.append(b, 8); + + const capacityBits = dataCodewords(version, ec.table) * 8; + bits.append(0, Math.min(4, capacityBits - bits.length)); // terminator + bits.append(0, (8 - (bits.length % 8)) % 8); // byte-align + for (let pad = 0; bits.length < capacityBits; pad ^= 1) bits.append(PAD_BYTES[pad]!, 8); + + const codewords = QrCode.#interleave(bits.toBytes(), version, ec.table); + const mask = options?.mask ?? -1; + if (mask < -1 || mask > 7) throw new Error(`invalid mask ${mask}`); + return new QrCode(version, ecLevel, codewords, mask); + } + + /** Split into blocks, append Reed-Solomon EC, and interleave per the spec. */ + static #interleave(data: Uint8Array, version: number, ecTable: number): Uint8Array { + const numBlocks = NUM_EC_BLOCKS[ecTable]![version]!; + const eccLen = ECC_CODEWORDS_PER_BLOCK[ecTable]![version]!; + const rawCodewords = Math.floor(rawDataModules(version) / 8); + const numShort = numBlocks - (rawCodewords % numBlocks); + const shortLen = Math.floor(rawCodewords / numBlocks); + const divisor = rsDivisor(eccLen); + + const blocks: Uint8Array[] = []; + const blockLen = shortLen + 1; + for (let i = 0, offset = 0; i < numBlocks; i++) { + const datLen = shortLen - eccLen + (i < numShort ? 0 : 1); + const dat = data.subarray(offset, offset + datLen); + offset += datLen; + // Every block is padded to the longest block's length so interleaving + // stays column-aligned; short blocks leave a zero in the last data slot. + const block = new Uint8Array(blockLen); + block.set(dat, 0); + block.set(rsRemainder(dat, divisor), blockLen - eccLen); + blocks.push(block); + } + + const result = new Uint8Array(rawCodewords); + let w = 0; + for (let i = 0; i < blockLen; i++) { + for (let b = 0; b < numBlocks; b++) { + // Skip the padding column at the data/EC boundary of short blocks. + if (i === shortLen - eccLen && b < numShort) continue; + result[w++] = blocks[b]![i]!; + } + } + return result; + } + + // ── Module placement ────────────────────────────────────────────────── + + #setFunction(x: number, y: number, dark: boolean): void { + this.#modules[y]![x] = dark; + this.#isFunction[y]![x] = true; + } + + #drawFunctionPatterns(): void { + for (let i = 0; i < this.size; i++) { + this.#setFunction(6, i, i % 2 === 0); + this.#setFunction(i, 6, i % 2 === 0); + } + this.#drawFinder(3, 3); + this.#drawFinder(this.size - 4, 3); + this.#drawFinder(3, this.size - 4); + + const align = this.#alignmentPositions(); + const last = align.length - 1; + for (let i = 0; i <= last; i++) { + for (let j = 0; j <= last; j++) { + // Skip the three finder corners. + if ((i === 0 && j === 0) || (i === 0 && j === last) || (i === last && j === 0)) continue; + this.#drawAlignment(align[i]!, align[j]!); + } + } + + this.#drawFormatBits(0); // placeholder until mask chosen + this.#drawVersion(); + } + + #drawFinder(cx: number, cy: number): void { + for (let dy = -4; dy <= 4; dy++) { + for (let dx = -4; dx <= 4; dx++) { + const dist = Math.max(Math.abs(dx), Math.abs(dy)); + const x = cx + dx; + const y = cy + dy; + if (x >= 0 && x < this.size && y >= 0 && y < this.size) { + this.#setFunction(x, y, dist !== 2 && dist !== 4); + } + } + } + } + + #drawAlignment(cx: number, cy: number): void { + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) { + this.#setFunction(cx + dx, cy + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1); + } + } + } + + #alignmentPositions(): number[] { + if (this.version === 1) return []; + const numAlign = Math.floor(this.version / 7) + 2; + const step = this.version === 32 ? 26 : Math.ceil((this.size - 13) / (numAlign * 2 - 2)) * 2; + const result = [6]; + for (let pos = this.size - 7; result.length < numAlign; pos -= step) result.splice(1, 0, pos); + return result; + } + + #drawFormatBits(mask: number): void { + const data = (EC_LEVELS[this.ecLevel].formatBits << 3) | mask; + let rem = data; + for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537); + const bits = ((data << 10) | rem) ^ 0x5412; + + for (let i = 0; i <= 5; i++) this.#setFunction(8, i, getBit(bits, i)); + this.#setFunction(8, 7, getBit(bits, 6)); + this.#setFunction(8, 8, getBit(bits, 7)); + this.#setFunction(7, 8, getBit(bits, 8)); + for (let i = 9; i < 15; i++) this.#setFunction(14 - i, 8, getBit(bits, i)); + + for (let i = 0; i < 8; i++) this.#setFunction(this.size - 1 - i, 8, getBit(bits, i)); + for (let i = 8; i < 15; i++) this.#setFunction(8, this.size - 15 + i, getBit(bits, i)); + this.#setFunction(8, this.size - 8, true); // always-dark module + } + + #drawVersion(): void { + if (this.version < 7) return; + let rem = this.version; + for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25); + const bits = (this.version << 12) | rem; + for (let i = 0; i < 18; i++) { + const bit = getBit(bits, i); + const a = this.size - 11 + (i % 3); + const b = Math.floor(i / 3); + this.#setFunction(a, b, bit); + this.#setFunction(b, a, bit); + } + } + + #drawCodewords(data: Uint8Array): void { + let i = 0; + const totalBits = data.length * 8; + for (let right = this.size - 1; right >= 1; right -= 2) { + if (right === 6) right = 5; + for (let vert = 0; vert < this.size; vert++) { + for (let j = 0; j < 2; j++) { + const x = right - j; + const upward = ((right + 1) & 2) === 0; + const y = upward ? this.size - 1 - vert : vert; + if (!this.#isFunction[y]![x] && i < totalBits) { + this.#modules[y]![x] = getBit(data[i >>> 3]!, 7 - (i & 7)); + i++; + } + } + } + } + } + + // ── Masking ─────────────────────────────────────────────────────────── + + #applyMask(mask: number): void { + for (let y = 0; y < this.size; y++) { + for (let x = 0; x < this.size; x++) { + if (!this.#isFunction[y]![x] && maskBit(mask, x, y)) { + this.#modules[y]![x] = !this.#modules[y]![x]; + } + } + } + } + + #selectMask(forced: number): number { + let mask = forced; + if (mask === -1) { + let minPenalty = Infinity; + for (let m = 0; m < 8; m++) { + this.#applyMask(m); + this.#drawFormatBits(m); + const penalty = this.#penaltyScore(); + if (penalty < minPenalty) { + mask = m; + minPenalty = penalty; + } + this.#applyMask(m); // undo (XOR mask is self-inverse) + } + } + this.#applyMask(mask); + this.#drawFormatBits(mask); + return mask; + } + + #penaltyScore(): number { + let result = 0; + const size = this.size; + const mods = this.#modules; + + // Rule 1 + Rule 3 — adjacent same-color runs, finder-like patterns (rows). + for (let y = 0; y < size; y++) { + let runColor = false; + let runLen = 0; + const history = [0, 0, 0, 0, 0, 0, 0]; + for (let x = 0; x < size; x++) { + if (mods[y]![x] === runColor) { + runLen++; + if (runLen === 5) result += PENALTY_N1; + else if (runLen > 5) result++; + } else { + this.#finderAddHistory(runLen, history); + if (!runColor) result += this.#finderCountPatterns(history) * PENALTY_N3; + runColor = mods[y]![x]!; + runLen = 1; + } + } + result += this.#finderTerminate(runColor, runLen, history) * PENALTY_N3; + } + // Rule 1 + Rule 3 — columns. + for (let x = 0; x < size; x++) { + let runColor = false; + let runLen = 0; + const history = [0, 0, 0, 0, 0, 0, 0]; + for (let y = 0; y < size; y++) { + if (mods[y]![x] === runColor) { + runLen++; + if (runLen === 5) result += PENALTY_N1; + else if (runLen > 5) result++; + } else { + this.#finderAddHistory(runLen, history); + if (!runColor) result += this.#finderCountPatterns(history) * PENALTY_N3; + runColor = mods[y]![x]!; + runLen = 1; + } + } + result += this.#finderTerminate(runColor, runLen, history) * PENALTY_N3; + } + + // Rule 2 — 2x2 blocks of one color. + for (let y = 0; y < size - 1; y++) { + for (let x = 0; x < size - 1; x++) { + const c = mods[y]![x]; + if (c === mods[y]![x + 1] && c === mods[y + 1]![x] && c === mods[y + 1]![x + 1]) { + result += PENALTY_N2; + } + } + } + + // Rule 4 — dark/light balance. + let dark = 0; + for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) if (mods[y]![x]) dark++; + const total = size * size; + const k = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1; + result += k * PENALTY_N4; + return result; + } + + #finderCountPatterns(history: readonly number[]): number { + const n = history[1]!; + const core = n > 0 && history[2] === n && history[3] === n * 3 && history[4] === n && history[5] === n; + return ( + (core && history[0]! >= n * 4 && history[6]! >= n ? 1 : 0) + + (core && history[6]! >= n * 4 && history[0]! >= n ? 1 : 0) + ); + } + + #finderAddHistory(runLen: number, history: number[]): void { + if (history[0] === 0) runLen += this.size; // light border before the first run + history.pop(); + history.unshift(runLen); + } + + #finderTerminate(runColor: boolean, runLen: number, history: number[]): number { + if (runColor) { + this.#finderAddHistory(runLen, history); + runLen = 0; + } + runLen += this.size; // light border after the final run + this.#finderAddHistory(runLen, history); + return this.#finderCountPatterns(history); + } +} + +/** Append-only MSB-first bit buffer. */ +class BitBuffer { + #bits: number[] = []; + + get length(): number { + return this.#bits.length; + } + + append(value: number, count: number): void { + for (let i = count - 1; i >= 0; i--) this.#bits.push((value >>> i) & 1); + } + + toBytes(): Uint8Array { + const out = new Uint8Array(this.#bits.length >>> 3); + for (let i = 0; i < this.#bits.length; i++) out[i >>> 3] = (out[i >>> 3]! << 1) | this.#bits[i]!; + return out; + } +} + +export interface QrRenderOptions { + /** Quiet-zone width in modules on every side (default 4, per spec). */ + margin?: number; +} + +const ANSI_RESET = "\x1b[0m"; +const ANSI_QR_ROW_PREFIX = "\x1b[47m\x1b[30m"; // white background, black foreground + +/** + * Render a QR symbol as ANSI half-block rows: each text row packs two module + * rows via `▀`/`▄`/`█`, drawn black-on-white so a phone camera reads dark + * modules as data and the quiet zone as the light margin. The leading margin + * makes the symbol scannable regardless of the terminal's own background. + */ +export function renderQrHalfBlocks(qr: QrCode, options?: QrRenderOptions): string[] { + const margin = Math.max(0, options?.margin ?? 4); + const dim = qr.size + margin * 2; + const dark = (gx: number, gy: number): boolean => { + const x = gx - margin; + const y = gy - margin; + return x >= 0 && x < qr.size && y >= 0 && y < qr.size && qr.module(x, y); + }; + + const lines: string[] = []; + for (let gy = 0; gy < dim; gy += 2) { + let row = ANSI_QR_ROW_PREFIX; + for (let gx = 0; gx < dim; gx++) { + const top = dark(gx, gy); + const bottom = gy + 1 < dim && dark(gx, gy + 1); + row += top ? (bottom ? "█" : "▀") : bottom ? "▄" : " "; + } + lines.push(row + ANSI_RESET); + } + return lines; +} diff --git a/packages/coding-agent/src/web/scrapers/firefox-addons.ts b/packages/coding-agent/src/web/scrapers/firefox-addons.ts index a36976b1b..c7fe84e2a 100644 --- a/packages/coding-agent/src/web/scrapers/firefox-addons.ts +++ b/packages/coding-agent/src/web/scrapers/firefox-addons.ts @@ -173,7 +173,7 @@ export const handleFirefoxAddons: SpecialHandler = async ( md += `- ${permission}\n`; } if (permissions.length > preview.length) { - md += `\n*...and ${permissions.length - preview.length} more*\n`; + md += `\n[…${permissions.length - preview.length} permissions elided…]\n`; } } diff --git a/packages/coding-agent/src/web/scrapers/github.ts b/packages/coding-agent/src/web/scrapers/github.ts index ff15e22d1..1e1926298 100644 --- a/packages/coding-agent/src/web/scrapers/github.ts +++ b/packages/coding-agent/src/web/scrapers/github.ts @@ -468,7 +468,7 @@ async function renderGitHubRepo( md += `${prefix}${item.path}\n`; } if (tree.length > 100) { - md += `... and ${tree.length - 100} more files\n`; + md += `[…${tree.length - 100} files elided…]\n`; } md += "```\n\n"; } diff --git a/packages/coding-agent/src/web/scrapers/go-pkg.ts b/packages/coding-agent/src/web/scrapers/go-pkg.ts index feb9145ce..115fec145 100644 --- a/packages/coding-agent/src/web/scrapers/go-pkg.ts +++ b/packages/coding-agent/src/web/scrapers/go-pkg.ts @@ -211,7 +211,7 @@ export const handleGoPkg: SpecialHandler = async ( sections.push(exported.slice(0, 50).join("\n")); if (exported.length > 50) { notes.push(`showing 50 of ${exported.length} exports`); - sections.push(`\n... and ${exported.length - 50} more`); + sections.push(`\n[…${exported.length - 50} exports elided…]`); } sections.push(""); } @@ -240,7 +240,7 @@ export const handleGoPkg: SpecialHandler = async ( sections.push(imports.slice(0, 20).join("\n")); if (imports.length > 20) { notes.push(`showing 20 of ${imports.length} imports`); - sections.push(`\n... and ${imports.length - 20} more`); + sections.push(`\n[…${imports.length - 20} imports elided…]`); } sections.push(""); } diff --git a/packages/coding-agent/src/web/scrapers/metacpan.ts b/packages/coding-agent/src/web/scrapers/metacpan.ts index dc67159a1..61415e9dd 100644 --- a/packages/coding-agent/src/web/scrapers/metacpan.ts +++ b/packages/coding-agent/src/web/scrapers/metacpan.ts @@ -162,7 +162,7 @@ function formatModuleMarkdown(module: ModuleResponse, release: ReleaseResponse | md += "\n"; } if (runtimeDeps.length > 20) { - md += `\n*...and ${runtimeDeps.length - 20} more*\n`; + md += `\n[…${runtimeDeps.length - 20} dependencies elided…]\n`; } } } @@ -212,7 +212,7 @@ function formatReleaseMarkdown(release: ReleaseResponse): string { md += "\n"; } if (runtimeDeps.length > 20) { - md += `\n*...and ${runtimeDeps.length - 20} more*\n`; + md += `\n[…${runtimeDeps.length - 20} dependencies elided…]\n`; } } diff --git a/packages/coding-agent/src/web/scrapers/nvd.ts b/packages/coding-agent/src/web/scrapers/nvd.ts index 20392d877..226ddbc3a 100644 --- a/packages/coding-agent/src/web/scrapers/nvd.ts +++ b/packages/coding-agent/src/web/scrapers/nvd.ts @@ -182,7 +182,7 @@ export const handleNvd: SpecialHandler = async ( md += `- \`${cpe}\`\n`; } if (cpes.length > 20) { - md += `\n*...and ${cpes.length - 20} more*\n`; + md += `\n[…${cpes.length - 20} CPEs elided…]\n`; } md += "\n"; } @@ -195,7 +195,7 @@ export const handleNvd: SpecialHandler = async ( md += `- ${ref.url}${tags}\n`; } if (vuln.references.length > 15) { - md += `\n*...and ${vuln.references.length - 15} more references*\n`; + md += `\n[…${vuln.references.length - 15} references elided…]\n`; } } diff --git a/packages/coding-agent/src/web/scrapers/ollama.ts b/packages/coding-agent/src/web/scrapers/ollama.ts index 91ee2613a..35f0573d0 100644 --- a/packages/coding-agent/src/web/scrapers/ollama.ts +++ b/packages/coding-agent/src/web/scrapers/ollama.ts @@ -139,7 +139,7 @@ function formatTagList(tags: string[], maxItems: number): string { const limited = tags.slice(0, maxItems); const formatted = limited.map(tag => `\`${tag}\``).join(", "); if (tags.length > maxItems) { - return `${formatted} (and ${tags.length - maxItems} more)`; + return `${formatted} […${tags.length - maxItems} tags elided…]`; } return formatted; } diff --git a/packages/coding-agent/src/web/scrapers/opencorporates.ts b/packages/coding-agent/src/web/scrapers/opencorporates.ts index 74e2b7db1..3cff50682 100644 --- a/packages/coding-agent/src/web/scrapers/opencorporates.ts +++ b/packages/coding-agent/src/web/scrapers/opencorporates.ts @@ -216,7 +216,7 @@ export const handleOpenCorporates: SpecialHandler = async ( md += "\n"; } if (inactiveOfficers.length > 10) { - md += `\n*...and ${inactiveOfficers.length - 10} more former officers*\n`; + md += `\n[…${inactiveOfficers.length - 10} former officers elided…]\n`; } md += "\n"; } diff --git a/packages/coding-agent/src/web/scrapers/pub-dev.ts b/packages/coding-agent/src/web/scrapers/pub-dev.ts index b7ad5b3a2..a90ae1e9c 100644 --- a/packages/coding-agent/src/web/scrapers/pub-dev.ts +++ b/packages/coding-agent/src/web/scrapers/pub-dev.ts @@ -109,7 +109,7 @@ export const handlePubDev: SpecialHandler = async (url: string, timeout: number, md += "\n"; } if (deps.length > 20) { - md += `\n*...and ${deps.length - 20} more*\n`; + md += `\n[…${deps.length - 20} dependencies elided…]\n`; } md += "\n"; } diff --git a/packages/coding-agent/src/web/scrapers/repology.ts b/packages/coding-agent/src/web/scrapers/repology.ts index aabde8d78..f7f09fbf6 100644 --- a/packages/coding-agent/src/web/scrapers/repology.ts +++ b/packages/coding-agent/src/web/scrapers/repology.ts @@ -239,7 +239,7 @@ export const handleRepology: SpecialHandler = async ( } if (packages.length > 15) { - md += `\n*...and ${packages.length - 15} more repositories*\n`; + md += `\n[…${packages.length - 15} repositories elided…]\n`; } md += `\n---\n\n[View on Repology](${url})\n`; diff --git a/packages/coding-agent/src/web/scrapers/sourcegraph.ts b/packages/coding-agent/src/web/scrapers/sourcegraph.ts index cbf59cdb4..5c13e7888 100644 --- a/packages/coding-agent/src/web/scrapers/sourcegraph.ts +++ b/packages/coding-agent/src/web/scrapers/sourcegraph.ts @@ -294,7 +294,7 @@ async function renderSearch( } if (results.length > maxResults) { - md += `... and ${results.length - maxResults} more results\n`; + md += `[…${results.length - maxResults} results elided…]\n`; } return { content: md, ok: true }; diff --git a/packages/coding-agent/src/web/scrapers/terraform.ts b/packages/coding-agent/src/web/scrapers/terraform.ts index fb7f05204..20d72dd33 100644 --- a/packages/coding-agent/src/web/scrapers/terraform.ts +++ b/packages/coding-agent/src/web/scrapers/terraform.ts @@ -150,7 +150,7 @@ async function handleModuleUrl( md += `| ${input.name} | \`${type}\` | ${required} | ${desc} |\n`; } if (inputs.length > 30) { - md += `\n*... and ${inputs.length - 30} more inputs*\n`; + md += `\n[…${inputs.length - 30} inputs elided…]\n`; } md += "\n"; } @@ -165,7 +165,7 @@ async function handleModuleUrl( md += "\n"; } if (outputs.length > 20) { - md += `\n*... and ${outputs.length - 20} more outputs*\n`; + md += `\n[…${outputs.length - 20} outputs elided…]\n`; } md += "\n"; } @@ -180,7 +180,7 @@ async function handleModuleUrl( md += "\n"; } if (deps.length > 15) { - md += `\n*... and ${deps.length - 15} more dependencies*\n`; + md += `\n[…${deps.length - 15} dependencies elided…]\n`; } md += "\n"; } @@ -193,7 +193,7 @@ async function handleModuleUrl( md += `- \`${res.type}\` (${res.name})\n`; } if (resources.length > 20) { - md += `\n*... and ${resources.length - 20} more resources*\n`; + md += `\n[…${resources.length - 20} resources elided…]\n`; } md += "\n"; } @@ -205,7 +205,7 @@ async function handleModuleUrl( md += `- **${sub.name}**: \`${sub.path}\`\n`; } if (mod.submodules.length > 10) { - md += `\n*... and ${mod.submodules.length - 10} more submodules*\n`; + md += `\n[…${mod.submodules.length - 10} submodules elided…]\n`; } } @@ -267,7 +267,7 @@ async function handleProviderUrl( md += `- [${doc.title}](https://registry.terraform.io/providers/${namespace}/${type}/latest/docs/${doc.category}/${doc.slug})\n`; } if (docs.length > 15) { - md += `\n*... and ${docs.length - 15} more*\n`; + md += `\n[…${docs.length - 15} documents elided…]\n`; } md += "\n"; } diff --git a/packages/coding-agent/src/web/scrapers/wikidata.ts b/packages/coding-agent/src/web/scrapers/wikidata.ts index 141c676ce..43331177d 100644 --- a/packages/coding-agent/src/web/scrapers/wikidata.ts +++ b/packages/coding-agent/src/web/scrapers/wikidata.ts @@ -169,7 +169,7 @@ export const handleWikidata: SpecialHandler = async ( if (values.length > 0) { // Limit values shown per property const displayValues = values.slice(0, 10); - const overflow = values.length > 10 ? ` (+${values.length - 10} more)` : ""; + const overflow = values.length > 10 ? ` […${values.length - 10} values elided…]` : ""; processedProperties.push(`- **${propLabel}:** ${displayValues.join(", ")}${overflow}`); } } @@ -187,7 +187,7 @@ export const handleWikidata: SpecialHandler = async ( const maxProps = 50; md += processedProperties.slice(0, maxProps).join("\n"); if (processedProperties.length > maxProps) { - md += `\n\n*...and ${processedProperties.length - maxProps} more properties*`; + md += `\n\n[…${processedProperties.length - maxProps} properties elided…]`; } md += "\n"; } diff --git a/packages/coding-agent/src/workspace-tree.ts b/packages/coding-agent/src/workspace-tree.ts index b128a44e0..914926400 100644 --- a/packages/coding-agent/src/workspace-tree.ts +++ b/packages/coding-agent/src/workspace-tree.ts @@ -298,7 +298,7 @@ function applyLineCap( const removed = new Set(removable.map(item => item.index)); const kept = lines.filter((_, index) => !removed.has(index)); kept.push({ - label: `… (${removable.length} lines elided beyond depth/cap)`, + label: `[…${removable.length}ln elided…]`, depth: 0, isRoot: false, }); diff --git a/packages/coding-agent/test/acp-agent.test.ts b/packages/coding-agent/test/acp-agent.test.ts index edd6c079e..1f131d878 100644 --- a/packages/coding-agent/test/acp-agent.test.ts +++ b/packages/coding-agent/test/acp-agent.test.ts @@ -29,6 +29,13 @@ import type { PlanModeState } from "@oh-my-pi/pi-coding-agent/plan-mode/state"; import type { AgentSession, AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { SILENT_ABORT_MARKER } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { DEFAULT_STT_MODEL_KEY, STT_MODEL_OPTIONS } from "@oh-my-pi/pi-coding-agent/stt/models"; +import { + DEFAULT_TTS_LOCAL_MODEL_KEY, + DEFAULT_TTS_VOICE, + TTS_LOCAL_MODELS, + TTS_LOCAL_VOICE_OPTIONS, +} from "@oh-my-pi/pi-coding-agent/tts/models"; import { getConfigRootDir, setAgentDir } from "@oh-my-pi/pi-utils"; import type { z } from "zod/v4"; @@ -876,7 +883,50 @@ describe("ACP agent", () => { await Bun.sleep(0); }); - it("accepts only ACP underscore-prefixed extension methods", async () => { + it("lists static speech models for ACP mobile voice settings", async () => { + const harness = await createHarness(); + const voices = TTS_LOCAL_VOICE_OPTIONS.map(({ value, label }) => ({ value, label })); + + const result = await harness.agent.extMethod("speech.models.list", {}); + + expect(result).toEqual({ + settings: { + speechToTextModel: "stt.modelName", + textToSpeechModel: "tts.localModel", + textToSpeechVoice: "tts.localVoice", + speechVoice: "speech.voice", + }, + defaults: { + speechToTextModel: DEFAULT_STT_MODEL_KEY, + textToSpeechModel: DEFAULT_TTS_LOCAL_MODEL_KEY, + voice: DEFAULT_TTS_VOICE, + }, + speechToText: { + setting: "stt.modelName", + defaultValue: DEFAULT_STT_MODEL_KEY, + models: STT_MODEL_OPTIONS.map(({ value, label, description }) => ({ value, label, description })), + }, + textToSpeech: { + modelSetting: "tts.localModel", + voiceSetting: "tts.localVoice", + speechVoiceSetting: "speech.voice", + defaultModel: DEFAULT_TTS_LOCAL_MODEL_KEY, + defaultVoice: DEFAULT_TTS_VOICE, + models: TTS_LOCAL_MODELS.map(({ key, label, description, voices: modelVoices }) => ({ + value: key, + label, + description, + voices: modelVoices.map(({ id, label: voiceLabel }) => ({ value: id, label: voiceLabel })), + })), + voices, + }, + }); + + harness.abortController.abort(); + await Bun.sleep(0); + }); + + it("accepts OMP extension methods and rejects unknown unprefixed methods", async () => { const harness = await createHarness(); const result = await harness.agent.extMethod("_omp/sessions/listAll", { limit: 2 }); diff --git a/packages/coding-agent/test/acp-lazy-startup.test.ts b/packages/coding-agent/test/acp-lazy-startup.test.ts index 46aa4b9a3..9ad225bec 100644 --- a/packages/coding-agent/test/acp-lazy-startup.test.ts +++ b/packages/coding-agent/test/acp-lazy-startup.test.ts @@ -248,6 +248,8 @@ describe("ACP lazy startup", () => { type ObservedAdvisorSettings = { enabled: boolean; subagents: boolean; + syncBacklog: "off" | "1" | "3" | "5"; + immuneTurns: number; }; const runProtocolStartup = async (mode: "rpc" | "rpc-ui" | "acp"): Promise => { @@ -257,6 +259,8 @@ describe("ACP lazy startup", () => { const settings = Settings.isolated({ "advisor.enabled": true, "advisor.subagents": true, + "advisor.syncBacklog": "5", + "advisor.immuneTurns": 3, }); let observed: ObservedAdvisorSettings | undefined; const stopMessage = "stop test protocol mode"; @@ -284,6 +288,8 @@ describe("ACP lazy startup", () => { observed = { enabled: settings.get("advisor.enabled"), subagents: settings.get("advisor.subagents"), + syncBacklog: settings.get("advisor.syncBacklog"), + immuneTurns: settings.get("advisor.immuneTurns"), }; throw new Error(stopMessage); }, @@ -291,6 +297,8 @@ describe("ACP lazy startup", () => { observed = { enabled: settings.get("advisor.enabled"), subagents: settings.get("advisor.subagents"), + syncBacklog: settings.get("advisor.syncBacklog"), + immuneTurns: settings.get("advisor.immuneTurns"), }; throw new Error(stopMessage); }, @@ -314,6 +322,8 @@ describe("ACP lazy startup", () => { await expect(runProtocolStartup(mode)).resolves.toEqual({ enabled: false, subagents: false, + syncBacklog: "off", + immuneTurns: 1, }); } }); diff --git a/packages/coding-agent/test/advisor/advisor-visibility.test.ts b/packages/coding-agent/test/advisor/advisor-visibility.test.ts new file mode 100644 index 000000000..18cb07b4a --- /dev/null +++ b/packages/coding-agent/test/advisor/advisor-visibility.test.ts @@ -0,0 +1,58 @@ +/** + * Contracts: an advisor-kind registry ref is observability-only — present for the + * Agent Hub, hidden from every agent-facing surface, and never messageable. + * + * - `AgentRegistry.listVisibleTo` (irc roster / broadcast targets) excludes advisors. + * - `IrcBus.send` to an advisor ref fails as non-messageable, without reviving it. + */ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { IrcBus } from "@oh-my-pi/pi-coding-agent/irc/bus"; +import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; + +describe("advisor registry visibility", () => { + beforeEach(() => { + AgentRegistry.resetGlobalForTests(); + IrcBus.resetGlobalForTests(); + }); + + afterEach(() => { + IrcBus.resetGlobalForTests(); + AgentRegistry.resetGlobalForTests(); + }); + + it("excludes advisor refs from listVisibleTo", () => { + const registry = AgentRegistry.global(); + registry.register({ id: "Main", displayName: "Main", kind: "main", session: null, status: "running" }); + registry.register({ id: "Worker", displayName: "Worker", kind: "sub", session: null, status: "idle" }); + registry.register({ + id: "Main/advisor", + displayName: "advisor", + kind: "advisor", + session: null, + status: "idle", + }); + + const visible = registry.listVisibleTo("Main").map(ref => ref.id); + expect(visible).toContain("Worker"); + expect(visible).not.toContain("Main/advisor"); + }); + + it("refuses to message an advisor ref", async () => { + const registry = AgentRegistry.global(); + registry.register({ + id: "Main/advisor", + displayName: "advisor", + kind: "advisor", + session: null, + sessionFile: "/tmp/x/__advisor.jsonl", + status: "parked", + }); + const bus = new IrcBus(registry); + + const receipt = await bus.send({ from: "Main", to: "Main/advisor", body: "hi" }); + expect(receipt.outcome).toBe("failed"); + expect(receipt.error).toContain("advisor"); + // It must still be parked — a refused send never revives an advisor transcript. + expect(registry.get("Main/advisor")?.status).toBe("parked"); + }); +}); diff --git a/packages/coding-agent/test/advisor/transcript-recorder.test.ts b/packages/coding-agent/test/advisor/transcript-recorder.test.ts new file mode 100644 index 000000000..23849266b --- /dev/null +++ b/packages/coding-agent/test/advisor/transcript-recorder.test.ts @@ -0,0 +1,163 @@ +/** + * Contracts: AdvisorTranscriptRecorder persists the advisor agent's turns to a + * subagent-style JSONL (`/__advisor.jsonl`) so the advisor model's usage + * is attributed in stats and its transcript shows in the Agent Hub. + * + * - Assistant turns land as `{type:"message", message:{role:"assistant", usage}}` + * entries — exactly the shape the stats parser reads for usage. + * - User deltas are persisted but flagged `synthetic`/agent-attributed so stats' + * user-message metrics skip them. + * - Non-conversational message kinds are not persisted. + * - The target follows the session file: a switch routes later turns to the new + * session's `__advisor.jsonl`, leaving the prior file intact. + */ +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 type { AgentMessage } from "@oh-my-pi/pi-agent-core"; +import { + ADVISOR_TRANSCRIPT_FILENAME, + AdvisorTranscriptRecorder, +} from "@oh-my-pi/pi-coding-agent/advisor/transcript-recorder"; + +interface AdvisorEntry { + type?: string; + id?: unknown; + message?: { + role?: string; + model?: string; + usage?: { input?: number }; + synthetic?: boolean; + attribution?: string; + }; +} + +async function withTempDir(fn: (dir: string) => Promise): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "advisor-recorder-")); + try { + return await fn(dir); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +} + +/** Parse the message entries (skipping the session header) from an advisor JSONL. */ +async function readMessageEntries(file: string): Promise { + const text = await Bun.file(file).text(); + // JSON.parse returns `any`; assigning to the typed array narrows reads below. + const entries: AdvisorEntry[] = text + .trim() + .split("\n") + .map(line => JSON.parse(line)); + return entries.filter(entry => entry.type === "message"); +} + +function assistantMessage(text: string, inputTokens: number): AgentMessage { + const message = { + role: "assistant" as const, + content: [{ type: "text" as const, text }], + api: "anthropic-messages", + provider: "anthropic", + model: "test-advisor-model", + usage: { + input: inputTokens, + output: 3, + cacheRead: 0, + cacheWrite: 0, + totalTokens: inputTokens + 3, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop" as const, + timestamp: 1, + }; + return message as unknown as AgentMessage; +} + +function userMessage(text: string): AgentMessage { + const message = { role: "user" as const, content: [{ type: "text" as const, text }], timestamp: 1 }; + return message as unknown as AgentMessage; +} + +function developerMessage(text: string): AgentMessage { + const message = { role: "developer" as const, content: [{ type: "text" as const, text }], timestamp: 1 }; + return message as unknown as AgentMessage; +} + +describe("AdvisorTranscriptRecorder", () => { + it("persists assistant turns with usage to /__advisor.jsonl", async () => { + await withTempDir(async dir => { + const sessionFile = path.join(dir, "sess.jsonl"); + const recorder = new AdvisorTranscriptRecorder( + () => sessionFile, + () => dir, + ); + recorder.record(assistantMessage("reviewing", 42)); + await recorder.close(); + + const messages = await readMessageEntries(path.join(dir, "sess", ADVISOR_TRANSCRIPT_FILENAME)); + expect(messages).toHaveLength(1); + expect(messages[0].message?.role).toBe("assistant"); + expect(messages[0].message?.model).toBe("test-advisor-model"); + expect(messages[0].message?.usage?.input).toBe(42); + // Stats keys on a non-empty entry id; SessionManager must assign one. + expect(typeof messages[0].id).toBe("string"); + expect(String(messages[0].id).length).toBeGreaterThan(0); + }); + }); + + it("marks advisor user deltas synthetic and agent-attributed", async () => { + await withTempDir(async dir => { + const sessionFile = path.join(dir, "sess.jsonl"); + const recorder = new AdvisorTranscriptRecorder( + () => sessionFile, + () => dir, + ); + recorder.record(userMessage("### Session update")); + await recorder.close(); + + const messages = await readMessageEntries(path.join(dir, "sess", ADVISOR_TRANSCRIPT_FILENAME)); + expect(messages).toHaveLength(1); + expect(messages[0].message?.role).toBe("user"); + expect(messages[0].message?.synthetic).toBe(true); + expect(messages[0].message?.attribution).toBe("agent"); + }); + }); + + it("skips non-conversational message kinds", async () => { + await withTempDir(async dir => { + const sessionFile = path.join(dir, "sess.jsonl"); + const recorder = new AdvisorTranscriptRecorder( + () => sessionFile, + () => dir, + ); + recorder.record(developerMessage("noise")); + recorder.record(assistantMessage("kept", 1)); + await recorder.close(); + + const messages = await readMessageEntries(path.join(dir, "sess", ADVISOR_TRANSCRIPT_FILENAME)); + expect(messages.map(m => m.message?.role)).toEqual(["assistant"]); + }); + }); + + it("routes later turns to the new session file after a switch", async () => { + await withTempDir(async dir => { + let sessionFile = path.join(dir, "first.jsonl"); + const recorder = new AdvisorTranscriptRecorder( + () => sessionFile, + () => dir, + ); + recorder.record(assistantMessage("before switch", 1)); + sessionFile = path.join(dir, "second.jsonl"); + recorder.record(assistantMessage("after switch", 2)); + await recorder.close(); + + const first = await readMessageEntries(path.join(dir, "first", ADVISOR_TRANSCRIPT_FILENAME)); + const second = await readMessageEntries(path.join(dir, "second", ADVISOR_TRANSCRIPT_FILENAME)); + expect(first).toHaveLength(1); + expect(first[0].message?.usage?.input).toBe(1); + expect(second).toHaveLength(1); + expect(second[0].message?.usage?.input).toBe(2); + }); + }); +}); diff --git a/packages/coding-agent/test/agent-hub-advisor-scroll.test.ts b/packages/coding-agent/test/agent-hub-advisor-scroll.test.ts new file mode 100644 index 000000000..5a52a4d38 --- /dev/null +++ b/packages/coding-agent/test/agent-hub-advisor-scroll.test.ts @@ -0,0 +1,171 @@ +/** + * Regression: the fullscreen transcript viewer must align the header, body, and + * footer on a single shared gutter. The transcript components carry their own + * 1-column left pad, so the viewer must NOT add a second outer gutter to body + * rows — doing so shifted the content one column right of the "Agent Hub" title + * (the reported "first char off / title shift"). Scrolling must also move the + * visible window. + */ +import { beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { AgentTranscriptViewer } from "@oh-my-pi/pi-coding-agent/modes/components/agent-transcript-viewer"; +import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; +import { CURRENT_SESSION_VERSION } from "@oh-my-pi/pi-coding-agent/session/session-entries"; + +const TS = new Date().toISOString(); + +function buildJsonl(): string { + const usage = { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; + const lines = [ + JSON.stringify({ type: "session", version: CURRENT_SESSION_VERSION, id: "adv", timestamp: TS, cwd: "/tmp" }), + ]; + lines.push( + JSON.stringify({ + type: "message", + id: "u0", + parentId: null, + timestamp: TS, + message: { role: "user", synthetic: true, attribution: "agent", content: "PROMPTMARKER", timestamp: 0 }, + }), + ); + for (let i = 0; i < 40; i++) { + lines.push( + JSON.stringify({ + type: "message", + id: `a${i}`, + parentId: null, + timestamp: TS, + message: { + role: "assistant", + content: [{ type: "text", text: `Reviewing step ${i}.` }], + api: "anthropic-messages", + provider: "anthropic", + model: "gpt-5.5", + usage, + stopReason: "stop", + timestamp: i, + }, + }), + ); + } + return `${lines.join("\n")}\n`; +} + +function makeViewer(file: string) { + const agents = new AgentRegistry(); + agents.register({ + id: "Main/advisor", + displayName: "advisor", + kind: "advisor", + parentId: "Main", + session: null, + sessionFile: file, + status: "parked", + }); + return new AgentTranscriptViewer({ + agentId: "Main/advisor", + registry: agents, + ui: { requestRender: () => {}, requestComponentRender: () => {} } as never, + cwd: "/tmp", + expandKeys: ["ctrl+o"], + hubKeys: ["ctrl+s"], + requestRender: () => {}, + onClose: () => {}, + onHubClose: () => {}, + }); +} + +/** Leading-space count of a stripped line (its content gutter). */ +function gutter(line: string): number { + const stripped = Bun.stripANSI(line); + return stripped.length - stripped.trimStart().length; +} + +function withViewer(fn: (viewer: AgentTranscriptViewer) => void): void { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "adv-view-")); + const file = path.join(dir, "__advisor.jsonl"); + fs.writeFileSync(file, buildJsonl()); + try { + fn(makeViewer(file)); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +describe("AgentTranscriptViewer", () => { + beforeEach(async () => { + resetSettingsForTest(); + await Settings.init({ inMemory: true }); + initTheme(); + }); + + it("aligns the title and body content on the same gutter", () => { + withViewer(viewer => { + viewer.render(80); // populate the scroll view before navigating + viewer.handleInput("g"); // scroll to top so the first message is visible + const lines = viewer.render(80).map(l => Bun.stripANSI(l)); + const titleLine = lines.find(l => l.includes("Agent Hub")); + const bodyLine = lines.find(l => l.includes("PROMPTMARKER")); + expect(titleLine).toBeDefined(); + expect(bodyLine).toBeDefined(); + // The body must not sit one column right of the title. + expect(gutter(bodyLine!)).toBe(gutter(titleLine!)); + }); + }); + + it("scrolls the visible window with j/k and g/G", () => { + withViewer(viewer => { + const atBottom = viewer + .render(80) + .map(l => Bun.stripANSI(l)) + .join("\n"); + viewer.handleInput("g"); + const atTop = viewer + .render(80) + .map(l => Bun.stripANSI(l)) + .join("\n"); + expect(atTop).not.toEqual(atBottom); + expect(atTop).toContain("PROMPTMARKER"); + expect(atBottom).not.toContain("PROMPTMARKER"); + }); + }); + + it("clears stale content when the transcript file is deleted while open", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "adv-view-")); + const file = path.join(dir, "__advisor.jsonl"); + fs.writeFileSync(file, buildJsonl()); + const viewer = makeViewer(file); + const body = () => + viewer + .render(80) + .map(l => Bun.stripANSI(l)) + .join("\n"); + try { + viewer.render(80); + viewer.handleInput("g"); + expect(body()).toContain("PROMPTMARKER"); + + fs.rmSync(file); + // Poll until the viewer's own poll timer re-stats and clears (deadline-bounded). + const deadline = Date.now() + 5000; + while (body().includes("PROMPTMARKER") && Date.now() < deadline) { + await Bun.sleep(50); + } + expect(body()).not.toContain("PROMPTMARKER"); + } finally { + viewer.dispose(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts b/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts index 25c425ea6..743bc9c7c 100644 --- a/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts +++ b/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts @@ -206,6 +206,54 @@ describe("AgentSession auto-compaction queue resume", () => { expect(runtimeSignals.some(signal => signal.startsWith("compaction:end:"))).toBe(true); }); + it("has isCompacting true when the auto_compaction_start event fires", async () => { + // Defect 1: the compaction AbortController (which backs isCompacting) must be + // installed before auto_compaction_start is emitted. If it is installed after, + // a message typed the instant the loader appears is read while + // isCompacting === false and mis-routed into the core steering queue (which a + // later handoff reset would wipe) instead of the safe UI compaction queue. + let capturedIsCompacting: boolean | undefined; + const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers(); + session.subscribe(event => { + if (event.type === "auto_compaction_start") { + capturedIsCompacting = session.isCompacting; + } else if (event.type === "auto_compaction_end") { + onCompactionDone(); + } + }); + + // Defensive: mirror the resume-drain stub so any queued continuation settles + // instead of spinning the drain (see the threshold test above). + vi.spyOn(session.agent, "continue").mockImplementation(async () => { + session.agent.clearAllQueues(); + }); + + const assistantMsg = { + role: "assistant" as const, + content: [{ type: "text" as const, text: "Done." }], + api: "anthropic-messages" as const, + provider: "anthropic" as const, + model: "claude-sonnet-4-5", + stopReason: "stop" as const, + usage: { + input: 190000, + output: 1000, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 191000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + timestamp: Date.now(), + }; + + session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg }); + session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] }); + + await compactionDone; + + expect(capturedIsCompacting).toBe(true); + }); + it("forwards todo reminder lifecycle signals to extensions", async () => { const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue(); diff --git a/packages/coding-agent/test/agent-session-handoff.test.ts b/packages/coding-agent/test/agent-session-handoff.test.ts index 76c947997..4f847888a 100644 --- a/packages/coding-agent/test/agent-session-handoff.test.ts +++ b/packages/coding-agent/test/agent-session-handoff.test.ts @@ -153,6 +153,101 @@ describe("AgentSession handoff", () => { expect(sessionManager.getEntries().filter(entry => entry.type === "compaction")).toHaveLength(0); }); + it("preserves queued steering and follow-up messages across the handoff reset", async () => { + // Defect 2: handoff() calls agent.reset(), which clears the core steering/follow-up + // queues. Steers/follow-ups already queued (the mis-routed first compaction message, + // or RPC/SDK steer()/followUp() issued during the handoff) must survive into the new + // session instead of being silently dropped. + vi.spyOn(compactionModule, "generateHandoff").mockResolvedValue("## Goal\nContinue"); + + const textOf = (message: AgentMessage): string => { + if (!("content" in message)) return ""; + const content = message.content; + if (typeof content === "string") return content; + const textBlock = content.find(block => block.type === "text"); + return textBlock?.type === "text" ? textBlock.text : ""; + }; + + const userMsg: AgentMessage = { + role: "user", + content: [{ type: "text", text: "keep-steer" }], + attribution: "user", + timestamp: Date.now(), + }; + // A hidden, user-attributed companion (e.g. an ultrathink notice). It is + // display:false, so isUserQueuedMessage(...) is false for it: preservation must + // keep it adjacent to its prompt rather than filter it out or reorder it. + const companionMsg: AgentMessage = { + role: "custom", + customType: "ultrathink-notice", + content: [{ type: "text", text: "companion" }], + attribution: "user", + display: false, + timestamp: Date.now(), + }; + const followUpMsg: AgentMessage = { + role: "user", + content: [{ type: "text", text: "keep-followup" }], + attribution: "user", + timestamp: Date.now(), + }; + session.agent.steer(userMsg); + session.agent.steer(companionMsg); + session.agent.followUp(followUpMsg); + expect(session.agent.hasQueuedMessages()).toBe(true); + + await session.handoff(); + + expect(session.agent.peekSteeringQueue().map(textOf)).toEqual(["keep-steer", "companion"]); + expect(session.agent.peekFollowUpQueue().map(textOf)).toEqual(["keep-followup"]); + }); + + it("preserves steering and follow-up messages enqueued while the handoff is in flight", async () => { + // Defect 2 in-flight window: the queue snapshot must be captured immediately before + // agent.reset() (after generateHandoff resolves), NOT at handoff entry. A steer or + // follow-up issued WHILE the handoff document is still generating must survive the + // reset — proving capture happens late rather than at the start of handoff(). + const { promise: handoffDoc, resolve: releaseHandoff } = Promise.withResolvers(); + let generateHandoffCalled = false; + vi.spyOn(compactionModule, "generateHandoff").mockImplementation(async () => { + generateHandoffCalled = true; + return handoffDoc; + }); + + const textOf = (message: AgentMessage): string => { + if (!("content" in message)) return ""; + const content = message.content; + if (typeof content === "string") return content; + const textBlock = content.find(block => block.type === "text"); + return textBlock?.type === "text" ? textBlock.text : ""; + }; + + const handoffPromise = session.handoff(); + // Block until we are genuinely mid-handoff (document generation in flight). + await waitFor(() => generateHandoffCalled); + + // Enqueue AFTER generation started but BEFORE it resolves — the window where the old + // session is still live and agent.reset() has not yet fired. + session.agent.steer({ + role: "user", + content: [{ type: "text", text: "inflight-steer" }], + attribution: "user", + timestamp: Date.now(), + }); + session.agent.followUp({ + role: "user", + content: [{ type: "text", text: "inflight-followup" }], + attribution: "user", + timestamp: Date.now(), + }); + + releaseHandoff("## Goal\nContinue"); + await handoffPromise; + + expect(session.agent.peekSteeringQueue().map(textOf)).toEqual(["inflight-steer"]); + expect(session.agent.peekFollowUpQueue().map(textOf)).toEqual(["inflight-followup"]); + }); + it("obfuscates custom instructions before generating a handoff", async () => { const placeholder = obfuscator.obfuscate(HANDOFF_SECRET); const generateHandoffSpy = vi diff --git a/packages/coding-agent/test/agent-session-message-pipeline.test.ts b/packages/coding-agent/test/agent-session-message-pipeline.test.ts index 638c7ba3c..19349650e 100644 --- a/packages/coding-agent/test/agent-session-message-pipeline.test.ts +++ b/packages/coding-agent/test/agent-session-message-pipeline.test.ts @@ -4,6 +4,7 @@ import { type Api, type Context, clearCustomApis, + type ImageContent, type Message, type Model, type ModelSpec, @@ -122,6 +123,34 @@ describe("AgentSession message pipeline", () => { session.clearQueue(); }); + it("resolves image attachments from submitted messages, not tool-result images", () => { + const userImage: ImageContent = { type: "image", data: "user-image", mimeType: "image/png" }; + const toolImage: ImageContent = { type: "image", data: "tool-image", mimeType: "image/png" }; + const session = new AgentSession({ + agent: createAgent(), + sessionManager: SessionManager.inMemory(), + settings: Settings.isolated({ "compaction.enabled": false }), + modelRegistry: {} as never, + }); + sessions.push(session); + + session.agent.appendMessage({ + role: "user", + content: [{ type: "text", text: "inspect this" }, userImage], + timestamp: Date.now(), + }); + session.agent.appendMessage({ + role: "toolResult", + toolCallId: "eval-1", + toolName: "eval", + content: [{ type: "text", text: "plot output" }, toolImage], + timestamp: Date.now(), + isError: false, + }); + + expect(session.getImageAttachments()).toEqual([{ label: "Image #1", uri: "attachment://1", image: userImage }]); + }); + it("keeps stored steering text raw while pre-LLM conversion wraps it", async () => { const session = new AgentSession({ agent: createAgent(), diff --git a/packages/coding-agent/test/agent-session-resolve-reminder.test.ts b/packages/coding-agent/test/agent-session-resolve-reminder.test.ts index a41c658b5..8f96a86fe 100644 --- a/packages/coding-agent/test/agent-session-resolve-reminder.test.ts +++ b/packages/coding-agent/test/agent-session-resolve-reminder.test.ts @@ -1,8 +1,8 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { Agent } from "@oh-my-pi/pi-agent-core"; +import { Agent, isSoftToolRequirement } from "@oh-my-pi/pi-agent-core"; import { createMockModel, type MockModel } from "@oh-my-pi/pi-ai/providers/mock"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; @@ -17,6 +17,7 @@ import { Snowflake } from "@oh-my-pi/pi-utils"; describe("AgentSession resolve reminder", () => { let session: AgentSession; + let toolSession: ToolSession; let tempDir: string; let mock: MockModel; let authStorage: AuthStorage | undefined; @@ -34,43 +35,17 @@ describe("AgentSession resolve reminder", () => { authStorage.setRuntimeApiKey("anthropic", "test-key"); const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml")); - mock = createMockModel({ - handler: () => { - if (mock.calls.length === 1) { - queueResolveHandler( - { - getToolChoiceQueue: () => session.toolChoiceQueue, - buildToolChoice: (name: string) => buildNamedToolChoice(name, session.model!), - steer: (msg: { customType: string; content: string; details?: unknown }) => - session.agent.steer({ - role: "custom", - customType: msg.customType, - content: msg.content, - display: false, - details: msg.details, - attribution: "agent", - timestamp: Date.now(), - }), - } as unknown as ToolSession, - { - label: "AST Edit: 1 replacement in 1 file", - sourceToolName: "ast_edit", - apply: async () => ({ content: [{ type: "text", text: "Applied" }] }), - }, - ); - } - return { content: ["Done"] }; - }, - }); + mock = createMockModel({ handler: () => ({ content: ["Done"] }) }); + + toolSession = { + getToolChoiceQueue: () => session.toolChoiceQueue, + buildToolChoice: (name: string) => buildNamedToolChoice(name, session.model!), + } as unknown as ToolSession; const agent = new Agent({ - initialState: { - model, - systemPrompt: ["Test"], - tools: [], - messages: [], - }, + initialState: { model, systemPrompt: ["Test"], tools: [], messages: [] }, streamFn: mock.stream, + getToolChoice: () => session.nextToolChoiceDirective(), }); session = new AgentSession({ @@ -88,24 +63,31 @@ describe("AgentSession resolve reminder", () => { if (fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true, force: true }); } - vi.restoreAllMocks(); }); - it("forces an immediate steering turn and injects resolve reminder before second assistant response", async () => { - await session.prompt("run preview"); + it("delivers the resolve reminder via a non-forcing soft requirement, not a steer or a forced tool_choice", () => { + queueResolveHandler(toolSession, { + label: "AST Edit: 1 replacement in 1 file", + sourceToolName: "ast_edit", + apply: async () => ({ content: [{ type: "text", text: "Applied" }] }), + }); - expect(mock.calls).toHaveLength(2); + // Forcing was removed — staging a preview never queues a hard tool_choice. + expect(session.nextToolChoice()).toBeUndefined(); - const messages = session.agent.state.messages; - const assistantIndices = messages - .map((message, index) => (message.role === "assistant" ? index : -1)) - .filter(index => index >= 0); - const reminderIndex = messages.findIndex( - message => message.role === "custom" && message.customType === "resolve-reminder", - ); + // The reminder now rides an agent-level soft requirement (delivered once by + // the agent loop) instead of a host-side steer that churned the prefix. + const directive = session.nextToolChoiceDirective(); + expect(isSoftToolRequirement(directive)).toBe(true); + if (!isSoftToolRequirement(directive)) throw new Error("expected soft requirement"); + expect(directive.toolName).toBe("resolve"); + const reminder = directive.reminder[0]; + expect(reminder?.role).toBe("custom"); + if (reminder?.role === "custom") { + expect(reminder.customType).toBe("resolve-reminder"); + } - expect(assistantIndices.length).toBe(2); - expect(reminderIndex).toBeGreaterThan(assistantIndices[0]!); - expect(reminderIndex).toBeLessThan(assistantIndices[1]!); + // Nothing was steered into the conversation — no prefix churn. + expect(session.agent.peekSteeringQueue()).toHaveLength(0); }); }); diff --git a/packages/coding-agent/test/agent-session-retry-fallback.test.ts b/packages/coding-agent/test/agent-session-retry-fallback.test.ts index f66495b75..5bc86fe97 100644 --- a/packages/coding-agent/test/agent-session-retry-fallback.test.ts +++ b/packages/coding-agent/test/agent-session-retry-fallback.test.ts @@ -1451,6 +1451,10 @@ describe("AgentSession retry fallback", () => { expect(modelRegistry.isSelectorSuppressed("openai/gpt-4o")).toBe(true); expect(modelRegistry.isSelectorSuppressed("openai/gpt-4o:low")).toBe(true); + modelRegistry.suppressSelector("openai/gpt-4o:max", future); + expect(modelRegistry.isSelectorSuppressed("openai/gpt-4o:xhigh")).toBe(true); + expect(modelRegistry.isSelectorSuppressed("openai/gpt-4o:max")).toBe(true); + await modelRegistry.refresh("offline"); expect(modelRegistry.isSelectorSuppressed("openai/gpt-4o")).toBe(false); }); diff --git a/packages/coding-agent/test/auto-thinking-classifier.test.ts b/packages/coding-agent/test/auto-thinking-classifier.test.ts index 687e3e9ad..1e8141eba 100644 --- a/packages/coding-agent/test/auto-thinking-classifier.test.ts +++ b/packages/coding-agent/test/auto-thinking-classifier.test.ts @@ -15,6 +15,7 @@ import { AUTO_THINKING, clampAutoThinkingEffort, parseConfiguredThinkingLevel, + parseEffort, parseThinkingLevel, } from "@oh-my-pi/pi-coding-agent/thinking"; import type { TinyMemoryLocalModelKey } from "@oh-my-pi/pi-coding-agent/tiny/models"; @@ -130,4 +131,18 @@ describe("auto thinking classifier helpers", () => { expect(clampAutoThinkingEffort(model, Effort.XHigh)).toBe(Effort.High); expect(clampAutoThinkingEffort(model, Effort.Minimal)).toBe(Effort.Low); }); + + it("accepts max as the top configured thinking alias", () => { + expect(parseEffort("max")).toBe(Effort.XHigh); + expect(parseThinkingLevel("max")).toBeUndefined(); + expect(parseConfiguredThinkingLevel("max")).toBe(ThinkingLevel.XHigh); + }); + + it("rejects inherited object keys as thinking selectors", () => { + for (const selector of ["toString", "constructor", "__proto__"]) { + expect(parseEffort(selector)).toBeUndefined(); + expect(parseThinkingLevel(selector)).toBeUndefined(); + expect(parseConfiguredThinkingLevel(selector)).toBeUndefined(); + } + }); }); diff --git a/packages/coding-agent/test/cache-invalidation-marker.test.ts b/packages/coding-agent/test/cache-invalidation-marker.test.ts new file mode 100644 index 000000000..321484084 --- /dev/null +++ b/packages/coding-agent/test/cache-invalidation-marker.test.ts @@ -0,0 +1,91 @@ +import { beforeAll, describe, expect, it } from "bun:test"; +import type { Usage } from "@oh-my-pi/pi-ai/types"; +import { + CacheInvalidationMarkerComponent, + detectCacheInvalidation, +} from "@oh-my-pi/pi-coding-agent/modes/components/cache-invalidation-marker"; +import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; + +function usage(parts: { input?: number; cacheRead?: number; cacheWrite?: number; output?: number }): Usage { + const input = parts.input ?? 0; + const output = parts.output ?? 0; + const cacheRead = parts.cacheRead ?? 0; + const cacheWrite = parts.cacheWrite ?? 0; + return { + input, + output, + cacheRead, + cacheWrite, + totalTokens: input + output + cacheRead + cacheWrite, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; +} + +describe("detectCacheInvalidation", () => { + it("does not flag the first turn (no prior cache footprint)", () => { + expect(detectCacheInvalidation(undefined, usage({ cacheWrite: 50_000, input: 2 }))).toBeUndefined(); + }); + + it("flags a cacheRead collapse after a warm turn and reports reprocessed tokens", () => { + // Mirrors the observed session: warm turn reads ~50k, next request reads + // nothing and re-creates the whole prefix. + const prev = usage({ cacheRead: 49_837, cacheWrite: 980, output: 79 }); + const current = usage({ cacheRead: 0, cacheWrite: 50_900, input: 99, output: 99 }); + expect(detectCacheInvalidation(prev, current)).toEqual({ reprocessedTokens: 50_999 }); + }); + + it("flags a second consecutive cold turn using the prior turn's cacheWrite as footprint", () => { + // The prior turn re-cached (cacheWrite) but read nothing; the next turn + // still reads nothing — a genuine repeat invalidation. + const prev = usage({ cacheRead: 0, cacheWrite: 50_900, input: 99 }); + const current = usage({ cacheRead: 0, cacheWrite: 51_113, input: 16 }); + expect(detectCacheInvalidation(prev, current)).toEqual({ reprocessedTokens: 51_129 }); + }); + + it("does not flag a turn that reused any cache", () => { + const prev = usage({ cacheRead: 0, cacheWrite: 51_113, input: 16 }); + const current = usage({ cacheRead: 50_900, cacheWrite: 3_459, input: 2 }); + expect(detectCacheInvalidation(prev, current)).toBeUndefined(); + }); + + it("does not flag implicit best-effort caches that report no cacheWrite", () => { + // Gemini/antigravity and Fireworks/glm report `cacheWrite: 0` and drop + // `cacheRead` to zero intermittently while the prefix is unchanged — a + // provider propagation race that self-heals next turn, not an invalidation. + // Mirrors the observed gemini-3.5-flash turn (warm 40.8k read, then a cold + // 43.1k reprocess with zero cacheWrite). + const prev = usage({ cacheRead: 40_789, input: 1_069, output: 353 }); + const current = usage({ cacheRead: 0, cacheWrite: 0, input: 43_102, output: 58 }); + expect(detectCacheInvalidation(prev, current)).toBeUndefined(); + }); + + it("ignores collapses when the prior footprint was below the cacheable floor", () => { + // No meaningful cache existed to invalidate (e.g. provider without prompt + // caching, or a tiny early context). + const prev = usage({ input: 500 }); + expect(detectCacheInvalidation(prev, usage({ input: 600 }))).toBeUndefined(); + }); + + it("ignores a cold turn that reprocessed only a trivial prompt", () => { + const prev = usage({ cacheRead: 40_000, cacheWrite: 1_000 }); + expect(detectCacheInvalidation(prev, usage({ cacheRead: 0, input: 12 }))).toBeUndefined(); + }); +}); + +describe("CacheInvalidationMarkerComponent", () => { + beforeAll(async () => { + // render() reads the global theme singleton (icons, rule glyph, colors). + await initTheme(); + }); + + it("renders a slim, left-aligned, partial-width divider padded by blank lines", () => { + const lines = new CacheInvalidationMarkerComponent({ reprocessedTokens: 50_999 }).render(80); + expect(lines).toHaveLength(3); + expect(lines[0]).toBe(""); + expect(lines[2]).toBe(""); + // The divider spans only a short rule + label — well under the full width. + const dividerWidth = Bun.stringWidth(lines[1]); + expect(dividerWidth).toBeGreaterThan(0); + expect(dividerWidth).toBeLessThan(80); + }); +}); diff --git a/packages/coding-agent/test/cli/ttsr-cli.test.ts b/packages/coding-agent/test/cli/ttsr-cli.test.ts index 5caf2540b..56320a0df 100644 --- a/packages/coding-agent/test/cli/ttsr-cli.test.ts +++ b/packages/coding-agent/test/cli/ttsr-cli.test.ts @@ -1,5 +1,6 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs"; +import * as os from "node:os"; import * as path from "node:path"; import { runTtsrCommand, @@ -11,6 +12,18 @@ import { import { resetSettingsForTest } from "@oh-my-pi/pi-coding-agent/config/settings"; import { getProjectAgentDir, getProjectDir, setProjectDir } from "@oh-my-pi/pi-utils"; +let testTmpDir: string; + +beforeAll(() => { + testTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-ttsr-tests-")); +}); + +afterAll(() => { + if (testTmpDir && fs.existsSync(testTmpDir)) { + fs.rmSync(testTmpDir, { force: true, recursive: true }); + } +}); + // Capture stdout writes so assertions don't leak to the test runner. let stdout = ""; @@ -58,7 +71,7 @@ async function writeTempRule(condition: string, scope: string[], astCondition?: // Stable basename "test-rule.md" so buildRuleFromMarkdown derives name // "test-rule" — assertions rely on it. Each call uses a unique parent dir // to avoid collisions across tests. - const dir = path.join(import.meta.dir, `.tmp-ttsr-${Math.random().toString(36).slice(2)}`); + const dir = path.join(testTmpDir, `rule-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(dir, { recursive: true }); const tmp = path.join(dir, "test-rule.md"); const fm: string[] = [`description: test rule`, `condition: "${condition.replace(/"/g, '\\"')}"`]; @@ -69,7 +82,7 @@ async function writeTempRule(condition: string, scope: string[], astCondition?: } async function writeTempSnippet(content: string, ext: string): Promise { - const dir = path.join(import.meta.dir, `.tmp-ttsr-${Math.random().toString(36).slice(2)}`); + const dir = path.join(testTmpDir, `snippet-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(dir, { recursive: true }); const tmp = path.join(dir, `snippet.${ext}`); await Bun.write(tmp, content); @@ -77,10 +90,9 @@ async function writeTempSnippet(content: string, ext: string): Promise { } function cleanupTmp(): void { - for (const entry of fs.readdirSync(import.meta.dir)) { - if (entry.startsWith(".tmp-ttsr-")) { - fs.rmSync(path.join(import.meta.dir, entry), { force: true, recursive: true }); - } + if (!testTmpDir || !fs.existsSync(testTmpDir)) return; + for (const entry of fs.readdirSync(testTmpDir)) { + fs.rmSync(path.join(testTmpDir, entry), { force: true, recursive: true }); } } @@ -208,7 +220,7 @@ describe("omp ttsr", () => { captureStreams(); // 1. Create a temporary project root directory - const projectDir = path.join(import.meta.dir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); + const projectDir = path.join(testTmpDir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(path.join(projectDir, "src"), { recursive: true }); // 2. Set project root @@ -239,7 +251,7 @@ describe("omp ttsr", () => { it("keeps default scan output summary-only", async () => { captureStreams(); - const projectDir = path.join(import.meta.dir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); + const projectDir = path.join(testTmpDir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(path.join(projectDir, "src"), { recursive: true }); setProjectDir(projectDir); @@ -262,7 +274,7 @@ describe("omp ttsr", () => { it("outputs json format correctly when json flag is set", async () => { captureStreams(); - const projectDir = path.join(import.meta.dir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); + const projectDir = path.join(testTmpDir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(path.join(projectDir, "src"), { recursive: true }); setProjectDir(projectDir); @@ -290,7 +302,7 @@ describe("omp ttsr", () => { it("reports no matches when files do not match rule conditions", async () => { captureStreams(); - const projectDir = path.join(import.meta.dir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); + const projectDir = path.join(testTmpDir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(path.join(projectDir, "src"), { recursive: true }); setProjectDir(projectDir); @@ -313,7 +325,7 @@ describe("omp ttsr", () => { it("respects project gitignore when scanning a subdirectory", async () => { captureStreams(); - const projectDir = path.join(import.meta.dir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); + const projectDir = path.join(testTmpDir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(path.join(projectDir, ".git"), { recursive: true }); fs.mkdirSync(path.join(projectDir, "packages/generated"), { recursive: true }); fs.mkdirSync(path.join(projectDir, "packages/src"), { recursive: true }); @@ -342,7 +354,7 @@ describe("omp ttsr", () => { it("honors ttsr.enabled=false for project scans", async () => { captureStreams(); - const projectDir = path.join(import.meta.dir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); + const projectDir = path.join(testTmpDir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(path.join(projectDir, "src"), { recursive: true }); fs.mkdirSync(getProjectAgentDir(projectDir), { recursive: true }); setProjectDir(projectDir); @@ -362,7 +374,7 @@ describe("omp ttsr", () => { it("includes ignored files when gitignore filtering is disabled", async () => { captureStreams(); - const projectDir = path.join(import.meta.dir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); + const projectDir = path.join(testTmpDir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(path.join(projectDir, ".git"), { recursive: true }); fs.mkdirSync(path.join(projectDir, "packages/generated"), { recursive: true }); setProjectDir(projectDir); @@ -388,7 +400,7 @@ describe("omp ttsr", () => { it("includes hidden source files like GitHub workflows", async () => { captureStreams(); - const projectDir = path.join(import.meta.dir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); + const projectDir = path.join(testTmpDir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(path.join(projectDir, ".github/workflows"), { recursive: true }); setProjectDir(projectDir); @@ -411,7 +423,7 @@ describe("omp ttsr", () => { it("keeps AST-only matches in default summary when type assertion spans whitespace", async () => { captureStreams(); - const projectDir = path.join(import.meta.dir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); + const projectDir = path.join(testTmpDir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(path.join(projectDir, "src"), { recursive: true }); setProjectDir(projectDir); @@ -436,7 +448,7 @@ describe("omp ttsr", () => { it("skips files larger than the scan byte limit", async () => { captureStreams(); - const projectDir = path.join(import.meta.dir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); + const projectDir = path.join(testTmpDir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(path.join(projectDir, "src"), { recursive: true }); setProjectDir(projectDir); @@ -463,7 +475,7 @@ describe("omp ttsr", () => { it("skips binary-looking files before text matching", async () => { captureStreams(); - const projectDir = path.join(import.meta.dir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); + const projectDir = path.join(testTmpDir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(path.join(projectDir, "src"), { recursive: true }); setProjectDir(projectDir); diff --git a/packages/coding-agent/test/collab/crypto.test.ts b/packages/coding-agent/test/collab/crypto.test.ts index 68f5bd181..9854ba566 100644 --- a/packages/coding-agent/test/collab/crypto.test.ts +++ b/packages/coding-agent/test/collab/crypto.test.ts @@ -123,6 +123,47 @@ describe("collab link format", () => { } }); + it("wraps custom relay links in an explicit web UI origin", () => { + const webLink = formatCollabWebLink( + "wss://relay.example.com:8443", + roomId, + key, + undefined, + "https://web.example", + ); + expect(webLink.startsWith("https://web.example/#relay.example.com:8443/r/")).toBe(true); + const parsed = parseCollabLink(webLink); + if ("error" in parsed) throw new Error(parsed.error); + expect(parsed.wsUrl).toBe(`wss://relay.example.com:8443/r/${roomId}`); + }); + + it("parses non-local http web UI wrappers by their relay fragment", () => { + const keyText = Buffer.from(key).toString("base64url"); + const parsed = parseCollabLink(`http://web.example/collab/#relay.example.com:8443/r/${roomId}.${keyText}`); + if ("error" in parsed) throw new Error(parsed.error); + expect(parsed.wsUrl).toBe(`wss://relay.example.com:8443/r/${roomId}`); + }); + + it("parses split web UI wrappers with full relay URLs in the fragment", () => { + const keyText = Buffer.from(key).toString("base64url"); + const parsed = parseCollabLink(`https://web.example/collab/#wss://relay.example.com/r/${roomId}.${keyText}`); + if ("error" in parsed) throw new Error(parsed.error); + expect(parsed.wsUrl).toBe(`wss://relay.example.com/r/${roomId}`); + }); + + it("falls through invalid http wrapper fragments without reparsing them", () => { + expect(parseCollabLink("https://web.example/#not-a-collab-link")).toEqual({ + error: "Collab link must contain a /r/ path", + }); + }); + + it("prefers browser wrapper fragments over relay-like web paths", () => { + const inner = formatCollabLink("wss://relay.example.com", roomId, key); + const parsed = parseCollabLink(`https://web.example/r/abcdefghij#${inner}`); + if ("error" in parsed) throw new Error(parsed.error); + expect(parsed.wsUrl).toBe(`wss://relay.example.com/r/${roomId}`); + }); + it("parses the scheme-less display form of web deep links", () => { const parsed = parseCollabLink(`my.omp.sh/#${formatCollabLink(DEFAULT_RELAY_URL, roomId, key)}`); if ("error" in parsed) throw new Error(parsed.error); @@ -164,6 +205,46 @@ describe("collab link format", () => { expect(url.search).toBe(""); expect(url.hash).toBe(`#${roomId}.${Buffer.from(key).toString("base64url")}`); }); + + it("normalizes explicit web UI roots, paths, trailing slashes, and ports", () => { + const rootLink = formatCollabWebLink(DEFAULT_RELAY_URL, roomId, key, undefined, " https://web.example/ "); + expect(rootLink.startsWith("https://web.example/#")).toBe(true); + + const pathLink = formatCollabWebLink( + DEFAULT_RELAY_URL, + roomId, + key, + undefined, + "https://web.example:8443/collab///", + ); + expect(pathLink.startsWith("https://web.example:8443/collab/#")).toBe(true); + + const localHttpLink = formatCollabWebLink( + DEFAULT_RELAY_URL, + roomId, + key, + undefined, + "http://localhost:5173/app/", + ); + expect(localHttpLink.startsWith("http://localhost:5173/app/#")).toBe(true); + }); + + it("rejects web UI URLs without an http or https protocol", () => { + expect(() => formatCollabWebLink(DEFAULT_RELAY_URL, roomId, key, undefined, "ftp://web.example")).toThrow( + "collab.webUrl must start with http:// or https://", + ); + }); + + it("rejects non-local plain-http web UI URLs", () => { + expect(() => formatCollabWebLink(DEFAULT_RELAY_URL, roomId, key, undefined, "http://web.example")).toThrow( + "collab.webUrl must use https:// unless it targets localhost", + ); + }); + it("rejects web UI URLs with query strings or fragments", () => { + expect(() => formatCollabWebLink(DEFAULT_RELAY_URL, roomId, key, undefined, "https://web.example/?x=1")).toThrow( + "collab.webUrl must not include a query string or fragment", + ); + }); }); describe("collab wire envelope", () => { diff --git a/packages/coding-agent/test/compact-modes.test.ts b/packages/coding-agent/test/compact-modes.test.ts new file mode 100644 index 000000000..ea179d993 --- /dev/null +++ b/packages/coding-agent/test/compact-modes.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "bun:test"; +import { findCompactMode, parseCompactArgs } from "@oh-my-pi/pi-coding-agent/session/compact-modes"; + +describe("compact mode registry", () => { + it("maps each mode to the settings overrides the engine relies on", () => { + // These override values are load-bearing: the engine merges them over the + // configured compaction.* settings, so a regression here silently changes + // what `/compact ` does. + expect(findCompactMode("soft")?.overrides).toEqual({ strategy: "context-full", remoteEnabled: false }); + expect(findCompactMode("remote")?.overrides).toEqual({ strategy: "context-full", remoteEnabled: true }); + expect(findCompactMode("snapcompact")?.overrides).toEqual({ strategy: "snapcompact" }); + }); + + it("flags remote as remote-requiring and snapcompact as focus-rejecting", () => { + expect(findCompactMode("remote")?.requiresRemote).toBe(true); + expect(findCompactMode("snapcompact")?.rejectsFocus).toBe(true); + // soft is a plain local summary: neither flag. + expect(findCompactMode("soft")?.requiresRemote).toBeUndefined(); + expect(findCompactMode("soft")?.rejectsFocus).toBeUndefined(); + }); + + it("resolves mode names case-insensitively and rejects unknowns", () => { + expect(findCompactMode("SOFT")?.name).toBe("soft"); + expect(findCompactMode(" Remote ")?.name).toBe("remote"); + expect(findCompactMode("bogus")).toBeUndefined(); + expect(findCompactMode("")).toBeUndefined(); + }); +}); + +describe("parseCompactArgs", () => { + it("returns no mode and no instructions for empty args", () => { + expect(parseCompactArgs("")).toEqual({}); + expect(parseCompactArgs(" ")).toEqual({}); + }); + + it("detects a leading mode token", () => { + expect(parseCompactArgs("soft")).toEqual({ mode: "soft" }); + expect(parseCompactArgs("remote")).toEqual({ mode: "remote" }); + expect(parseCompactArgs("snapcompact")).toEqual({ mode: "snapcompact" }); + }); + + it("splits a mode from its trailing focus instructions", () => { + expect(parseCompactArgs("soft focus on the parser bug")).toEqual({ + mode: "soft", + instructions: "focus on the parser bug", + }); + expect(parseCompactArgs("remote keep auth details")).toEqual({ + mode: "remote", + instructions: "keep auth details", + }); + }); + + it("treats a non-mode first token as plain focus instructions (backward compatible)", () => { + expect(parseCompactArgs("summarize the auth flow")).toEqual({ instructions: "summarize the auth flow" }); + // A bare word that is not a mode is still focus text, not an error. + expect(parseCompactArgs("everything")).toEqual({ instructions: "everything" }); + }); + + it("rejects focus instructions for modes that produce no summary", () => { + const result = parseCompactArgs("snapcompact keep the diffs"); + expect(result).toHaveProperty("error"); + expect("error" in result && result.error).toContain("snapcompact"); + // Bare snapcompact is fine. + expect(parseCompactArgs("snapcompact")).toEqual({ mode: "snapcompact" }); + }); +}); diff --git a/packages/coding-agent/test/config-cli.test.ts b/packages/coding-agent/test/config-cli.test.ts index bcd7f7b96..4ea13601e 100644 --- a/packages/coding-agent/test/config-cli.test.ts +++ b/packages/coding-agent/test/config-cli.test.ts @@ -107,4 +107,18 @@ describe("config CLI schema coverage", () => { value: 600, }); }); + + it("accepts max as a persisted default thinking level", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + await runConfigCommand({ action: "set", key: "defaultThinkingLevel", value: "max", flags: { json: true } }); + await runConfigCommand({ action: "get", key: "defaultThinkingLevel", flags: { json: true } }); + + const payload = logSpy.mock.calls.at(-1)?.[0]; + expect(typeof payload).toBe("string"); + const parsed = JSON.parse(String(payload)) as { key: string; value: unknown; type: string }; + expect(parsed.key).toBe("defaultThinkingLevel"); + expect(parsed.type).toBe("enum"); + expect(parsed.value).toBe("max"); + }); }); diff --git a/packages/coding-agent/test/config-spacing.test.ts b/packages/coding-agent/test/config-spacing.test.ts deleted file mode 100644 index c944fa816..000000000 --- a/packages/coding-agent/test/config-spacing.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -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 { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { getDefaultTabWidth, getIndentation, Snowflake, setDefaultTabWidth } from "@oh-my-pi/pi-utils"; -import { beginSettingsTest, restoreSettingsTestState, type SettingsTestState } from "./helpers/settings-test-state"; - -describe("indentation resolver", () => { - let settingsState: SettingsTestState | undefined; - let tempDir = ""; - - beforeEach(async () => { - settingsState = beginSettingsTest(); - setDefaultTabWidth(3); - tempDir = path.join(os.tmpdir(), "pi-spacing", Snowflake.next()); - await fs.mkdir(tempDir, { recursive: true }); - }); - - afterEach(async () => { - restoreSettingsTestState(settingsState); - settingsState = undefined; - if (tempDir) { - await fs.rm(tempDir, { recursive: true, force: true }); - } - tempDir = ""; - }); - - it("applies current display tab width during initial settings load", async () => { - await Settings.init({ inMemory: true, cwd: tempDir, overrides: { "display.tabWidth": 7 } }); - expect(getDefaultTabWidth()).toBe(7); - expect(getIndentation()).toBe(7); - }); - - it("applies nearest editorconfig rules for the target file", async () => { - const filePath = path.join(tempDir, "src", "feature.ts"); - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile(filePath, "\tconst x = 1;\n"); - await fs.writeFile( - path.join(tempDir, ".editorconfig"), - ["root = true", "", "[*]", "indent_size = 2", "", "[*.md]", "indent_size = 4"].join("\n"), - ); - - expect(getIndentation(filePath)).toBe(2); - expect(getIndentation(path.join(tempDir, "README.md"))).toBe(4); - }); - - it("merges editorconfig files from root to leaf", async () => { - const nestedDir = path.join(tempDir, "packages", "feature"); - const filePath = path.join(nestedDir, "index.ts"); - await fs.mkdir(nestedDir, { recursive: true }); - await fs.writeFile(filePath, "\tconst y = 2;\n"); - - await fs.writeFile(path.join(tempDir, ".editorconfig"), ["root = true", "", "[*]", "indent_size = 2"].join("\n")); - await fs.writeFile(path.join(tempDir, "packages", ".editorconfig"), ["[*.ts]", "indent_size = 6"].join("\n")); - - expect(getIndentation(filePath)).toBe(6); - }); -}); diff --git a/packages/coding-agent/test/core/js-executor.test.ts b/packages/coding-agent/test/core/js-executor.test.ts index 914f72b09..f0886a915 100644 --- a/packages/coding-agent/test/core/js-executor.test.ts +++ b/packages/coding-agent/test/core/js-executor.test.ts @@ -6,6 +6,7 @@ import { disposeAllVmContexts } from "@oh-my-pi/pi-coding-agent/eval/js/context- import { executeJs, type JsResult } from "@oh-my-pi/pi-coding-agent/eval/js/executor"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { TempDir } from "@oh-my-pi/pi-utils"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import { type } from "arktype"; // JS eval cold-starts a Bun worker; under --isolate + high CI concurrency that startup @@ -365,13 +366,13 @@ describe("executeJs", () => { expect(execute).toHaveBeenNthCalledWith( 1, expect.stringMatching(/^js-read-/), - { path: "artifact://15:raw:1-1400", _i: "js prelude" }, + { path: "artifact://15:raw:1-1400", [INTENT_FIELD]: "js prelude" }, expect.any(AbortSignal), ); expect(execute).toHaveBeenNthCalledWith( 2, expect.stringMatching(/^js-read-/), - { path: "artifact://15:raw:1-2", _i: "js prelude" }, + { path: "artifact://15:raw:1-2", [INTENT_FIELD]: "js prelude" }, expect.any(AbortSignal), ); }); @@ -422,8 +423,8 @@ describe("executeJs", () => { agentOutput: "from-agent", }); expect(execute).toHaveBeenCalledTimes(2); - expect(execute.mock.calls[0]?.[1]).toEqual({ path: "package.json", _i: "js prelude" }); - expect(execute.mock.calls[1]?.[1]).toEqual({ path: "agent://agent-42", _i: "js prelude" }); + expect(execute.mock.calls[0]?.[1]).toEqual({ path: "package.json", [INTENT_FIELD]: "js prelude" }); + expect(execute.mock.calls[1]?.[1]).toEqual({ path: "agent://agent-42", [INTENT_FIELD]: "js prelude" }); }); it("auto-displays the final awaited expression result", async () => { diff --git a/packages/coding-agent/test/core/js-tool-bridge.test.ts b/packages/coding-agent/test/core/js-tool-bridge.test.ts index df6c75512..fb1f3809b 100644 --- a/packages/coding-agent/test/core/js-tool-bridge.test.ts +++ b/packages/coding-agent/test/core/js-tool-bridge.test.ts @@ -3,6 +3,7 @@ import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { callSessionTool } from "@oh-my-pi/pi-coding-agent/eval/js/tool-bridge"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import { type } from "arktype"; function createTool( @@ -53,7 +54,7 @@ describe("callSessionTool", () => { expect(result).toBe("hello"); expect(execute).toHaveBeenCalledWith( expect.stringMatching(/^js-read-/), - { path: "/tmp/demo.txt", _i: "js prelude" }, + { path: "/tmp/demo.txt", [INTENT_FIELD]: "js prelude" }, undefined, ); expect(statuses).toEqual([expect.objectContaining({ op: "read", path: "/tmp/demo.txt", chars: 5 })]); diff --git a/packages/coding-agent/test/core/python-tool-bridge.test.ts b/packages/coding-agent/test/core/python-tool-bridge.test.ts index 67d158356..2bb4ee099 100644 --- a/packages/coding-agent/test/core/python-tool-bridge.test.ts +++ b/packages/coding-agent/test/core/python-tool-bridge.test.ts @@ -6,6 +6,7 @@ import { registerPyToolBridge, } from "@oh-my-pi/pi-coding-agent/eval/py/tool-bridge"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; interface FakeCall { id: string; @@ -64,14 +65,14 @@ describe("Python tool bridge HTTP server", () => { session: "test-session-1", run: "run-1", name: "read", - args: { path: "foo.ts", _i: "py prelude" }, + args: { path: "foo.ts", [INTENT_FIELD]: "py prelude" }, }); const body = await res.json(); expect(res.status).toBe(200); expect(body).toEqual({ ok: true, value: "file body" }); expect(calls).toHaveLength(1); // `_i` survives the bridge round trip so transcript renderers have a label. - expect((calls[0]!.args as { _i?: string })._i).toBe("py prelude"); + expect((calls[0]!.args as Record)[INTENT_FIELD]).toBe("py prelude"); } finally { unregister(); } diff --git a/packages/coding-agent/test/edit/file-snapshot-store.test.ts b/packages/coding-agent/test/edit/file-snapshot-store.test.ts index bd950ee82..eb829e4ee 100644 --- a/packages/coding-agent/test/edit/file-snapshot-store.test.ts +++ b/packages/coding-agent/test/edit/file-snapshot-store.test.ts @@ -72,17 +72,15 @@ describe("snapshot store fusion via canonical keys", () => { describe("parseSeenLinesFromHashlineBody", () => { it("collects single NN: line numbers and skips the header and footer rows", () => { - const body = ["[src/x.ts#1A2B]", "300:function f() {", "301:\treturn 1;", "302:}", "[2 lines elided; …]"].join( - "\n", - ); + const body = ["[src/x.ts#1A2B]", "300:function f() {", "301:\treturn 1;", "302:}", "[…2ln elided; …]"].join("\n"); expect(parseSeenLinesFromHashlineBody(body)).toEqual([300, 301, 302]); }); it("adds only the boundary lines of a collapsed NN-MM: summary row, never the interior", () => { const body = [ - "30-39:export interface Snapshot { .. }", + "30-39:export interface Snapshot { … }", "40:", - "46-61:export abstract class SnapshotStore { .. }", + "46-61:export abstract class SnapshotStore { … }", ].join("\n"); expect(parseSeenLinesFromHashlineBody(body)).toEqual([30, 39, 40, 46, 61]); }); @@ -92,7 +90,7 @@ describe("parseSeenLinesFromHashlineBody", () => { }); it("tolerates grep `*`/space match markers before the line number (search/ast-grep output)", () => { - const body = ["*73:matched line", " 74:context line", "...", " 75:more context"].join("\n"); + const body = ["*73:matched line", " 74:context line", "…", " 75:more context"].join("\n"); expect(parseSeenLinesFromHashlineBody(body)).toEqual([73, 74, 75]); }); }); diff --git a/packages/coding-agent/test/file-mentions.test.ts b/packages/coding-agent/test/file-mentions.test.ts index 8700ced2d..4437cb497 100644 --- a/packages/coding-agent/test/file-mentions.test.ts +++ b/packages/coding-agent/test/file-mentions.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { generateFileMentionMessages } from "@oh-my-pi/pi-coding-agent/utils/file-mentions"; +import { extractFileMentions, generateFileMentionMessages } from "@oh-my-pi/pi-coding-agent/utils/file-mentions"; const tempDirs: string[] = []; @@ -79,4 +79,22 @@ describe("generateFileMentionMessages path resolution", () => { expect(message.files[0]?.path).toBe("real.txt"); expect(message.files[0]?.content).toContain("present"); }); + + test("resolves quoted paths containing spaces", async () => { + const cwd = await createTempDir(); + await fs.mkdir(path.join(cwd, "My Folder"), { recursive: true }); + await Bun.write(path.join(cwd, "My Folder", "my file.png"), "image content"); + + const mentions = extractFileMentions("Please see @\"My Folder/my file.png\" and @'My Folder/my file.png'"); + expect(mentions).toEqual(["My Folder/my file.png"]); + + const messages = await generateFileMentionMessages(mentions, cwd); + expect(messages).toHaveLength(1); + const message = messages[0]; + if (message?.role !== "fileMention") { + throw new Error("expected file mention message"); + } + expect(message.files).toHaveLength(1); + expect(message.files[0]?.path).toBe("My Folder/my file.png"); + }); }); diff --git a/packages/coding-agent/test/flag-tables.test.ts b/packages/coding-agent/test/flag-tables.test.ts index 70db464c9..386996f19 100644 --- a/packages/coding-agent/test/flag-tables.test.ts +++ b/packages/coding-agent/test/flag-tables.test.ts @@ -89,3 +89,20 @@ describe("parseArgs end-of-options (--)", () => { expect(result.messages).toEqual(["hello", "--no-tools"]); }); }); + +describe("parseArgs @file parsing with quotes", () => { + it("parses unquoted @file arguments normally", () => { + const result = parseArgs(["@foo.png"]); + expect(result.fileArgs).toEqual(["foo.png"]); + }); + + it('parses double-quoted @"file" arguments', () => { + const result = parseArgs(['@"foo bar.png"']); + expect(result.fileArgs).toEqual(["foo bar.png"]); + }); + + it("parses single-quoted @'file' arguments", () => { + const result = parseArgs(["@'foo bar.png'"]); + expect(result.fileArgs).toEqual(["foo bar.png"]); + }); +}); diff --git a/packages/coding-agent/test/gallery-cli.test.ts b/packages/coding-agent/test/gallery-cli.test.ts index c88eeaf11..9eaf0b5d7 100644 --- a/packages/coding-agent/test/gallery-cli.test.ts +++ b/packages/coding-agent/test/gallery-cli.test.ts @@ -2,7 +2,7 @@ import { beforeAll, describe, expect, it } from "bun:test"; import { GALLERY_STATES, renderGalleryState, resolveFixture } from "@oh-my-pi/pi-coding-agent/cli/gallery-cli"; import type { GalleryFixture } from "@oh-my-pi/pi-coding-agent/cli/gallery-fixtures"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import { initTheme, theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { toolRenderers } from "@oh-my-pi/pi-coding-agent/tools/renderers"; beforeAll(async () => { @@ -63,7 +63,7 @@ describe("gallery harness", () => { const lines = await renderGalleryState("task", task, "error", 100); const stripped = lines.map(line => Bun.stripANSI(line).trim()); // The framed result header carries the label inside the box border... - expect(stripped.some(line => line.startsWith("┌") && line.includes("Task"))).toBe(true); + expect(stripped.some(line => line.startsWith(theme.boxRound.topLeft) && line.includes("Task"))).toBe(true); // ...but no standalone "Task" label line precedes it. expect(stripped).not.toContain("Task"); }); diff --git a/packages/coding-agent/test/helpers/settings-test-state.ts b/packages/coding-agent/test/helpers/settings-test-state.ts index e4825c4c7..44928c099 100644 --- a/packages/coding-agent/test/helpers/settings-test-state.ts +++ b/packages/coding-agent/test/helpers/settings-test-state.ts @@ -1,20 +1,12 @@ import { vi } from "bun:test"; import { resetSettingsForTest } from "@oh-my-pi/pi-coding-agent/config/settings"; import { isTuiTight, setTuiTight } from "@oh-my-pi/pi-tui"; -import { - getAgentDir, - getDefaultTabWidth, - getProjectDir, - setAgentDir, - setDefaultTabWidth, - setProjectDir, -} from "@oh-my-pi/pi-utils"; +import { getAgentDir, getProjectDir, setAgentDir, setProjectDir } from "@oh-my-pi/pi-utils"; export interface SettingsTestState { agentDir: string; env: Record; projectDir: string; - tabWidth: number; tuiTight: boolean; } @@ -30,7 +22,6 @@ export function beginSettingsTest(): SettingsTestState { agentDir: getAgentDir(), env, projectDir: getProjectDir(), - tabWidth: getDefaultTabWidth(), tuiTight: isTuiTight(), }; resetSettingsForTest(); @@ -43,7 +34,6 @@ export function restoreSettingsTestState(state: SettingsTestState | undefined): if (!state) return; restoreEnv(state.env); - setDefaultTabWidth(state.tabWidth); setProjectDir(state.projectDir); setAgentDir(state.agentDir); setTuiTight(state.tuiTight); diff --git a/packages/coding-agent/test/hindsight-mental-models.test.ts b/packages/coding-agent/test/hindsight-mental-models.test.ts index 14809629f..3187080ce 100644 --- a/packages/coding-agent/test/hindsight-mental-models.test.ts +++ b/packages/coding-agent/test/hindsight-mental-models.test.ts @@ -319,7 +319,7 @@ describe("diffMentalModelContent", () => { const out = diffMentalModelContent(null, big, 50); const lines = out.split("\n"); expect(lines.length).toBe(51); // 50 diff lines + 1 elision marker - expect(lines[lines.length - 1]).toMatch(/more lines? elided$/); + expect(lines[lines.length - 1]).toMatch(/\[…\d+ln elided…\]$/); }); it("caps LCS input lines so a huge curated model cannot hang the diff", () => { diff --git a/packages/coding-agent/test/input-controller-thinking-visibility.test.ts b/packages/coding-agent/test/input-controller-thinking-visibility.test.ts index f5bf6c6a5..c8383d0c6 100644 --- a/packages/coding-agent/test/input-controller-thinking-visibility.test.ts +++ b/packages/coding-agent/test/input-controller-thinking-visibility.test.ts @@ -9,7 +9,7 @@ describe("InputController thinking visibility", () => { const loadingIndicator = { kind: "loading" }; const assistant = new AssistantMessageComponent(); const setHideThinkingBlock = vi.spyOn(assistant, "setHideThinkingBlock"); - const invalidate = vi.spyOn(assistant, "invalidate"); + const resetDisplay = vi.fn(); const clear = vi.fn(); const addChild = vi.fn(); const rebuildChatFromMessages = vi.fn(); @@ -26,6 +26,7 @@ describe("InputController thinking visibility", () => { streamingMessage: undefined, rebuildChatFromMessages, showStatus, + ui: { resetDisplay }, } as unknown as InteractiveModeContext; new InputController(ctx).toggleThinkingBlockVisibility(); @@ -38,7 +39,7 @@ describe("InputController thinking visibility", () => { expect(addChild).not.toHaveBeenCalled(); expect(rebuildChatFromMessages).not.toHaveBeenCalled(); expect(setHideThinkingBlock).toHaveBeenCalledWith(true); - expect(invalidate).toHaveBeenCalledTimes(1); + expect(resetDisplay).toHaveBeenCalledTimes(1); expect(showStatus).toHaveBeenCalledWith("Thinking blocks: hidden"); }); }); diff --git a/packages/coding-agent/test/interactive-mode-plan-review.test.ts b/packages/coding-agent/test/interactive-mode-plan-review.test.ts index 0022e3b2f..cd5db7850 100644 --- a/packages/coding-agent/test/interactive-mode-plan-review.test.ts +++ b/packages/coding-agent/test/interactive-mode-plan-review.test.ts @@ -957,7 +957,7 @@ describe("InteractiveMode plan review rendering", () => { let modelAtFlushTime: string | undefined; // Mirror executeCompaction's ordering: invoke beforeFlush, THEN observe the // model the queue would flush on. - vi.spyOn(mode, "handleCompactCommand").mockImplementation(async (_instructions, beforeFlush) => { + vi.spyOn(mode, "handleCompactCommand").mockImplementation(async (_instructions, _mode, beforeFlush) => { hookWasFunction = typeof beforeFlush === "function"; if (beforeFlush) await beforeFlush("ok"); modelAtFlushTime = session.model?.id; diff --git a/packages/coding-agent/test/internal-urls/history-protocol.test.ts b/packages/coding-agent/test/internal-urls/history-protocol.test.ts index 503170f41..348b4f7c7 100644 --- a/packages/coding-agent/test/internal-urls/history-protocol.test.ts +++ b/packages/coding-agent/test/internal-urls/history-protocol.test.ts @@ -13,6 +13,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { InternalUrlRouter } from "@oh-my-pi/pi-coding-agent/internal-urls"; +import { HistoryProtocolHandler } from "@oh-my-pi/pi-coding-agent/internal-urls/history-protocol"; import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { CURRENT_SESSION_VERSION } from "@oh-my-pi/pi-coding-agent/session/session-entries"; @@ -186,4 +187,67 @@ describe("history:// protocol", () => { expect(error?.message).toContain("no transcript"); }); + + it("hides advisor transcripts from the index and direct lookup", async () => { + AgentRegistry.global().register({ + id: "HubAgent", + displayName: "task", + kind: "sub", + session: fakeLiveSession([]), + status: "idle", + }); + AgentRegistry.global().register({ + id: "Main/advisor", + displayName: "advisor", + kind: "advisor", + session: fakeLiveSession([{ role: "user", content: "should stay hidden", timestamp: 1 }]), + status: "parked", + }); + AgentRegistry.global().register({ + id: "AdvisorProbe", + displayName: "advisor", + kind: "advisor", + session: fakeLiveSession([{ role: "user", content: "should stay hidden", timestamp: 1 }]), + status: "parked", + }); + + // Index lists the subagent but never the advisor. + const index = await InternalUrlRouter.instance().resolve("history://"); + expect(index.content).toContain("HubAgent"); + expect(index.content).not.toContain("advisor"); + + // Direct lookup of an advisor-kind ref is reported as unknown — the driving + // agent must not be able to read it via history://. + const error = await InternalUrlRouter.instance() + .resolve("history://AdvisorProbe") + .then( + () => null, + err => err as Error, + ); + expect(error).toBeInstanceOf(Error); + expect(error?.message).toContain("Unknown agent"); + }); + + it("omits advisor refs from history:// completions", async () => { + AgentRegistry.global().register({ + id: "HubAgent", + displayName: "task", + kind: "sub", + session: fakeLiveSession([]), + status: "idle", + }); + AgentRegistry.global().register({ + id: "AdvisorProbe", + displayName: "advisor", + kind: "advisor", + session: null, + sessionFile: "/tmp/x/__advisor.jsonl", + status: "parked", + }); + + const completions = await new HistoryProtocolHandler().complete(); + const values = completions.map(c => c.value); + expect(values).toContain("HubAgent"); + expect(values).not.toContain("AdvisorProbe"); + }); }); diff --git a/packages/coding-agent/test/issue-2375-repro.test.ts b/packages/coding-agent/test/issue-2375-repro.test.ts index 3200f0279..f4e8f834a 100644 --- a/packages/coding-agent/test/issue-2375-repro.test.ts +++ b/packages/coding-agent/test/issue-2375-repro.test.ts @@ -13,7 +13,10 @@ * text paste; the user must see an SSH-aware diagnostic so they know to * paste image bytes directly instead. */ + import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import * as os from "node:os"; +import * as path from "node:path"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { InputController } from "@oh-my-pi/pi-coding-agent/modes/controllers/input-controller"; import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; @@ -162,7 +165,7 @@ describe("InputController.handleImagePathPaste (issue #2375)", () => { readText: async () => "", }); // This test file itself: resolvable, readable, but not an image. - const nonImage = import.meta.path.replace(/\.ts$/, ".png"); + const nonImage = path.join(os.tmpdir(), `issue-2375-not-png-${Math.random().toString(36).slice(2)}.png`); await Bun.write(nonImage, "not really a png"); try { await controller.handleImagePathPaste(nonImage); diff --git a/packages/coding-agent/test/keybindings-migration.test.ts b/packages/coding-agent/test/keybindings-migration.test.ts index 6cca930c5..53c8727d1 100644 --- a/packages/coding-agent/test/keybindings-migration.test.ts +++ b/packages/coding-agent/test/keybindings-migration.test.ts @@ -55,6 +55,43 @@ describe("KeybindingsManager.create", () => { } }); + it("migrates legacy keybinding JSON with comments to YAML during create", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-keybindings-")); + const jsonPath = path.join(agentDir, "keybindings.json"); + const ymlPath = path.join(agentDir, "keybindings.yml"); + + await Bun.write( + jsonPath, + `{ + // Legacy config files may contain comments from hand-edited examples. + "fork": "ctrl+f", + "selectConfirm": "enter", + "cursorUp": "ctrl+p", + "app.clipboard.copyPrompt": ["alt+c", "ctrl+shift+c"] +} +`, + ); + + try { + const manager = KeybindingsManager.create(agentDir); + const writtenConfig = YAML.parse(await Bun.file(ymlPath).text()); + + expect(manager.getKeys("app.session.fork")).toEqual(["ctrl+f"]); + expect(manager.getKeys("tui.select.confirm")).toEqual(["enter"]); + expect(manager.getKeys("tui.editor.cursorUp")).toEqual(["ctrl+p"]); + expect(manager.getKeys("app.clipboard.copyPrompt")).toEqual(["alt+c", "ctrl+shift+c"]); + expect(writtenConfig).toEqual({ + "app.clipboard.copyPrompt": ["alt+c", "ctrl+shift+c"], + "app.session.fork": "ctrl+f", + "tui.editor.cursorUp": "ctrl+p", + "tui.select.confirm": "enter", + }); + expect(await Bun.file(jsonPath).exists()).toBe(true); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + it("loads keybindings.yml directly", async () => { const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-keybindings-")); const configPath = path.join(agentDir, "keybindings.yml"); diff --git a/packages/coding-agent/test/mcp-dispose-disconnect-bounded.test.ts b/packages/coding-agent/test/mcp-dispose-disconnect-bounded.test.ts new file mode 100644 index 000000000..eecff1787 --- /dev/null +++ b/packages/coding-agent/test/mcp-dispose-disconnect-bounded.test.ts @@ -0,0 +1,84 @@ +/** + * Regression guard for the dispose-path MCP teardown (PR #2839). + * + * `AgentSession.dispose()` disconnects the MCP manager it OWNS so stdio + * subprocesses are reaped instead of orphaned. That disconnect MUST be + * BOUNDED: an owned manager may hold an HTTP/SSE server whose + * session-termination DELETE blocks up to the MCP request timeout (30s + * default, unbounded when `OMP_MCP_TIMEOUT_MS=0`). `dispose()` wraps the + * disconnect in `withTimeout(...)`; this test proves that when the underlying + * `MCPManager.disconnectAll()` stalls on a stuck transport close, the bound + * returns promptly so `/exit` and print-mode shutdown are never gated on a + * broken remote endpoint. + * + * Shutdown-side analog of the startup bound defended by + * `mcp-startup-no-block.test.ts` (issue #2100): the same unbounded + * `Promise.allSettled` over MCP transports must never gate a lifecycle + * transition. Like that test, this is an integration test against a real MCP + * subprocess and the real `withTimeout` clock, so it asserts elapsed wall time + * rather than driving fake timers — fake timers cannot advance the subprocess + * transport and would neutralize the very bound under test. + */ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { withTimeout } from "@oh-my-pi/pi-utils"; +import { MCPManager } from "../src/mcp/manager"; +import type { MCPStdioServerConfig } from "../src/mcp/types"; + +const FIXTURE_PATH = path.join(import.meta.dir, "fixtures", "instructions-mcp.ts"); +const BUN_EXEC = process.execPath; + +describe("owned-manager dispose disconnect is bounded (PR #2839)", () => { + let workDir: string; + + beforeEach(() => { + workDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-mcp-dispose-")); + }); + + afterEach(() => { + fs.rmSync(workDir, { recursive: true, force: true }); + }); + + it("bounds the owned disconnect when a transport close stalls", async () => { + const manager = new MCPManager(workDir); + const config: MCPStdioServerConfig = { type: "stdio", command: BUN_EXEC, args: [FIXTURE_PATH] }; + const result = await manager.connectServers({ instr: config }, {}); + expect(result.errors.has("instr")).toBe(false); + expect(manager.getConnectedServers()).toContain("instr"); + + const connection = manager.getConnection("instr"); + if (!connection) throw new Error("expected a live connection to the fixture server"); + + // Stand in for an HTTP/SSE transport whose termination DELETE never + // returns. A controllable gate keeps cleanup deterministic — no + // forever-pending promise and no orphaned subprocess once the test ends. + const realClose = connection.transport.close.bind(connection.transport); + let releaseClose: () => void = () => {}; + const closeGate = new Promise(resolve => { + releaseClose = resolve; + }); + connection.transport.close = () => closeGate; + + try { + // `disconnectAll()` is exactly what `dispose()` invokes; with a stuck + // close it never settles on its own. The dispose bound (`withTimeout`) + // MUST reject within its deadline — a rejection here proves the + // disconnect did not settle in time and that shutdown is not blocked + // on the 30s request timeout. + const disconnect = manager.disconnectAll(); + const start = performance.now(); + await expect(withTimeout(disconnect, 250, "owned MCP disconnect timed out during dispose")).rejects.toThrow( + /timed out/i, + ); + expect(performance.now() - start).toBeLessThan(3_000); + + // Release the gate so the detached disconnect finishes cleanly. + releaseClose(); + await disconnect; + } finally { + await realClose(); + } + }, 20_000); +}); diff --git a/packages/coding-agent/test/model-registry-create.test.ts b/packages/coding-agent/test/model-registry-create.test.ts index 2f809a66a..0253a3efc 100644 --- a/packages/coding-agent/test/model-registry-create.test.ts +++ b/packages/coding-agent/test/model-registry-create.test.ts @@ -69,4 +69,23 @@ describe("ModelRegistry.create() factory (F6)", () => { const mtime2 = fs.statSync(yml).mtimeMs; expect(mtime2).toBe(mtime1); }); + + test("ConfigFile migrates legacy models.json containing comments", async () => { + const yml = path.join(tempDir.path(), "models.yml"); + const json = path.join(tempDir.path(), "models.json"); + await Bun.write( + json, + `{ + // Custom models comment + "providers": { + /* Block comment */ + } + }`, + ); + + const cf = new ConfigFile("models", ModelsConfigSchema, yml); + const result = cf.tryLoad(); + expect(result.status).toBe("ok"); + expect(fs.existsSync(yml)).toBe(true); + }); }); diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index 47e8df17a..bbc6006fc 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -6,6 +6,7 @@ import type { CanonicalModelVariant } from "@oh-my-pi/pi-coding-agent/config/mod import { type CanonicalModelRegistry, expandRoleAlias, + extractExplicitThinkingSelector, filterAvailableModelsByEnabledPatterns, parseModelPattern, parseModelString, @@ -111,6 +112,37 @@ const mockOpenRouterModels: Model[] = [ }), ]; +const mockMaxSuffixModels: Model[] = [ + buildModel({ + id: "coding-router", + name: "NanoGPT Coding Router", + api: "openai-completions", + provider: "nanogpt", + baseUrl: "https://nano-gpt.com/api/v1", + reasoning: true, + thinking: { + mode: "effort", + efforts: [Effort.Low, Effort.Medium, Effort.High, Effort.XHigh], + }, + input: ["text"], + cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, + }), + buildModel({ + id: "coding-router:max", + name: "NanoGPT Coding Router Max", + api: "openai-completions", + provider: "nanogpt", + baseUrl: "https://nano-gpt.com/api/v1", + reasoning: false, + input: ["text"], + cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, + }), +]; + const mockProviderOverlapModels: Model<"anthropic-messages">[] = [ buildModel({ id: "kimi-k2.5", @@ -403,6 +435,21 @@ describe("parseModelPattern", () => { expect(result.warning).toBeUndefined(); } }); + test("max aliases the highest thinking level after the literal pattern misses", () => { + const result = parseModelPattern("gpt-5.3-codex:max", allModels); + expect(result.model?.id).toBe("gpt-5.3-codex"); + expect(result.thinkingLevel).toBe(Effort.XHigh); + expect(result.explicitThinkingLevel).toBe(true); + expect(result.warning).toBeUndefined(); + }); + + test("literal model ids ending in max win over the thinking alias", () => { + const result = parseModelPattern("nanogpt/coding-router:max", mockMaxSuffixModels); + expect(result.model?.id).toBe("coding-router:max"); + expect(result.thinkingLevel).toBeUndefined(); + expect(result.explicitThinkingLevel).toBe(false); + expect(result.warning).toBeUndefined(); + }); }); describe("patterns with invalid thinking levels", () => { @@ -484,6 +531,16 @@ describe("parseModelPattern", () => { expect(result.explicitThinkingLevel).toBe(true); expect(result.warning).toBeUndefined(); }); + + test("openrouter/:max applies xhigh through the exact-selector path, not an OpenRouter route", () => { + // `max` is a thinking alias, never an OpenRouter route suffix: the request must + // resolve the base model and carry xhigh, not clone a literal `z-ai/glm-4.7:max`. + const result = parseModelPattern("openrouter/z-ai/glm-4.7:max", allModels); + expect(result.model?.provider).toBe("openrouter"); + expect(result.model?.id).toBe("z-ai/glm-4.7"); + expect(result.thinkingLevel).toBe(Effort.XHigh); + expect(result.explicitThinkingLevel).toBe(true); + }); }); describe("invalid thinking levels with OpenRouter models", () => { @@ -571,6 +628,19 @@ describe("resolveModelRoleValue", () => { expect(result.explicitThinkingLevel).toBe(true); }); + test("resolves pi/:max by expanding role alias before parsing thinking", () => { + const settings = { + getModelRole: (role: string) => (role === "smol" ? "openai-codex/gpt-5.3-codex" : undefined), + } as NonNullable[2]>["settings"]; + + const result = resolveModelRoleValue("pi/smol:max", allModels, { settings }); + + expect(result.model?.provider).toBe("openai-codex"); + expect(result.model?.id).toBe("gpt-5.3-codex"); + expect(result.thinkingLevel).toBe(Effort.XHigh); + expect(result.explicitThinkingLevel).toBe(true); + }); + test("resolves pi/default through configured default role alias", () => { const settings = { getModelRole: (role: string) => (role === "default" ? "openrouter/qwen/qwen3-coder:exacto" : undefined), @@ -883,6 +953,51 @@ describe("resolveCliModel", () => { expect(result.model?.id).toBe("z-ai/glm-4.7-20251222:nitro"); }); + test("accepts Bedrock inference profile ARNs and preserves thinking suffixes", () => { + const defaultBedrockModel = buildModel({ + id: "us.anthropic.claude-opus-4-8", + name: "Claude Opus 4.8 (US)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + contextWindow: 1000000, + maxTokens: 128000, + }); + const profileArn = "arn:aws:bedrock:us-east-2:1234567890:application-inference-profile/company-opus-48"; + + const baseResult = resolveCliModel({ + cliProvider: "amazon-bedrock", + cliModel: profileArn, + modelRegistry: { + getAll: () => [defaultBedrockModel], + }, + }); + const offResult = resolveCliModel({ + cliProvider: "amazon-bedrock", + cliModel: `${profileArn}:off`, + modelRegistry: { + getAll: () => [defaultBedrockModel], + }, + }); + + expect(baseResult.error).toBeUndefined(); + expect(baseResult.model?.provider).toBe("amazon-bedrock"); + expect(baseResult.model?.api).toBe("bedrock-converse-stream"); + expect(baseResult.model?.id).toBe(profileArn); + expect(baseResult.model?.name).toBe("Bedrock inference profile"); + expect(baseResult.model?.reasoning).toBe(false); + expect(baseResult.model?.thinking).toBeUndefined(); + expect(baseResult.model?.contextWindow).toBeNull(); + expect(baseResult.model?.maxTokens).toBeNull(); + expect(baseResult.thinkingLevel).toBeUndefined(); + expect(offResult.error).toBeUndefined(); + expect(offResult.model?.id).toBe(profileArn); + expect(offResult.thinkingLevel).toBe("off"); + }); + test("returns a clear error when there are no models", () => { const registry = { getAll: () => [], @@ -981,6 +1096,49 @@ describe("resolveModelScope", () => { expect(scoped[0].model.provider).toBe("openai"); expect(scoped[0].model.id).toBe("gpt-5.5"); }); + + test("expands exact canonical ids with max thinking aliases", async () => { + const scoped = await resolveModelScope(["claude-sonnet-4-5:max"], { + getAvailable: () => canonicalVariantModels, + getCanonicalVariants: (canonicalId: string, options?: { candidates?: Model<"anthropic-messages">[] }) => + canonicalRegistry.getCanonicalVariants!(canonicalId, options), + } as unknown as Parameters[1]); + + expect(scoped).toHaveLength(2); + expect(scoped.map(entry => `${entry.model.provider}/${entry.model.id}`).sort()).toEqual([ + "anthropic/claude-sonnet-4-5", + "github-copilot/anthropic/claude-sonnet-4.5", + ]); + expect(scoped.map(entry => entry.thinkingLevel)).toEqual([Effort.High, Effort.High]); + expect(scoped.every(entry => entry.explicitThinkingLevel)).toBe(true); + }); + + test("applies max thinking aliases to glob scopes when no literal max ids match", async () => { + const registry = { + getAvailable: () => mockCodexOverlapModels, + getCanonicalVariants: (_id: string, _opts?: unknown): CanonicalModelVariant[] => [], + }; + + const scoped = await resolveModelScope(["openai-codex/*:max"], registry); + + expect(scoped).toHaveLength(2); + expect(scoped.map(entry => entry.thinkingLevel)).toEqual([Effort.XHigh, Effort.XHigh]); + expect(scoped.every(entry => entry.explicitThinkingLevel)).toBe(true); + }); + + test("preserves literal :max in scoped-model globs", async () => { + const registry = { + getAvailable: () => mockMaxSuffixModels, + getCanonicalVariants: (_id: string, _opts?: unknown): CanonicalModelVariant[] => [], + }; + + const scoped = await resolveModelScope(["nanogpt/*:max"], registry); + + expect(scoped).toHaveLength(1); + expect(scoped[0].model.id).toBe("coding-router:max"); + expect(scoped[0].thinkingLevel).toBeUndefined(); + expect(scoped[0].explicitThinkingLevel).toBe(false); + }); }); describe("parseModelString", () => { @@ -1029,6 +1187,23 @@ describe("parseModelString", () => { }); }); + test("extracts max when explicitly enabled for provider id selectors", () => { + const result = parseModelString("deepseek/deepseek-v4-pro:max", { allowMaxAlias: true }); + expect(result).toEqual({ provider: "deepseek", id: "deepseek-v4-pro", thinkingLevel: Effort.XHigh }); + }); + + test("preserves literal max model ids when the caller can prove they exist", () => { + const result = parseModelString("nanogpt/coding-router:max", { + allowMaxAlias: true, + isLiteralModelId: (provider, id) => provider === "nanogpt" && id === "coding-router:max", + }); + expect(result).toEqual({ provider: "nanogpt", id: "coding-router:max" }); + }); + + test("does not strip inherited object keys as thinking suffixes", () => { + const result = parseModelString("anthropic/claude-sonnet-4-5:constructor"); + expect(result).toEqual({ provider: "anthropic", id: "claude-sonnet-4-5:constructor" }); + }); test("does not extract thinking level from model ID with invalid suffix", () => { const result = parseModelString("openrouter/openai/gpt-4o:extended"); // :extended is not a valid thinking level, so it stays as part of the ID @@ -1043,6 +1218,20 @@ describe("parseModelString", () => { }); }); +describe("resolveModelFromString", () => { + test("applies max as a provider model selector alias after literal lookup misses", () => { + const result = resolveModelFromString("nanogpt/coding-router:max", [mockMaxSuffixModels[0]]); + expect(result?.provider).toBe("nanogpt"); + expect(result?.id).toBe("coding-router"); + }); + + test("preserves literal max provider model ids before alias parsing", () => { + const result = resolveModelFromString("nanogpt/coding-router:max", mockMaxSuffixModels); + expect(result?.provider).toBe("nanogpt"); + expect(result?.id).toBe("coding-router:max"); + }); +}); + describe("expandRoleAlias", () => { test("expands pi/vision to configured vision role", () => { const settings = Settings.isolated(); @@ -1059,6 +1248,31 @@ describe("expandRoleAlias", () => { }); }); +describe("extractExplicitThinkingSelector", () => { + test("does not carry max from literal role model ids", () => { + const result = extractExplicitThinkingSelector("nanogpt/coding-router:max", undefined, { + isLiteralModelId: (provider, id) => provider === "nanogpt" && id === "coding-router:max", + }); + expect(result).toBeUndefined(); + }); + + test("treats max as an explicit selector when the model id is not literal", () => { + const result = extractExplicitThinkingSelector("nanogpt/coding-router:max", undefined, { + isLiteralModelId: () => false, + }); + expect(result).toBe(Effort.XHigh); + }); + + test("treats max on pi role aliases as an explicit selector before expansion", () => { + const settings = Settings.isolated(); + settings.setModelRole("smol", "nanogpt/coding-router:max"); + const result = extractExplicitThinkingSelector("pi/smol:max", settings, { + isLiteralModelId: (provider, id) => provider === "nanogpt" && id === "coding-router:max", + }); + expect(result).toBe(Effort.XHigh); + }); +}); + describe("provider routing selector (@upstream)", () => { const openRouterOnly = (model: Model | undefined): string[] | undefined => (model?.compat as { openRouterRouting?: { only?: string[] } } | undefined)?.openRouterRouting?.only; @@ -1210,6 +1424,22 @@ describe("filterAvailableModelsByEnabledPatterns", () => { expect(result[0].id).toBe("claude-sonnet-4-5"); }); + test("expands canonical enabledModels entries with max thinking aliases", () => { + const registry = { + getCanonicalVariants: (canonicalId: string, options?: { candidates?: Model<"anthropic-messages">[] }) => + canonicalRegistry.getCanonicalVariants!(canonicalId, options), + }; + const result = filterAvailableModelsByEnabledPatterns( + canonicalVariantModels, + ["claude-sonnet-4-5:max"], + registry, + ); + expect(result.map(model => `${model.provider}/${model.id}`).sort()).toEqual([ + "anthropic/claude-sonnet-4-5", + "github-copilot/anthropic/claude-sonnet-4.5", + ]); + }); + test("strips :thinkingLevel suffix before matching", () => { const result = filterAvailableModelsByEnabledPatterns(models, ["anthropic/claude-sonnet-4-5:high"], registry); expect(result).toHaveLength(1); @@ -1240,6 +1470,11 @@ describe("filterAvailableModelsByEnabledPatterns", () => { expect(result).toHaveLength(1); expect(result[0].provider).toBe("anthropic"); }); + test("preserves literal :max in enabledModels globs", () => { + const result = filterAvailableModelsByEnabledPatterns(mockMaxSuffixModels, ["nanogpt/*:max"], registry); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("coding-router:max"); + }); test("evaluates glob patterns against bare model id", () => { const result = filterAvailableModelsByEnabledPatterns(models, ["claude-*"], registry); diff --git a/packages/coding-agent/test/modes/components/copy-selector.test.ts b/packages/coding-agent/test/modes/components/copy-selector.test.ts index 7b09f17be..83cf0e94f 100644 --- a/packages/coding-agent/test/modes/components/copy-selector.test.ts +++ b/packages/coding-agent/test/modes/components/copy-selector.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun: import { stripVTControlCharacters } from "node:util"; import { KeybindingsManager } from "@oh-my-pi/pi-coding-agent/config/keybindings"; import { CopySelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/copy-selector"; -import { getThemeByName, setThemeInstance } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import { getThemeByName, setThemeInstance, theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import type { CopyTarget } from "@oh-my-pi/pi-coding-agent/modes/utils/copy-targets"; import { setKeybindings } from "@oh-my-pi/pi-tui"; @@ -77,7 +77,7 @@ describe("CopySelectorComponent", () => { it("renders an outlined tree with code blocks nested under their message", () => { const out = render(new CopySelectorComponent(makeRoots(), { onPick: vi.fn(), onCancel: vi.fn() })); - expect(out).toContain("┌"); + expect(out).toContain(theme.boxRound.topLeft); expect(out).toContain("│"); expect(out).toContain("Copy to clipboard"); // Messages and their nested blocks are all visible (always expanded), diff --git a/packages/coding-agent/test/modes/components/oauth-selector.test.ts b/packages/coding-agent/test/modes/components/oauth-selector.test.ts index edfd5677a..0b161d66a 100644 --- a/packages/coding-agent/test/modes/components/oauth-selector.test.ts +++ b/packages/coding-agent/test/modes/components/oauth-selector.test.ts @@ -1,5 +1,6 @@ -import { beforeAll, describe, expect, it } from "bun:test"; +import { afterEach, beforeAll, describe, expect, it } from "bun:test"; import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { OAuthSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/oauth-selector"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; @@ -102,4 +103,88 @@ describe("OAuthSelectorComponent", () => { component.handleInput("\n"); expect(selected).toEqual(["opencode-go"]); }); + + describe("disabledProviders", () => { + afterEach(() => { + resetSettingsForTest(); + }); + + it("hides disabled providers from the login list even when searched", async () => { + const providers = getOAuthProviders(); + const victim = + providers.find(provider => provider.available && provider.id === "vllm") ?? + providers.find(provider => provider.available) ?? + providers[0]; + expect(victim).toBeDefined(); + if (!victim) return; + + resetSettingsForTest(); + await Settings.init({ inMemory: true, overrides: { disabledProviders: [victim.id] } }); + + const component = new OAuthSelectorComponent( + "login", + authStorage, + () => {}, + () => {}, + ); + for (const char of victim.id) { + component.handleInput(char); + } + const rendered = component + .render(80) + .map(line => Bun.stripANSI(line)) + .join("\n"); + expect(rendered).not.toContain(victim.name); + }); + + it("hides alias logins whose storeCredentialsAs target is disabled", async () => { + const alias = getOAuthProviders().find(provider => provider.storeCredentialsAs === "openai-codex"); + expect(alias).toBeDefined(); + if (!alias) return; + expect(alias.id).not.toBe("openai-codex"); + + resetSettingsForTest(); + await Settings.init({ inMemory: true, overrides: { disabledProviders: ["openai-codex"] } }); + + const component = new OAuthSelectorComponent( + "login", + authStorage, + () => {}, + () => {}, + ); + for (const char of alias.id) { + component.handleInput(char); + } + const rendered = component + .render(80) + .map(line => Bun.stripANSI(line)) + .join("\n"); + expect(rendered).not.toContain(alias.name); + }); + + it("keeps disabled providers as logout targets", async () => { + resetSettingsForTest(); + await Settings.init({ inMemory: true, overrides: { disabledProviders: ["opencode-go"] } }); + + const selected: string[] = []; + const component = new OAuthSelectorComponent( + "logout", + { + has: (providerId: string) => providerId === "opencode-go", + hasAuth: (providerId: string) => providerId === "opencode-go", + getCredentialOrigin: (_providerId: string) => undefined, + } as unknown as AuthStorage, + providerId => selected.push(providerId), + () => {}, + ); + for (const char of "opencode-go") { + component.handleInput(char); + } + const rendered = component + .render(80) + .map(line => Bun.stripANSI(line)) + .join("\n"); + expect(rendered).toContain("OpenCode Go"); + }); + }); }); diff --git a/packages/coding-agent/test/modes/components/plan-review-overlay.test.ts b/packages/coding-agent/test/modes/components/plan-review-overlay.test.ts index 609a17111..ef9841fd4 100644 --- a/packages/coding-agent/test/modes/components/plan-review-overlay.test.ts +++ b/packages/coding-agent/test/modes/components/plan-review-overlay.test.ts @@ -58,9 +58,9 @@ describe("PlanReviewOverlay", () => { for (const option of APPROVAL_OPTIONS) expect(out).toContain(option); expect(out).toContain("esc cancel"); // Outlined like the /copy overlay. - expect(out).toContain("┌"); + expect(out).toContain(theme.boxRound.topLeft); expect(out).toContain("│"); - expect(out).toContain("└"); + expect(out).toContain(theme.boxRound.bottomLeft); }); it("confirms the highlighted option on Enter", () => { diff --git a/packages/coding-agent/test/modes/components/settings-layout.test.ts b/packages/coding-agent/test/modes/components/settings-layout.test.ts index 1552953e6..f9fec2699 100644 --- a/packages/coding-agent/test/modes/components/settings-layout.test.ts +++ b/packages/coding-agent/test/modes/components/settings-layout.test.ts @@ -1,7 +1,9 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { SETTING_TABS, SETTINGS_SCHEMA, + type SettingPath, type SettingTab, TAB_GROUPS, } from "@oh-my-pi/pi-coding-agent/config/settings-schema"; @@ -13,6 +15,15 @@ interface UiShape { } describe("settings layout", () => { + beforeEach(async () => { + resetSettingsForTest(); + await Settings.init({ inMemory: true }); + }); + + afterEach(() => { + resetSettingsForTest(); + }); + it("every UI setting declares a group registered in TAB_GROUPS for its tab", () => { const violations: string[] = []; for (const path in SETTINGS_SCHEMA) { @@ -48,4 +59,21 @@ describe("settings layout", () => { expect(grouped).toEqual(expected); } }); + + it("hides advisor dependent settings when advisor is disabled", () => { + const advisorDependentPaths: SettingPath[] = ["advisor.subagents", "advisor.syncBacklog", "advisor.immuneTurns"]; + const advisorDependentPathSet = new Set(advisorDependentPaths); + const defs = getSettingsForTab("model").filter(def => advisorDependentPathSet.has(def.path)); + + expect(defs.map(def => def.path)).toEqual(advisorDependentPaths); + for (const def of defs) { + expect(def.condition?.()).toBe(false); + } + + Settings.instance.set("advisor.enabled", true); + + for (const def of defs) { + expect(def.condition?.()).toBe(true); + } + }); }); diff --git a/packages/coding-agent/test/read-summary.test.ts b/packages/coding-agent/test/read-summary.test.ts index c0c53ded5..0539b3fd9 100644 --- a/packages/coding-agent/test/read-summary.test.ts +++ b/packages/coding-agent/test/read-summary.test.ts @@ -72,8 +72,8 @@ describe("read summary", () => { const result = await tool.execute("read-summary-ts", { path: fixture }); const text = textOutput(result); - expect(text).toContain("export function alpha(value: string): string { .. }"); - expect(text).toContain("export function beta(): number { .. }"); + expect(text).toContain("export function alpha(value: string): string { … }"); + expect(text).toContain("export function beta(): number { … }"); expect(text).not.toContain("const clean = value.trim()"); expect(result.details?.summary?.elidedSpans).toBe(2); }); @@ -124,7 +124,7 @@ describe("read summary", () => { const text = textOutput(result); expect(text).toContain("const clean = 'alpha';"); - expect(text).not.toContain("..."); + expect(text).not.toContain("…"); expect(result.details?.summary).toBeUndefined(); }); @@ -197,10 +197,10 @@ describe("read summary", () => { expect(text).toContain("name: Ada"); }); - it("renders brace-pair elisions as a single numbered line with `..`", async () => { + it("renders brace-pair elisions as a single numbered line with `…`", async () => { // Regression for the read-tool format request: collapse the head / // elided / closing-brace sandwich into one numbered line of the form - // `START-END:head { .. }` instead of three separate lines. + // `START-END:head { … }` instead of three separate lines. const fixture = path.join(tmpDir, "merge.ts"); await fs.writeFile( fixture, @@ -211,9 +211,9 @@ describe("read summary", () => { const result = await tool.execute("read-summary-merge", { path: fixture }); const text = textOutput(result); - expect(text).toContain("export function stripNewLinePrefixes(lines: string[]): string[] { .. }"); - // The plain `...` ellipsis line must NOT appear once the merge fires. - expect(text).not.toContain("\n...\n"); + expect(text).toContain("export function stripNewLinePrefixes(lines: string[]): string[] { … }"); + // The plain `…` ellipsis line must NOT appear once the merge fires. + expect(text).not.toContain("\n…\n"); // The merged line must use the numbered range shape. expect(text).toMatch(/\b1-7:export function stripNewLinePrefixes/); expect(result.details?.summary?.elidedSpans).toBe(1); @@ -229,8 +229,8 @@ describe("read summary", () => { const result = await tool.execute("read-summary-merge-trailing", { path: fixture }); const text = textOutput(result); - expect(text).toContain("export const config = { .. };"); - expect(text).not.toContain("\n...\n"); + expect(text).toContain("export const config = { … };"); + expect(text).not.toContain("\n…\n"); }); it("does not merge when the closing line is not a bare brace", async () => { @@ -248,15 +248,15 @@ describe("read summary", () => { const text = textOutput(result); expect(text).toContain("def greet(name: str) -> str:"); - // Python's body elision keeps first/last body lines, so plain `...` + // Python's body elision keeps first/last body lines, so plain `…` // must remain as the elided segment. - expect(text).toContain("\n...\n"); - expect(text).not.toContain(" .. "); + expect(text).toContain("\n…\n"); + expect(text).not.toContain(" … "); }); it("appends an elision footer that names targeted recovery ranges", async () => { // Regression for issue #1046: summarized reads must tell the model how - // to recover the elided body so it does not stall on `...` / `{ .. }` + // to recover the elided body so it does not stall on `…` / `{ … }` // markers and burn a turn guessing the selector. const fixture = path.join(tmpDir, "footer.ts"); await fs.writeFile( @@ -270,7 +270,7 @@ describe("read summary", () => { expect(result.details?.summary?.elidedSpans).toBe(2); expect(result.details?.summary?.elidedLines).toBeGreaterThan(0); - expect(text).toContain("lines elided"); + expect(text).toContain("ln elided"); expect(text).toContain(`${fixture}:1-5,7-11`); expect(text).not.toContain(`${fixture}:raw`); expect(text).not.toContain(`${fixture}:1-9999`); diff --git a/packages/coding-agent/test/sdk-mcp-auto-discovery.test.ts b/packages/coding-agent/test/sdk-mcp-auto-discovery.test.ts index d60856fbe..654125252 100644 --- a/packages/coding-agent/test/sdk-mcp-auto-discovery.test.ts +++ b/packages/coding-agent/test/sdk-mcp-auto-discovery.test.ts @@ -151,4 +151,62 @@ describe("createAgentSession deferred MCP auto discovery", () => { expect(session.getActiveToolNames()).not.toContain("search_tool_bm25"); expect(session.isMCPDiscoveryEnabled()).toBe(false); }, 40_000); + + it("disconnects the owned MCP manager when a top-level session disposes", async () => { + writeMcpConfig(); + const { session, mcpManager } = await createAgentSession({ + ...baseOptions(), + toolNames: ["read", "edit", "bash"], + }); + expect(mcpManager).toBeDefined(); + if (!mcpManager) throw new Error("expected owning session to create an MCPManager"); + try { + // Let the deferred connect FINISH first, so a later disconnectAll can + // only originate from dispose() itself — not the mid-connect disposal + // path (covered by the test above). Genuine integration wait: discovery + // connects a real subprocess fire-and-forget with no awaitable signal, + // and fake timers cannot drive a child process; poll the live session + // with a generous ceiling, exiting the instant discovery flips on. + const deadline = Date.now() + 30_000; + while (!session.isMCPDiscoveryEnabled() && Date.now() < deadline) { + await Bun.sleep(50); + } + expect(session.isMCPDiscoveryEnabled()).toBe(true); + expect(mcpManager.getConnectedServers()).toContain("many"); + + const disconnectSpy = spyOn(mcpManager, "disconnectAll"); + await session.dispose(); + expect(disconnectSpy).toHaveBeenCalled(); + } finally { + // dispose() already tore it down; this is idempotent belt-and-braces. + await mcpManager.disconnectAll(); + } + }, 40_000); + + it("does not disconnect a reused parent MCP manager when a child session disposes", async () => { + writeMcpConfig(); + const parent = await createAgentSession({ + ...baseOptions(), + toolNames: ["read", "edit", "bash"], + }); + expect(parent.mcpManager).toBeDefined(); + if (!parent.mcpManager) throw new Error("expected parent session to create an MCPManager"); + const parentManager = parent.mcpManager; + try { + // A subagent-style session reuses the parent's manager via + // `mcpManager` and therefore does NOT own it. + const child = await createAgentSession({ + ...baseOptions(), + hasUI: false, + toolNames: ["read"], + mcpManager: parentManager, + }); + const disconnectSpy = spyOn(parentManager, "disconnectAll"); + await child.session.dispose(); + expect(disconnectSpy).not.toHaveBeenCalled(); + } finally { + await parent.session.dispose(); + await parentManager.disconnectAll(); + } + }, 40_000); }); diff --git a/packages/coding-agent/test/sdk-model-selection.test.ts b/packages/coding-agent/test/sdk-model-selection.test.ts index 6404ea206..af792bacb 100644 --- a/packages/coding-agent/test/sdk-model-selection.test.ts +++ b/packages/coding-agent/test/sdk-model-selection.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { Effort } from "@oh-my-pi/pi-ai"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; @@ -119,6 +120,19 @@ describe("createAgentSession deferred model pattern resolution", () => { expect(session.thinkingLevel).toBe("off"); }); + test("normalizes max default thinking level from settings", async () => { + const settings = Settings.isolated({ defaultThinkingLevel: "max" }); + + const { session } = await createAgentSession({ + ...(await buildSessionOptions("runtime-provider/runtime-reasoning-model")), + settings, + }); + + expect(session.model?.provider).toBe("runtime-provider"); + expect(session.model?.id).toBe("runtime-reasoning-model"); + expect(session.thinkingLevel).toBe(Effort.XHigh); + }); + test("selects the settings default model without synchronously validating auth", async () => { const defaultModel = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!defaultModel) { @@ -322,7 +336,7 @@ describe("createAgentSession deferred model pattern resolution", () => { } }); - test("restores role model from extension provider after startup resume", async () => { + test("restores role model max selector from extension provider after startup resume", async () => { const defaultModel = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!defaultModel) { throw new Error("Expected bundled anthropic default model"); @@ -351,7 +365,7 @@ describe("createAgentSession deferred model pattern resolution", () => { id: "smol-model", parentId: "default-model", timestamp, - model: "runtime-provider/runtime-model", + model: "runtime-provider/runtime-reasoning-model:max", role: "smol", }, ] @@ -380,7 +394,8 @@ describe("createAgentSession deferred model pattern resolution", () => { try { expect(session.model?.provider).toBe("runtime-provider"); - expect(session.model?.id).toBe("runtime-model"); + expect(session.model?.id).toBe("runtime-reasoning-model"); + expect(session.thinkingLevel).toBe(Effort.XHigh); } finally { await session.dispose(); authStorage.close(); diff --git a/packages/coding-agent/test/secrets-obfuscator.test.ts b/packages/coding-agent/test/secrets-obfuscator.test.ts index 94df47c88..89a8d7c19 100644 --- a/packages/coding-agent/test/secrets-obfuscator.test.ts +++ b/packages/coding-agent/test/secrets-obfuscator.test.ts @@ -153,3 +153,48 @@ describe("SecretObfuscator regex behavior", () => { expect(obfuscator.deobfuscateObject(obfuscated)).toEqual(messages); }); }); + +describe("SecretObfuscator cross-turn cache stability", () => { + // The provider prompt cache is content-addressed: convertToLlm / transformProviderContext + // re-run obfuscation over the WHOLE message array every turn, so a non-deterministic + // placeholder for the same secret would rewrite already-sent prefix bytes and bust the + // cache (cacheWrite @ $6.25/M vs cacheRead @ $0.50/M on opus). These tests pin the + // determinism that makes obfuscation cache-safe so a future change cannot silently + // reintroduce per-turn cache invalidation. + it("produces byte-identical output when re-obfuscating the same content across turns", () => { + const secret = "SUPER_SECRET_TOKEN_12345"; + const obfuscator = new SecretObfuscator([ + { type: "plain", content: secret }, + { type: "regex", content: "tok_[a-z0-9]+" }, + ]); + const messages: Message[] = [{ role: "user", content: `use ${secret} and tok_abc123`, timestamp: 1 }]; + + const turn1 = JSON.stringify(obfuscateMessages(obfuscator, messages)); + const turn2 = JSON.stringify(obfuscateMessages(obfuscator, messages)); + + expect(turn1).not.toContain(secret); + expect(turn1).not.toContain("tok_abc123"); + // Identical bytes on the second pass → the cached prefix stays valid. + expect(turn2).toEqual(turn1); + }); + + it("keeps earlier message placeholders stable when a later message reveals a new regex secret", () => { + const obfuscator = new SecretObfuscator([{ type: "regex", content: "tok_[a-z0-9]+" }]); + const early: Message[] = [{ role: "user", content: "first uses tok_aaa", timestamp: 1 }]; + + // Turn N: only the early message exists; tok_aaa mints a fresh placeholder. + const earlyTurnN = JSON.stringify(obfuscateMessages(obfuscator, early)); + expect(earlyTurnN).not.toContain("tok_aaa"); + + // A later turn reveals a brand-new secret. Lazy regex discovery assigns it a fresh + // index — this MUST NOT shift the placeholder already minted for tok_aaa. + const later: Message[] = [{ role: "user", content: "later uses tok_bbb", timestamp: 2 }]; + const laterOut = JSON.stringify(obfuscateMessages(obfuscator, later)); + expect(laterOut).not.toContain("tok_bbb"); + + // Re-obfuscate the early message after the new discovery: identical bytes → the + // already-cached prefix for the early message stays valid. + const earlyTurnNPlus1 = JSON.stringify(obfuscateMessages(obfuscator, early)); + expect(earlyTurnNPlus1).toEqual(earlyTurnN); + }); +}); diff --git a/packages/coding-agent/test/session-messages.test.ts b/packages/coding-agent/test/session-messages.test.ts index 14bfde91f..5d79b6aaf 100644 --- a/packages/coding-agent/test/session-messages.test.ts +++ b/packages/coding-agent/test/session-messages.test.ts @@ -195,7 +195,7 @@ describe("wrapSteeringForModel", () => { expect(wrappedText).not.toContain("&"); }); - it("leaves buried steering messages unchanged", () => { + it("wraps buried steering messages too so wire bytes stay stable across turns", () => { const buried: AgentMessage = { role: "user", content: "old steer", @@ -207,8 +207,16 @@ describe("wrapSteeringForModel", () => { const wrapped = wrapSteeringForModel(messages); - expect(wrapped).toBe(messages); - expect(wrapped[0]).toBe(buried); + // Buried steer is rewritten (enveloped) just like a trailing one, so its wire + // bytes are identical whether it is the tail (turn N) or buried (turn N+1) — + // the cached prefix stays valid instead of busting on the turn after a steer. + expect(wrapped).not.toBe(messages); + expect(wrapped[0]).not.toBe(buried); + expect(getUserText(wrapped[0])).toContain(""); + expect(getUserText(wrapped[0])).toContain("\nold steer\n"); + // Non-steering trailing message is untouched, and the persisted steer is not mutated. + expect(wrapped[1]).toBe(later); + expect(buried.content).toBe("old steer"); }); it("leaves trailing user messages without the steering marker unchanged", () => { diff --git a/packages/coding-agent/test/session/session-dump-format.test.ts b/packages/coding-agent/test/session/session-dump-format.test.ts index e715c104c..eb11c8ea8 100644 --- a/packages/coding-agent/test/session/session-dump-format.test.ts +++ b/packages/coding-agent/test/session/session-dump-format.test.ts @@ -10,6 +10,7 @@ import { describe, expect, it } from "bun:test"; import type { Model, Usage } from "@oh-my-pi/pi-ai"; import { formatSessionDumpText } from "@oh-my-pi/pi-coding-agent/session/session-dump-format"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import { type } from "arktype"; const ZERO_USAGE: Usage = { @@ -93,6 +94,39 @@ describe("formatSessionDumpText tool parameters", () => { expect(out).toContain(""); expect(out).toContain(''); }); + + it("omits the Available Tools section if inlineToolDescriptors is true", () => { + const out = formatSessionDumpText({ + messages: [], + inlineToolDescriptors: true, + tools: [ + { + name: "web_search", + description: "Searches the web.", + parameters: { type: "object" }, + }, + ], + }); + + expect(out).not.toContain("## Available Tools"); + }); + + it("does not falsely omit the Available Tools section even if systemPrompt contains tool headings", () => { + const out = formatSessionDumpText({ + messages: [], + systemPrompt: ["# Inventory\nThis is a rule discussing # Tool: web_search.\nNever call it directly."], + inlineToolDescriptors: false, + tools: [ + { + name: "web_search", + description: "Searches the web.", + parameters: { type: "object" }, + }, + ], + }); + + expect(out).toContain("## Available Tools"); + }); }); describe("formatSessionDumpText markdown-headings transcript", () => { @@ -109,7 +143,7 @@ describe("formatSessionDumpText markdown-headings transcript", () => { type: "toolCall", id: "c1", name: "read", - arguments: { _i: "Reading the file", path: "src/foo.ts" }, + arguments: { [INTENT_FIELD]: "Reading the file", path: "src/foo.ts" }, }, ], api: "mock", @@ -138,7 +172,7 @@ describe("formatSessionDumpText markdown-headings transcript", () => { expect(out).toContain("path: src/foo.ts"); // The `_i` intent renders as a `//` comment under the heading, never inside the YAML args. expect(out).toContain("// Reading the file"); - expect(out).not.toContain("_i:"); + expect(out).not.toContain(`${INTENT_FIELD}:`); // Tool calls render as a readable heading + YAML, never the / XML. expect(out).not.toContain(" { type: "toolCall", id: "tc-intent", name: "read", - arguments: { path: "src/config.ts", _i: "reading config file" }, + arguments: { path: "src/config.ts", [INTENT_FIELD]: "reading config file" }, }, { type: "toolCall", @@ -131,7 +132,8 @@ describe("formatSessionHistoryMarkdown", () => { name: "read", arguments: { path: "src/config.ts", - _i: "reading config file with a very very long and descriptive intent that will exceed the maximum length limit of eighty characters", + [INTENT_FIELD]: + "reading config file with a very very long and descriptive intent that will exceed the maximum length limit of eighty characters", }, }, ], diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index b7a1f7829..ebde01131 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -494,5 +494,102 @@ describe("Settings", () => { expect(fs.readFileSync(path.join(agentDir, "last-changelog-version"), "utf8")).toBe("0.41.0"); }); + + it("migrates from settings.json containing comments", async () => { + const jsonPath = path.join(agentDir, "settings.json"); + await fs.promises.writeFile( + jsonPath, + `{ + // This is a comment + "display": { + /* Multiline comment */ + "showTokenUsage": true + } + }`, + ); + + const settings = await Settings.init({ cwd: projectDir, agentDir }); + expect(settings.get("display.showTokenUsage")).toBe(true); + expect(fs.existsSync(jsonPath)).toBe(false); + expect(fs.existsSync(`${jsonPath}.bak`)).toBe(true); + }); + it("migrates legacy power booleans with system=true to system level", async () => { + await writeSettings({ + power: { + preventIdleSleep: true, + preventSystemSleep: true, + declareUserActive: false, + preventDisplaySleep: false, + }, + }); + const settings = await Settings.init({ cwd: projectDir, agentDir }); + expect(settings.get("power.sleepPrevention")).toBe("system"); + }); + + it("migrates legacy power booleans with display=true to display level", async () => { + await writeSettings({ + power: { + preventIdleSleep: true, + preventSystemSleep: false, + declareUserActive: false, + preventDisplaySleep: true, + }, + }); + const settings = await Settings.init({ cwd: projectDir, agentDir }); + expect(settings.get("power.sleepPrevention")).toBe("display"); + }); + + it("migrates legacy power booleans with declareUserActive=true to system level", async () => { + await writeSettings({ + power: { + preventIdleSleep: true, + preventSystemSleep: false, + declareUserActive: true, + preventDisplaySleep: false, + }, + }); + const settings = await Settings.init({ cwd: projectDir, agentDir }); + expect(settings.get("power.sleepPrevention")).toBe("system"); + }); + + it("preserves old idle default when only non-idle keys are set", async () => { + // Old default was preventIdleSleep=true; user only set display=false. + // Migration should yield "idle", not "off". + await writeSettings({ + power: { preventDisplaySleep: false }, + }); + const settings = await Settings.init({ cwd: projectDir, agentDir }); + expect(settings.get("power.sleepPrevention")).toBe("idle"); + }); + + it("migrates all-false power booleans to off", async () => { + await writeSettings({ + power: { + preventIdleSleep: false, + preventSystemSleep: false, + declareUserActive: false, + preventDisplaySleep: false, + }, + }); + const settings = await Settings.init({ cwd: projectDir, agentDir }); + expect(settings.get("power.sleepPrevention")).toBe("off"); + }); + + it("migrates flat-key power booleans to the enum", async () => { + await writeSettings({ + "power.preventIdleSleep": true, + "power.preventDisplaySleep": true, + }); + const settings = await Settings.init({ cwd: projectDir, agentDir }); + expect(settings.get("power.sleepPrevention")).toBe("display"); + }); + + it("does not overwrite an explicit power.sleepPrevention", async () => { + await writeSettings({ + power: { sleepPrevention: "off", preventIdleSleep: true }, + }); + const settings = await Settings.init({ cwd: projectDir, agentDir }); + expect(settings.get("power.sleepPrevention")).toBe("off"); + }); }); }); diff --git a/packages/coding-agent/test/shake.test.ts b/packages/coding-agent/test/shake.test.ts index c9fa94b39..3ae2034aa 100644 --- a/packages/coding-agent/test/shake.test.ts +++ b/packages/coding-agent/test/shake.test.ts @@ -192,6 +192,52 @@ describe("AgentSession shake", () => { expect(end[0]).toMatchObject({ type: "auto_compaction_end", action: "shake" }); }); + it("has isCompacting true when the shake auto_compaction_start event fires", async () => { + // Defect 1 parity for the shake strategy: the controller backing isCompacting + // must be installed before auto_compaction_start is emitted, so a message + // typed as the loader appears is queued safely rather than mis-routed. + session.settings.set("compaction.strategy", "shake"); + session.settings.set("compaction.thresholdPercent", 1); + session.settings.set("contextPromotion.enabled", false); + + let capturedIsCompacting: boolean | undefined; + const { promise: shakeStarted, resolve: onShakeStarted } = Promise.withResolvers(); + session.subscribe(event => { + if (event.type === "auto_compaction_start" && event.action === "shake") { + capturedIsCompacting = session.isCompacting; + onShakeStarted(); + } + }); + + vi.spyOn(session, "shake").mockResolvedValue({ + mode: "elide", + toolResultsDropped: 1, + blocksDropped: 0, + tokensFreed: 10_000, + }); + + const assistantMessage: AssistantMessage = { + role: "assistant", + content: [{ type: "text", text: "trigger" }], + ...apiInfo, + stopReason: "stop", + usage: { + input: 10_000, + output: 1_000, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 11_000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + timestamp: Date.now(), + }; + session.agent.emitExternalEvent({ type: "message_end", message: assistantMessage }); + session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMessage] }); + await shakeStarted; + + expect(capturedIsCompacting).toBe(true); + }); + it("falls back to context-full when shake cannot drop context below the threshold (regression #2119)", async () => { session.settings.set("compaction.strategy", "shake"); session.settings.set("compaction.thresholdPercent", 1); diff --git a/packages/coding-agent/test/silent-abort-overlay-render.test.ts b/packages/coding-agent/test/silent-abort-overlay-render.test.ts index e99a6ccf2..5e1bb0f9f 100644 --- a/packages/coding-agent/test/silent-abort-overlay-render.test.ts +++ b/packages/coding-agent/test/silent-abort-overlay-render.test.ts @@ -12,11 +12,12 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { AgentHubOverlayComponent } from "@oh-my-pi/pi-coding-agent/modes/components/agent-hub"; +import { AgentTranscriptViewer } from "@oh-my-pi/pi-coding-agent/modes/components/agent-transcript-viewer"; import type { ObservableSession } from "@oh-my-pi/pi-coding-agent/modes/session-observer-registry"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; import { SILENT_ABORT_MARKER } from "@oh-my-pi/pi-coding-agent/session/messages"; +import type { TUI } from "@oh-my-pi/pi-tui"; const SESSION_ID = "test-session-1"; @@ -36,7 +37,7 @@ function makeSubagentRegistry(sessions: ObservableSession[]) { } as unknown as import("@oh-my-pi/pi-coding-agent/modes/session-observer-registry").SessionObserverRegistry; } -function makeHub(sessionFile: string, observed: ObservableSession[]): AgentHubOverlayComponent { +function makeViewer(sessionFile: string, observed: ObservableSession[]): AgentTranscriptViewer { const agents = new AgentRegistry(); agents.register({ id: SESSION_ID, @@ -47,15 +48,19 @@ function makeHub(sessionFile: string, observed: ObservableSession[]): AgentHubOv sessionFile, status: "parked", }); - const hub = new AgentHubOverlayComponent({ - observers: makeSubagentRegistry(observed), - hubKeys: ["ctrl+s"], - onDone: () => {}, - requestRender: () => {}, + const ui = { requestRender: () => {}, requestComponentRender: () => {} } as unknown as TUI; + return new AgentTranscriptViewer({ + agentId: SESSION_ID, registry: agents, + observers: makeSubagentRegistry(observed), + ui, + cwd: path.dirname(sessionFile), + expandKeys: ["ctrl+o"], + hubKeys: ["ctrl+s"], + requestRender: () => {}, + onClose: () => {}, + onHubClose: () => {}, }); - hub.openChat(SESSION_ID); - return hub; } describe("Agent hub silent-abort regression", () => { @@ -112,7 +117,7 @@ describe("Agent hub silent-abort regression", () => { }, ]); - const hub = makeHub(sessionFile, [ + const viewer = makeViewer(sessionFile, [ { id: SESSION_ID, kind: "subagent", @@ -123,10 +128,8 @@ describe("Agent hub silent-abort regression", () => { }, ]); - // Render with a reasonable width — the hub chat view reads the session - // file and calls #buildTranscriptLines internally. - const rendered = hub.render(120); - hub.dispose(); + const rendered = viewer.render(120); + viewer.dispose(); const renderedText = rendered.join("\n"); // The sentinel MUST NOT appear verbatim in any rendered line @@ -171,7 +174,7 @@ describe("Agent hub silent-abort regression", () => { }, ]); - const hub = makeHub(sessionFile, [ + const viewer = makeViewer(sessionFile, [ { id: SESSION_ID, kind: "subagent", @@ -182,8 +185,8 @@ describe("Agent hub silent-abort regression", () => { }, ]); - const rendered = hub.render(120); - hub.dispose(); + const rendered = viewer.render(120); + viewer.dispose(); const renderedText = rendered.join("\n"); // AssistantMessageComponent renders the error as "Error: " diff --git a/packages/coding-agent/test/slash-commands/collab-qrcode.test.ts b/packages/coding-agent/test/slash-commands/collab-qrcode.test.ts new file mode 100644 index 000000000..8a63482d0 --- /dev/null +++ b/packages/coding-agent/test/slash-commands/collab-qrcode.test.ts @@ -0,0 +1,154 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; +import { CollabHost } from "@oh-my-pi/pi-coding-agent/collab/host"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; +import { + type BuiltinSlashCommandRuntime, + executeBuiltinSlashCommand, +} from "@oh-my-pi/pi-coding-agent/slash-commands/builtin-registry"; +import { CollabQrCodeComponent } from "@oh-my-pi/pi-coding-agent/slash-commands/helpers/collab-qrcode"; +import { Spacer } from "@oh-my-pi/pi-tui"; + +beforeAll(async () => { + resetSettingsForTest(); + await Settings.init({ inMemory: true }); + await initTheme(false); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +afterAll(() => { + resetSettingsForTest(); +}); + +function fakeHost(options?: { + webLink?: string; + webViewLink?: string; +}): NonNullable { + return { + link: "relay.example.com/r/full-control", + viewLink: "relay.example.com/r/read-only", + webLink: options?.webLink ?? "https://my.omp.sh/#full-control", + webViewLink: options?.webViewLink ?? "https://my.omp.sh/#read-only", + participants: [{ name: "host", role: "host" }], + } as unknown as NonNullable; +} + +function createRuntimeHarness(options?: { collabHost?: NonNullable }) { + const setText = vi.fn(); + const showStatus = vi.fn(); + const showError = vi.fn(); + const present = vi.fn(); + const settingsGet = vi.fn((key: string) => { + if (key === "collab.relayUrl") return "wss://relay.example.com"; + if (key === "collab.webUrl") return ""; + return ""; + }); + const ctx = { + editor: { setText }, + showStatus, + showError, + present, + settings: { get: settingsGet }, + collabHost: options?.collabHost, + } as unknown as InteractiveModeContext; + return { + ctx, + setText, + showStatus, + showError, + present, + runtime: { ctx } as BuiltinSlashCommandRuntime, + }; +} + +function mockStartedHostLinks() { + return vi.spyOn(CollabHost.prototype, "start").mockImplementation(function (this: CollabHost): Promise { + Object.defineProperties(this, { + link: { value: "relay.example.com/r/full-control", configurable: true }, + viewLink: { value: "relay.example.com/r/read-only", configurable: true }, + webLink: { value: "https://my.omp.sh/#started-full", configurable: true }, + webViewLink: { value: "https://my.omp.sh/#started-view", configurable: true }, + participants: { value: [{ name: "host", role: "host" as const }], configurable: true }, + }); + return Promise.resolve(); + }); +} + +describe("/collab slash command QR code rendering", () => { + it("starts hosting and prints a one-shot full-control QR", async () => { + const startSpy = mockStartedHostLinks(); + const harness = createRuntimeHarness(); + + const handled = await executeBuiltinSlashCommand("/collab", harness.runtime); + + expect(handled).toBe(true); + expect(harness.setText).toHaveBeenCalledWith(""); + expect(startSpy).toHaveBeenCalledWith("wss://relay.example.com", ""); + expect(harness.ctx.collabHost).toBeInstanceOf(CollabHost); + const statusText = harness.showStatus.mock.calls[0]?.[0] as string; + expect(statusText).toContain("my.omp.sh/#started-full"); + const presented = harness.present.mock.calls[0]?.[0] as readonly unknown[]; + expect(presented[0]).toBeInstanceOf(Spacer); + expect(presented[1]).toBeInstanceOf(CollabQrCodeComponent); + const component = presented[1] as CollabQrCodeComponent; + expect(component.url).toBe("https://my.omp.sh/#started-full"); + expect(component.render(120).join("\n")).toMatch(/\x1b\[(?:47|40)m/); + }); + + it("starts hosting and prints a one-shot read-only QR", async () => { + const startSpy = mockStartedHostLinks(); + const harness = createRuntimeHarness(); + + const handled = await executeBuiltinSlashCommand("/collab view", harness.runtime); + + expect(handled).toBe(true); + expect(startSpy).toHaveBeenCalledWith("wss://relay.example.com", ""); + expect(harness.ctx.collabHost).toBeInstanceOf(CollabHost); + const statusText = harness.showStatus.mock.calls[0]?.[0] as string; + expect(statusText).toContain("my.omp.sh/#started-view"); + expect(statusText).not.toContain("my.omp.sh/#started-full"); + const presented = harness.present.mock.calls[0]?.[0] as readonly unknown[]; + expect(presented[0]).toBeInstanceOf(Spacer); + expect(presented[1]).toBeInstanceOf(CollabQrCodeComponent); + const component = presented[1] as CollabQrCodeComponent; + expect(component.url).toBe("https://my.omp.sh/#started-view"); + }); + + it("prints the active full-control browser QR when hosting", async () => { + const harness = createRuntimeHarness({ collabHost: fakeHost() }); + + const handled = await executeBuiltinSlashCommand("/collab", harness.runtime); + + expect(handled).toBe(true); + const statusText = harness.showStatus.mock.calls[0]?.[0] as string; + expect(statusText).toContain("my.omp.sh/#full-control"); + const presented = harness.present.mock.calls[0]?.[0] as readonly unknown[]; + expect(presented[0]).toBeInstanceOf(Spacer); + expect(presented[1]).toBeInstanceOf(CollabQrCodeComponent); + const component = presented[1] as CollabQrCodeComponent; + expect(component.render(120).join("\n")).toMatch(/\x1b\[(?:47|40)m/); + }); + + it("prints a one-shot read-only browser QR when hosting", async () => { + const webLink = "https://my.omp.sh/#full-control"; + const webViewLink = "https://my.omp.sh/#read-only"; + const harness = createRuntimeHarness({ collabHost: fakeHost({ webLink, webViewLink }) }); + + const handled = await executeBuiltinSlashCommand("/collab view", harness.runtime); + + expect(handled).toBe(true); + const statusText = harness.showStatus.mock.calls[0]?.[0] as string; + expect(statusText).toContain(webViewLink); + expect(statusText).not.toContain(webLink); + const presented = harness.present.mock.calls[0]?.[0] as readonly unknown[]; + expect(presented[0]).toBeInstanceOf(Spacer); + expect(presented[1]).toBeInstanceOf(CollabQrCodeComponent); + const component = presented[1] as CollabQrCodeComponent; + expect(component.url).toBe(webViewLink); + expect(component.render(10).join("\n")).toContain("QR code hidden"); + }); +}); diff --git a/packages/coding-agent/test/slash-commands/compact.test.ts b/packages/coding-agent/test/slash-commands/compact.test.ts new file mode 100644 index 000000000..b301be01a --- /dev/null +++ b/packages/coding-agent/test/slash-commands/compact.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from "bun:test"; +import type { CompactOptions } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/types"; +import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; +import type { CompactMode } from "@oh-my-pi/pi-coding-agent/session/compact-modes"; +import { + ACP_BUILTIN_SLASH_COMMANDS, + executeAcpBuiltinSlashCommand, +} from "@oh-my-pi/pi-coding-agent/slash-commands/acp-builtins"; +import { executeBuiltinSlashCommand } from "@oh-my-pi/pi-coding-agent/slash-commands/builtin-registry"; +import type { SlashCommandRuntime } from "@oh-my-pi/pi-coding-agent/slash-commands/types"; + +function acpRuntime() { + const compact = vi.fn(async (_instructions?: string, _options?: CompactOptions) => {}); + const getContextUsage = vi.fn(() => undefined); + const output = vi.fn(); + const runtime = { session: { compact, getContextUsage }, output } as unknown as SlashCommandRuntime; + return { compact, output, runtime }; +} + +function tuiRuntime() { + const handleCompactCommand = vi.fn(async () => "ok" as const); + const setText = vi.fn(); + const showWarning = vi.fn(); + const runtime = { + ctx: { + editor: { setText } as unknown as InteractiveModeContext["editor"], + handleCompactCommand, + showWarning, + } as unknown as InteractiveModeContext, + }; + return { handleCompactCommand, setText, showWarning, runtime }; +} + +describe("/compact dispatch (ACP)", () => { + it("compacts with the configured strategy and no mode for a bare invocation", async () => { + const h = acpRuntime(); + await executeAcpBuiltinSlashCommand("/compact", h.runtime); + expect(h.compact).toHaveBeenCalledWith(undefined, undefined); + }); + + it("threads each mode subcommand into compact()", async () => { + for (const mode of ["soft", "remote", "snapcompact"] as const satisfies readonly CompactMode[]) { + const h = acpRuntime(); + await executeAcpBuiltinSlashCommand(`/compact ${mode}`, h.runtime); + expect(h.compact).toHaveBeenCalledWith(undefined, { mode }); + } + }); + + it("splits a mode from its focus instructions", async () => { + const h = acpRuntime(); + await executeAcpBuiltinSlashCommand("/compact soft focus on the parser", h.runtime); + expect(h.compact).toHaveBeenCalledWith("focus on the parser", { mode: "soft" }); + }); + + it("treats a non-mode argument as plain focus instructions (backward compatible)", async () => { + const h = acpRuntime(); + await executeAcpBuiltinSlashCommand("/compact summarize the auth flow", h.runtime); + expect(h.compact).toHaveBeenCalledWith("summarize the auth flow", undefined); + }); + + it("rejects focus text on snapcompact without compacting", async () => { + const h = acpRuntime(); + const result = await executeAcpBuiltinSlashCommand("/compact snapcompact keep the diffs", h.runtime); + expect(h.compact).not.toHaveBeenCalled(); + expect(result).toEqual({ consumed: true }); + expect((h.output.mock.calls[0]?.[0] as string) ?? "").toContain("snapcompact"); + }); + + it("advertises the mode subcommands and input hint to ACP clients", () => { + const advertised = ACP_BUILTIN_SLASH_COMMANDS.find(c => c.name === "compact"); + expect(advertised).toBeDefined(); + expect(advertised?.input?.hint).toBe("[soft|remote|snapcompact] [focus]"); + }); +}); + +describe("/compact dispatch (TUI)", () => { + it("routes mode + focus to handleCompactCommand and clears the editor", async () => { + const h = tuiRuntime(); + const handled = await executeBuiltinSlashCommand("/compact soft fix the bug", h.runtime); + expect(handled).toBe(true); + expect(h.setText).toHaveBeenCalledWith(""); + expect(h.handleCompactCommand).toHaveBeenCalledWith("fix the bug", "soft"); + }); + + it("passes no mode for a bare /compact", async () => { + const h = tuiRuntime(); + await executeBuiltinSlashCommand("/compact", h.runtime); + expect(h.handleCompactCommand).toHaveBeenCalledWith(undefined, undefined); + }); + + it("warns on snapcompact + focus text and does not compact", async () => { + const h = tuiRuntime(); + await executeBuiltinSlashCommand("/compact snapcompact keep diffs", h.runtime); + expect(h.handleCompactCommand).not.toHaveBeenCalled(); + expect(h.showWarning).toHaveBeenCalled(); + }); +}); diff --git a/packages/coding-agent/test/snapcompact-inline.test.ts b/packages/coding-agent/test/snapcompact-inline.test.ts index 024edca4b..7d5d8b65d 100644 --- a/packages/coding-agent/test/snapcompact-inline.test.ts +++ b/packages/coding-agent/test/snapcompact-inline.test.ts @@ -310,46 +310,6 @@ describe("SnapcompactInlineTransformer", () => { expect(result.messages[4]).toBe(context.messages[4]); }); - it("stops swapping at OpenRouter's hard 8-image cap; later tools ship verbatim", () => { - const transformer = new SnapcompactInlineTransformer( - withTestShape({ renderSystemPrompt: "none", renderToolResults: true }), - ); - const context: Context = { - messages: [ - userMessage("go"), - toolResult("call_1", LARGE), - toolResult("call_2", LARGE), - toolResult("call_3", LARGE), - toolResult("call_4", LARGE), - toolResult("call_5", LARGE), - toolResult("call_6", LARGE), - ], - }; - // OpenRouter budget 8; each LARGE needs 2 frames: call_1..4 swap (8 - // frames), call_5 no longer fits → verbatim; call_6 is the most - // recent and always stays text. - const result = transformer.transform(context, makeModel({ provider: "openrouter" })); - expect(imageCount(result)).toBe(8); - expect(result.messages[5]).toBe(context.messages[5]); - expect(result.messages[6]).toBe(context.messages[6]); - }); - - it("is a no-op when existing images (e.g. compaction frames) exhaust the OpenRouter cap", () => { - const transformer = new SnapcompactInlineTransformer( - withTestShape({ renderSystemPrompt: "none", renderToolResults: true }), - ); - const png: ImageContent = { type: "image", data: "ZmFrZQ==", mimeType: "image/png" }; - const context: Context = { - messages: [ - { role: "user", content: [{ type: "text", text: "summary" }, ...Array(8).fill(png)], timestamp: 0 }, - toolResult("call_1", LARGE), - toolResult("call_2", LARGE), - ], - }; - const result = transformer.transform(context, makeModel({ provider: "openrouter" })); - expect(result).toBe(context); - }); - it("caches renders across turns: identical input does not re-rasterize", () => { const spy = spyOn(snapcompact, "renderMany"); try { diff --git a/packages/coding-agent/test/streaming-output.test.ts b/packages/coding-agent/test/streaming-output.test.ts index 1a682417e..eddda058c 100644 --- a/packages/coding-agent/test/streaming-output.test.ts +++ b/packages/coding-agent/test/streaming-output.test.ts @@ -560,9 +560,10 @@ describe("truncateMiddle", () => { expect(result.content).not.toContain("elided"); }); - test("formatMiddleElisionMarker pluralises and formats bytes", () => { - expect(formatMiddleElisionMarker(1, 100)).toBe("[… 1 line elided (100B) …]"); - expect(formatMiddleElisionMarker(123, 4096)).toBe("[… 123 lines elided (4.0KB) …]"); + test("formatMiddleElisionMarker uses lines, falling back to bytes for <=1 line", () => { + expect(formatMiddleElisionMarker(0, 512)).toBe("[…512B elided…]"); + expect(formatMiddleElisionMarker(1, 100)).toBe("[…100B elided…]"); + expect(formatMiddleElisionMarker(123, 4096)).toBe("[…123ln elided…]"); }); }); diff --git a/packages/coding-agent/test/streaming-preview-height.test.ts b/packages/coding-agent/test/streaming-preview-height.test.ts index a30867f5b..2c73301dd 100644 --- a/packages/coding-agent/test/streaming-preview-height.test.ts +++ b/packages/coding-agent/test/streaming-preview-height.test.ts @@ -386,11 +386,11 @@ describe("streaming tool call preview height (bounded across renderers)", () => const width = 80; const { lines } = renderPending("bash", { command: "echo hi" }); const strippedLines = lines.map(line => Bun.stripANSI(line)); - const topBorder = strippedLines.find(line => line.includes(activeTheme.boxSharp.topLeft)); + const topBorder = strippedLines.find(line => line.includes(activeTheme.boxRound.topLeft)); expect(topBorder).toBeDefined(); - expect(topBorder?.[0]).toBe(activeTheme.boxSharp.topLeft); - expect(topBorder?.endsWith(activeTheme.boxSharp.topRight)).toBe(true); + expect(topBorder?.[0]).toBe(activeTheme.boxRound.topLeft); + expect(topBorder?.endsWith(activeTheme.boxRound.topRight)).toBe(true); expect(visibleWidth(topBorder ?? "")).toBe(width); }); diff --git a/packages/coding-agent/test/system-prompt-inventory.test.ts b/packages/coding-agent/test/system-prompt-inventory.test.ts index bee6e3f61..d19fb8fb5 100644 --- a/packages/coding-agent/test/system-prompt-inventory.test.ts +++ b/packages/coding-agent/test/system-prompt-inventory.test.ts @@ -46,7 +46,7 @@ describe("system prompt tool inventory", () => { afterEach(cleanupTempHome(() => ({ tempDir, tempHomeDir, originalHome }))); - async function render(opts: { nativeTools: boolean; repeatToolDescriptions: boolean }): Promise { + async function render(opts: { nativeTools: boolean; inlineToolDescriptors: boolean }): Promise { const { systemPrompt } = await buildSystemPrompt({ cwd: tempDir, contextFiles: [], @@ -55,13 +55,14 @@ describe("system prompt tool inventory", () => { toolNames: ["read", "bash"], tools: TOOLS, workspaceTree: { ...EMPTY_TREE, rootPath: tempDir }, - ...opts, + nativeTools: opts.nativeTools, + inlineToolDescriptors: opts.inlineToolDescriptors, }); return systemPrompt.join("\n\n"); } - it("renders a compact name list only when native tools are active and descriptions are not repeated", async () => { - const text = await render({ nativeTools: true, repeatToolDescriptions: false }); + it("renders a compact name list only when native tools are active and descriptors stay in schemas", async () => { + const text = await render({ nativeTools: true, inlineToolDescriptors: false }); expect(text).toContain("- Read: `read`"); expect(text).toContain("- Bash: `bash`"); // No full per-tool sections in list mode. @@ -70,7 +71,7 @@ describe("system prompt tool inventory", () => { }); it("renders `# Tool:` sections (not a name list) when tools are not native", async () => { - const text = await render({ nativeTools: false, repeatToolDescriptions: false }); + const text = await render({ nativeTools: false, inlineToolDescriptors: false }); expect(text).toContain("# Tool: read"); expect(text).toContain("# Tool: bash"); expect(text).toContain("Reads files from disk."); @@ -79,8 +80,8 @@ describe("system prompt tool inventory", () => { expect(text).not.toContain(" { - const text = await render({ nativeTools: true, repeatToolDescriptions: true }); + it("renders `# Tool:` sections when descriptors are inlined even with native tools", async () => { + const text = await render({ nativeTools: true, inlineToolDescriptors: true }); expect(text).toContain("# Tool: read"); expect(text).toContain("Executes a shell command."); expect(text).not.toContain("- Read: `read`"); @@ -112,7 +113,7 @@ describe("system prompt tool inventory", () => { }); it("places the inventory at the bottom of the TOOLS section (after I/O and Exploration)", async () => { - const text = await render({ nativeTools: true, repeatToolDescriptions: false }); + const text = await render({ nativeTools: true, inlineToolDescriptors: false }); const inventoryIdx = text.indexOf("# Inventory"); const ioIdx = text.indexOf("# I/O"); const explorationIdx = text.indexOf("# Exploration"); diff --git a/packages/coding-agent/test/task/output-manager.test.ts b/packages/coding-agent/test/task/output-manager.test.ts index b374105fe..de33b13ad 100644 --- a/packages/coding-agent/test/task/output-manager.test.ts +++ b/packages/coding-agent/test/task/output-manager.test.ts @@ -65,4 +65,13 @@ describe("AgentOutputManager", () => { expect(await mgr.allocate("Bob")).toBe("Anna.Bob-2"); expect(await mgr.allocate("Dave")).toBe("Anna.Dave"); }); + + it("reserves the advisor transcript stem so a task can't clobber __advisor.jsonl", async () => { + const mgr = new AgentOutputManager(() => null); + // A subagent allocated `__advisor` would write `__advisor.jsonl`, colliding with + // the advisor transcript in the same artifacts dir; the stem is pre-reserved. + expect(await mgr.allocate("__advisor")).toBe("__advisor-2"); + // Unrelated names sharing the prefix are unaffected. + expect(await mgr.allocate("__advisor-notes")).toBe("__advisor-notes"); + }); }); diff --git a/packages/coding-agent/test/task/render-call.test.ts b/packages/coding-agent/test/task/render-call.test.ts index 4a0c03425..b0d05118c 100644 --- a/packages/coding-agent/test/task/render-call.test.ts +++ b/packages/coding-agent/test/task/render-call.test.ts @@ -124,7 +124,7 @@ describe("task renderer: streaming call preview", () => { expect(out).toContain("Shared brief."); // Interior divider bars start with the tee glyph; only the header (top) // and bottom border may exist. - const tee = theme.boxSharp.teeRight; + const tee = theme.boxRound.teeRight; expect(lines.filter(line => line.trimStart().startsWith(tee))).toHaveLength(0); }); diff --git a/packages/coding-agent/test/tools/approval.test.ts b/packages/coding-agent/test/tools/approval.test.ts index 11f64bc01..65a6e4d8b 100644 --- a/packages/coding-agent/test/tools/approval.test.ts +++ b/packages/coding-agent/test/tools/approval.test.ts @@ -145,7 +145,7 @@ describe("MCP fallback and prompt formatting", () => { it("truncates prompt details without touching short strings", () => { expect(truncateForPrompt("hello", 10)).toBe("hello"); - expect(truncateForPrompt("abcdefgh", 5)).toBe("abcde… (3 chars truncated)"); + expect(truncateForPrompt("abcdefgh", 5)).toBe("abcde[…3ch elided…]"); }); }); diff --git a/packages/coding-agent/test/tools/ast-edit.test.ts b/packages/coding-agent/test/tools/ast-edit.test.ts index 64bf976a6..5bd45a194 100644 --- a/packages/coding-agent/test/tools/ast-edit.test.ts +++ b/packages/coding-agent/test/tools/ast-edit.test.ts @@ -113,9 +113,8 @@ describe("ast_edit tool schema", () => { expect(previewResult.details).toBeDefined(); expect((previewResult.details as { applied?: boolean }).applied).toBe(false); - expect(queue.inspect().some(l => l.startsWith("pending-action:ast_edit"))).toBe(true); - queue.nextToolChoice(); - const invoker = queue.peekInFlightInvoker()!; + expect(queue.hasPendingInvoker).toBe(true); + const invoker = queue.peekPendingInvoker()!; const applyResult = (await invoker({ action: "apply", reason: "apply previewed AST edit", @@ -160,8 +159,7 @@ describe("ast_edit tool schema", () => { const mutatedContent = "otherWrap(x, value)\n"; await Bun.write(filePath, mutatedContent); - queue.nextToolChoice(); - const invoker = queue.peekInFlightInvoker()!; + const invoker = queue.peekPendingInvoker()!; const applyResult = (await invoker({ action: "apply", reason: "apply stale preview" })) as InvokedToolResult; const applyText = applyResult.content.find(content => content.type === "text")?.text ?? ""; @@ -225,8 +223,7 @@ describe("ast_edit tool schema", () => { ]), ); - queue.nextToolChoice(); - const invoker = queue.peekInFlightInvoker()!; + const invoker = queue.peekPendingInvoker()!; await invoker({ action: "apply", reason: "apply previewed AST edit with combined globs" }); expect(await Bun.file(path.join(sourceDir, "root.ts")).text()).toContain("modernWrap(rootValue, rootArg)"); @@ -270,8 +267,7 @@ describe("ast_edit tool schema", () => { expect(details?.totalReplacements).toBe(1); expect(details?.parseErrors).toBeUndefined(); - queue.nextToolChoice(); - const invoker = queue.peekInFlightInvoker()!; + const invoker = queue.peekPendingInvoker()!; await invoker({ action: "apply", reason: "apply tlaplus AST edit" }); expect(await Bun.file(filePath).text()).toContain("Start == x = 0"); } finally { diff --git a/packages/coding-agent/test/tools/eval-agent-progress.test.ts b/packages/coding-agent/test/tools/eval-agent-progress.test.ts index 0b2e4c134..3b95e81f1 100644 --- a/packages/coding-agent/test/tools/eval-agent-progress.test.ts +++ b/packages/coding-agent/test/tools/eval-agent-progress.test.ts @@ -51,7 +51,7 @@ describe("eval renderer: agent() progress below the cell box", () => { /** Index of the box's closing border (bottom-right corner glyph). */ function boxBottomIndex(lines: string[]): number { - return lines.findIndex(line => line.includes(theme.boxSharp.bottomRight)); + return lines.findIndex(line => line.includes(theme.boxRound.bottomRight)); } it("draws a running subagent below the box with its current tool and intent", () => { diff --git a/packages/coding-agent/test/tools/eval-display-text.test.ts b/packages/coding-agent/test/tools/eval-display-text.test.ts index a8e74f22c..238c81087 100644 --- a/packages/coding-agent/test/tools/eval-display-text.test.ts +++ b/packages/coding-agent/test/tools/eval-display-text.test.ts @@ -176,7 +176,7 @@ describe("EvalTool display() text surfacing", () => { }); const text = result.content.map(c => (c.type === "text" ? c.text : "")).join("\n"); - expect(text).toContain("chars truncated"); + expect(text).toContain("ch elided"); expect(text.length).toBeLessThan(20000); }); }); diff --git a/packages/coding-agent/test/tools/inspect-image.test.ts b/packages/coding-agent/test/tools/inspect-image.test.ts index c01fa906f..f3149ab54 100644 --- a/packages/coding-agent/test/tools/inspect-image.test.ts +++ b/packages/coding-agent/test/tools/inspect-image.test.ts @@ -2,10 +2,13 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import type { completeSimple, Model } from "@oh-my-pi/pi-ai"; +import { AuthStorage, type completeSimple, type ImageContent, type Model } from "@oh-my-pi/pi-ai"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; +import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { getThemeByName } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import { createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { InspectImageTool } from "@oh-my-pi/pi-coding-agent/tools/inspect-image"; import { inspectImageToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/inspect-image-renderer"; @@ -39,6 +42,7 @@ interface CreateSessionOptions { availableModels?: Model<"openai-responses">[]; activeModel?: Model<"openai-responses">; configureVisionRole?: boolean; + imageAttachments?: { label: string; uri: string; image: ImageContent }[]; } interface CompleteSimpleStub { @@ -59,7 +63,7 @@ function createSession( settings.setModelRole("vision", `${model.provider}/${model.id}`); } - return { + const session: ToolSession = { cwd, hasUI: false, getSessionFile: () => null, @@ -75,6 +79,10 @@ function createSession( resolver: () => async () => apiKey, } as unknown as NonNullable, }; + if (options.imageAttachments) { + session.getImageAttachments = () => options.imageAttachments ?? []; + } + return session; } function createCompleteSimpleSuccessStub(text: string): CompleteSimpleStub { @@ -148,6 +156,126 @@ describe("InspectImageTool", () => { expect(contentParts[1]).toEqual({ type: "text", text: "Extract visible UI labels." }); }); + it("resolves pasted image labels from current attachments without using cwd", async () => { + const image: ImageContent = { type: "image", data: TINY_PNG_BASE64, mimeType: "image/png" }; + const stub = createCompleteSimpleSuccessStub("Attached image inspected"); + const missingCwd = path.join(testDir, "missing-cwd"); + const tool = new InspectImageTool( + createSession(missingCwd, visionModel, "test-key", Settings.isolated(), { + imageAttachments: [{ label: "Image #1", uri: "attachment://1", image }], + }), + stub.fn, + ); + + const result = await tool.execute("call-attachment-label", { + path: "Image #1", + question: "Describe the pasted image.", + }); + + expect(result.details?.imagePath).toBe("attachment://1"); + expect(stub.calls).toHaveLength(1); + const request = stub.calls[0]?.[1] as { messages?: Array<{ content?: unknown }> } | undefined; + const attachmentContent = request?.messages?.[0]?.content; + const attachmentParts = (Array.isArray(attachmentContent) ? attachmentContent : []) as Array<{ + type: string; + data?: string; + }>; + expect(attachmentParts[0]).toMatchObject({ type: "image", data: TINY_PNG_BASE64 }); + }); + + it("resolves bracketed labels and attachment URIs deterministically", async () => { + const first: ImageContent = { type: "image", data: TINY_PNG_BASE64, mimeType: "image/png" }; + const second: ImageContent = { type: "image", data: TINY_PNG_BASE64, mimeType: "image/png" }; + const attachments = [ + { label: "Image #1", uri: "attachment://1", image: first }, + { label: "Image #2", uri: "attachment://2", image: second }, + ]; + + const bracketStub = createCompleteSimpleSuccessStub("First"); + const bracketTool = new InspectImageTool( + createSession(testDir, visionModel, "test-key", Settings.isolated(), { imageAttachments: attachments }), + bracketStub.fn, + ); + const bracketResult = await bracketTool.execute("call-bracket-label", { + path: "[Image #1, 1568x784]", + question: "Describe the first attachment.", + }); + + const uriStub = createCompleteSimpleSuccessStub("Second"); + const uriTool = new InspectImageTool( + createSession(testDir, visionModel, "test-key", Settings.isolated(), { imageAttachments: attachments }), + uriStub.fn, + ); + const uriResult = await uriTool.execute("call-uri-label", { + path: "attachment://2", + question: "Describe the second attachment.", + }); + + expect(bracketResult.details?.imagePath).toBe("attachment://1"); + expect(uriResult.details?.imagePath).toBe("attachment://2"); + }); + + it("reports attachment-aware errors for missing image labels", async () => { + const image: ImageContent = { type: "image", data: TINY_PNG_BASE64, mimeType: "image/png" }; + const stub = createCompleteSimpleForbiddenStub(); + const tool = new InspectImageTool( + createSession(testDir, visionModel, "test-key", Settings.isolated(), { + imageAttachments: [{ label: "Image #1", uri: "attachment://1", image }], + }), + stub.fn, + ); + + await expect(tool.execute("call-missing-label", { path: "Image #2", question: "Describe it." })).rejects.toThrow( + /Available image attachments: Image #1 -> attachment:\/\/1/, + ); + expect(stub.calls).toHaveLength(0); + }); + + it("wires createAgentSession tool sessions to live image attachments", async () => { + const image: ImageContent = { type: "image", data: TINY_PNG_BASE64, mimeType: "image/png" }; + const authStorage = await AuthStorage.create(path.join(testDir, "auth.db")); + const modelRegistry = new ModelRegistry(authStorage); + const settings = Settings.isolated({ "compaction.enabled": false, "inspect_image.enabled": true }); + settings.setModelRole("vision", `${visionModel.provider}/${visionModel.id}`); + + try { + const { session } = await createAgentSession({ + cwd: testDir, + agentDir: testDir, + sessionManager: SessionManager.inMemory(testDir), + settings, + model: visionModel, + modelRegistry, + disableExtensionDiscovery: true, + skills: [], + contextFiles: [], + promptTemplates: [], + slashCommands: [], + enableMCP: false, + enableLsp: false, + toolNames: ["inspect_image"], + }); + try { + session.agent.appendMessage({ + role: "user", + content: [{ type: "text", text: "inspect this" }, image], + timestamp: Date.now(), + }); + + const tool = session.getToolByName("inspect_image"); + expect(tool).toBeDefined(); + const wiredToolSession = (tool as unknown as { session?: ToolSession }).session; + expect(wiredToolSession?.getImageAttachments?.()).toEqual([ + { label: "Image #1", uri: "attachment://1", image }, + ]); + } finally { + await session.dispose(); + } + } finally { + authStorage.close(); + } + }); + it("sends question text unchanged", async () => { const imagePath = path.join(testDir, "screen.png"); fs.writeFileSync(imagePath, Buffer.from(TINY_PNG_BASE64, "base64")); diff --git a/packages/coding-agent/test/tools/irc.test.ts b/packages/coding-agent/test/tools/irc.test.ts index 37ed6e562..b246f92a0 100644 --- a/packages/coding-agent/test/tools/irc.test.ts +++ b/packages/coding-agent/test/tools/irc.test.ts @@ -434,6 +434,24 @@ describe("IRC", () => { expect(text).toContain("Parked agents are revived automatically"); }); + it("op=list hides advisor-kind refs from the peer roster", async () => { + const sub = makeFakeSession(); + registry.register({ id: "0-Worker", displayName: "task", kind: "sub", session: sub.session }); + registry.register({ + id: "0-Main/advisor", + displayName: "advisor", + kind: "advisor", + session: null, + status: "parked", + }); + + const tool = new IrcTool(makeToolSession(registry, "0-Main")); + const result = await tool.execute("call-1", { op: "list" }); + const peerIds = result.details?.peers?.map(peer => peer.id) ?? []; + expect(peerIds).toContain("0-Worker"); + expect(peerIds).not.toContain("0-Main/advisor"); + }); + it("op=send returns receipts immediately without waiting for a reply", async () => { const sub = makeFakeSession(); registry.register({ id: "0-Sub", displayName: "task", kind: "sub", session: sub.session }); diff --git a/packages/coding-agent/test/tools/lsp-regressions.test.ts b/packages/coding-agent/test/tools/lsp-regressions.test.ts index 768fddd49..45039443e 100644 --- a/packages/coding-agent/test/tools/lsp-regressions.test.ts +++ b/packages/coding-agent/test/tools/lsp-regressions.test.ts @@ -308,6 +308,78 @@ describe("lsp regressions", () => { } }); + it("accepts dynamic capability registration before semantic requests", async () => { + const tempDir = TempDir.createSync("@omp-lsp-dynamic-registration-"); + try { + let dynamicRegistrationAccepted = false; + const server = installFakeLsp((message, srv) => { + if (message.method === "initialize") { + srv.send({ jsonrpc: "2.0", id: message.id, result: { capabilities: { hoverProvider: true } } }); + } else if (message.method === "initialized") { + srv.send({ + jsonrpc: "2.0", + id: 9002, + method: "client/registerCapability", + params: { + registrations: [ + { + id: "-42", + method: "workspace/didChangeWatchedFiles", + registerOptions: { + watchers: [{ globPattern: "**/mix.lock" }, { globPattern: "**/*.{ex,exs}" }], + }, + }, + ], + }, + }); + srv.send({ + jsonrpc: "2.0", + id: "expert-unregister-1", + method: "client/unregisterCapability", + params: { unregisterations: [{ id: "-42", method: "workspace/didChangeWatchedFiles" }] }, + }); + } else if (message.id === 9002 && message.method === undefined) { + dynamicRegistrationAccepted = message.error === undefined; + } else if (message.method === "textDocument/hover" && dynamicRegistrationAccepted) { + srv.send({ jsonrpc: "2.0", id: message.id, result: { contents: "Atas.version()" } }); + } else if (message.method === "shutdown") { + srv.send({ jsonrpc: "2.0", id: message.id, result: null }); + } else if (message.method === "exit") { + srv.exit(0); + } + }); + + const config: ServerConfig = { + command: "fake-lsp", + fileTypes: ["ex"], + rootMarkers: [], + }; + + const client = await lspClient.getOrCreateClient(config, tempDir.path(), 1_000); + const registerResponse = await server.waitFor(message => message.id === 9002 && message.method === undefined); + const unregisterResponse = await server.waitFor( + message => message.id === "expert-unregister-1" && message.method === undefined, + ); + expect(registerResponse.error).toBeUndefined(); + expect(unregisterResponse.error).toBeUndefined(); + const result = await lspClient.sendRequest( + client, + "textDocument/hover", + { + textDocument: { uri: fileToUri(path.join(tempDir.path(), "lib", "atas.ex")) }, + position: { line: 0, character: 0 }, + }, + undefined, + 50, + ); + + expect(result).toEqual({ contents: "Atas.version()" }); + } finally { + await lspClient.shutdownAll(); + tempDir.removeSync(); + } + }); + it("opens rust-analyzer Cargo workspace files before polling workspace readiness", async () => { const tempDir = TempDir.createSync("@omp-lsp-rust-workspace-"); try { diff --git a/packages/coding-agent/test/tools/output-caps.test.ts b/packages/coding-agent/test/tools/output-caps.test.ts index 3c7997db6..feec2c115 100644 --- a/packages/coding-agent/test/tools/output-caps.test.ts +++ b/packages/coding-agent/test/tools/output-caps.test.ts @@ -8,7 +8,7 @@ import { describe, expect, it } from "bun:test"; import { DEFAULT_MAX_BYTES, enforceInlineByteCap } from "@oh-my-pi/pi-coding-agent/session/streaming-output"; -const MARKER_PATTERN = /\[… elided \d+ bytes of test output …\]/; +const MARKER_PATTERN = /\[…\d+B elided…\]/; /** Build `count` complete lines of the form `line-00001 `. */ function makeLines(count: number, pad = "x".repeat(40)): string { @@ -22,24 +22,24 @@ function makeLines(count: number, pad = "x".repeat(40)): string { describe("enforceInlineByteCap", () => { it("returns sub-cap text unchanged (identity, not just equality)", async () => { const text = makeLines(10); - const result = await enforceInlineByteCap(text, { maxBytes: 4096, label: "test output" }); + const result = await enforceInlineByteCap(text, { maxBytes: 4096 }); expect(result).toBe(text); }); it("returns text exactly at the cap unchanged", async () => { const text = "a".repeat(1000); expect(Buffer.byteLength(text, "utf-8")).toBe(1000); - const result = await enforceInlineByteCap(text, { maxBytes: 1000, label: "test output" }); + const result = await enforceInlineByteCap(text, { maxBytes: 1000 }); expect(result).toBe(text); }); it("uses DEFAULT_MAX_BYTES when maxBytes is omitted", async () => { const under = "a".repeat(DEFAULT_MAX_BYTES - 1); - expect(await enforceInlineByteCap(under, { label: "test output" })).toBe(under); + expect(await enforceInlineByteCap(under, {})).toBe(under); const over = makeLines(2000); // ~94KB, well over the 50KB default expect(Buffer.byteLength(over, "utf-8")).toBeGreaterThan(DEFAULT_MAX_BYTES); - const result = await enforceInlineByteCap(over, { label: "test output" }); + const result = await enforceInlineByteCap(over, {}); expect(result).not.toBe(over); expect(result).toMatch(MARKER_PATTERN); expect(Buffer.byteLength(result, "utf-8")).toBeLessThanOrEqual(DEFAULT_MAX_BYTES); @@ -48,7 +48,7 @@ describe("enforceInlineByteCap", () => { it("elides over-cap text to head + tail on line boundaries within the budget", async () => { const maxBytes = 4096; const text = makeLines(500); // ~23KB - const result = await enforceInlineByteCap(text, { maxBytes, label: "test output" }); + const result = await enforceInlineByteCap(text, { maxBytes }); expect(result).toMatch(MARKER_PATTERN); // Stays within the cap plus a sliver of slack for the marker line. @@ -80,7 +80,7 @@ describe("enforceInlineByteCap", () => { // Each line is multi-byte heavy: é (2B), € (3B), 😀 (4B). const text = makeLines(800, "é€😀".repeat(12)); const maxBytes = 4096; - const result = await enforceInlineByteCap(text, { maxBytes, label: "test output" }); + const result = await enforceInlineByteCap(text, { maxBytes }); expect(result).toMatch(MARKER_PATTERN); // Valid UTF-8 round-trip: encode/decode is lossless and introduces no @@ -102,7 +102,6 @@ describe("enforceInlineByteCap", () => { let saved: string | undefined; const result = await enforceInlineByteCap(text, { maxBytes: 4096, - label: "test output", saveArtifact: full => { saved = full; return Promise.resolve("17"); @@ -118,7 +117,6 @@ describe("enforceInlineByteCap", () => { const text = makeLines(500); const result = await enforceInlineByteCap(text, { maxBytes: 4096, - label: "test output", saveArtifact: () => undefined, }); expect(result).not.toContain("[raw output: artifact://"); @@ -130,7 +128,6 @@ describe("enforceInlineByteCap", () => { const text = "short output"; const result = await enforceInlineByteCap(text, { maxBytes: 4096, - label: "test output", saveArtifact: () => { called = true; return "99"; diff --git a/packages/coding-agent/test/tools/read-renderer.test.ts b/packages/coding-agent/test/tools/read-renderer.test.ts index 44f7acc15..c87678a17 100644 --- a/packages/coding-agent/test/tools/read-renderer.test.ts +++ b/packages/coding-agent/test/tools/read-renderer.test.ts @@ -122,10 +122,10 @@ describe("read ToolExecutionComponent framing", () => { try { const lines = component.render(80).map(line => Bun.stripANSI(line)); const topBorderIndex = lines.findIndex( - line => line.includes(activeTheme.boxSharp.topLeft) && line.includes("Read"), + line => line.includes(activeTheme.boxRound.topLeft) && line.includes("Read"), ); const bottomBorderIndex = lines.findIndex( - (line, index) => index > topBorderIndex && line.includes(activeTheme.boxSharp.bottomLeft), + (line, index) => index > topBorderIndex && line.includes(activeTheme.boxRound.bottomLeft), ); expect(topBorderIndex).toBeGreaterThanOrEqual(0); diff --git a/packages/coding-agent/test/tools/resolve.test.ts b/packages/coding-agent/test/tools/resolve.test.ts index 5df4bc042..46c1f84ad 100644 --- a/packages/coding-agent/test/tools/resolve.test.ts +++ b/packages/coding-agent/test/tools/resolve.test.ts @@ -147,7 +147,7 @@ it("renders a highlighted apply summary", async () => { expect(rendered).toContain("All replacements are correct"); expect(rendered).not.toContain("Applied 2 replacements in 1 file."); expect(rendered).not.toContain("Decision"); - expect(rendered).not.toContain("┌"); + expect(rendered).not.toContain(uiTheme.boxRound.topLeft); }); it("keeps the inverse block color across the full line (no mid-line fg reset)", async () => { diff --git a/packages/coding-agent/test/tools/root-path-alias.test.ts b/packages/coding-agent/test/tools/root-path-alias.test.ts index 5a8ed4417..daf084bae 100644 --- a/packages/coding-agent/test/tools/root-path-alias.test.ts +++ b/packages/coding-agent/test/tools/root-path-alias.test.ts @@ -148,8 +148,7 @@ describe("tool path root alias", () => { expect(details?.scopePath).toBe("."); expect(details?.totalReplacements).toBe(1); - queue.nextToolChoice(); - const invoker = queue.peekInFlightInvoker()!; + const invoker = queue.peekPendingInvoker()!; expect(invoker).toBeDefined(); await invoker({ action: "apply", reason: "apply root alias rewrite" }); diff --git a/packages/coding-agent/test/tools/search-path-lists.test.ts b/packages/coding-agent/test/tools/search-path-lists.test.ts index daaa8854f..f618da3c3 100644 --- a/packages/coding-agent/test/tools/search-path-lists.test.ts +++ b/packages/coding-agent/test/tools/search-path-lists.test.ts @@ -7,7 +7,7 @@ import { validateToolArguments } from "@oh-my-pi/pi-ai/utils/validation"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { canonicalSnapshotKey } from "@oh-my-pi/pi-coding-agent/edit/file-snapshot-store"; import type { RenderResultOptions } from "@oh-my-pi/pi-coding-agent/extensibility/custom-tools/types"; -import { AgentHubOverlayComponent } from "@oh-my-pi/pi-coding-agent/modes/components/agent-hub"; +import { AgentTranscriptViewer } from "@oh-my-pi/pi-coding-agent/modes/components/agent-transcript-viewer"; import { TreeSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/tree-selector"; import type { ObservableSession, @@ -354,16 +354,20 @@ describe("tool path arrays", () => { status: "parked", }); - const hub = new AgentHubOverlayComponent({ - observers, - hubKeys: ["ctrl+s"], - onDone: () => {}, - requestRender: () => {}, + const viewer = new AgentTranscriptViewer({ + agentId: "search-overlay-session", registry: agents, + observers, + ui: { requestRender: () => {}, requestComponentRender: () => {} } as never, + cwd: tmp, + expandKeys: ["ctrl+o"], + hubKeys: ["ctrl+s"], + requestRender: () => {}, + onClose: () => {}, + onHubClose: () => {}, }); - hub.openChat("search-overlay-session"); - const rendered = Bun.stripANSI(hub.render(120).join("\n")); - hub.dispose(); + const rendered = Bun.stripANSI(viewer.render(120).join("\n")); + viewer.dispose(); // The hub chat now renders through searchToolRenderer.renderCall; the // single-string `paths` arg shows up as the "in " scope meta on the @@ -634,8 +638,7 @@ describe("tool path arrays", () => { expect(details?.totalReplacements).toBe(3); expect(details?.scopePath).toBe("apps/**/*.ts, packages/**/*.ts, phases/**/*.ts"); - queue.nextToolChoice(); - const invoker = queue.peekInFlightInvoker(); + const invoker = queue.peekPendingInvoker(); if (!invoker) throw new Error("Expected pending resolve invoker"); await invoker({ action: "apply", reason: "apply multi-path ast edit" }); diff --git a/packages/coding-agent/test/tools/strip-output-notice.test.ts b/packages/coding-agent/test/tools/strip-output-notice.test.ts index d85b4e246..91c0fd9f1 100644 --- a/packages/coding-agent/test/tools/strip-output-notice.test.ts +++ b/packages/coding-agent/test/tools/strip-output-notice.test.ts @@ -45,7 +45,7 @@ const limitsOnly: OutputMeta = { describe("stripOutputNotice", () => { it("removes the exact notice appended by the wrapper for middle elision", () => { - const body = "line1\nline2\n[… 4 lines elided (215B) …]\nline7\nline8"; + const body = "line1\nline2\n[…4ln elided…]\nline7\nline8"; const notice = formatOutputNotice(truncation); const combined = body + notice; diff --git a/packages/coding-agent/test/utils/qrcode.test.ts b/packages/coding-agent/test/utils/qrcode.test.ts new file mode 100644 index 000000000..956f98f6d --- /dev/null +++ b/packages/coding-agent/test/utils/qrcode.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "bun:test"; +import { QrCode, type QrEcLevel, renderQrHalfBlocks } from "@oh-my-pi/pi-coding-agent/utils/qrcode"; + +function matrixFingerprint(qr: QrCode): string { + let bits = ""; + for (let y = 0; y < qr.size; y++) for (let x = 0; x < qr.size; x++) bits += qr.module(x, y) ? "1" : "0"; + return new Bun.CryptoHasher("sha256").update(bits).digest("hex").slice(0, 16); +} + +describe("QR encoder", () => { + // Golden vectors captured from the encoder after byte-for-byte cross-validation + // against the `qrcode` reference library (all versions/EC levels/masks) and a + // real jsQR decode. A changed hash means the symbol bytes drifted. + const vectors: ReadonlyArray<{ + text: string; + ecl: QrEcLevel; + mask: number; + version: number; + size: number; + hash: string; + }> = [ + { text: "HELLO WORLD", ecl: "M", mask: 0, version: 1, size: 21, hash: "a28227450c6dd5ab" }, + { + text: "https://my.omp.sh/#mgAYTZwEnpRQtca0CTgn-Q.gdJU", + ecl: "M", + mask: 4, + version: 4, + size: 33, + hash: "4af2f66e1b06a5b1", + }, + { + text: "https://web.example/collab/#relay.example.com:8443/r/AbCdEfGhIjKlMnOp.0123456789abcdef", + ecl: "Q", + mask: 6, + version: 7, + size: 45, + hash: "2ca4a51e3cba1adf", + }, + { text: "x", ecl: "L", mask: 1, version: 1, size: 21, hash: "d1330f755ac63f88" }, + ]; + + for (const v of vectors) { + it(`produces a stable symbol for ${JSON.stringify(v.text).slice(0, 32)} (${v.ecl})`, () => { + const qr = QrCode.encodeText(v.text, v.ecl, { mask: v.mask }); + expect(qr.version).toBe(v.version); + expect(qr.size).toBe(v.size); + expect(qr.mask).toBe(v.mask); + expect(matrixFingerprint(qr)).toBe(v.hash); + }); + } + + it("auto-selects the smallest version that fits the byte payload", () => { + // Byte-mode, EC level M capacities: v1=14, v2=26, v3=42 data bytes. + expect(QrCode.encodeText("a".repeat(14), "M").version).toBe(1); + expect(QrCode.encodeText("a".repeat(15), "M").version).toBe(2); + expect(QrCode.encodeText("a".repeat(26), "M").version).toBe(2); + expect(QrCode.encodeText("a".repeat(27), "M").version).toBe(3); + }); + + it("sizes by UTF-8 byte length, not string length", () => { + // "日" is 3 UTF-8 bytes; five of them = 15 bytes, just over v1-M's 14. + expect(QrCode.encodeText("日".repeat(5), "M").version).toBe(2); + }); + + it("deterministically selects a penalty-minimizing mask when none is forced", () => { + // Auto mask is the lowest-penalty choice; locking it guards the penalty rules. + const qr = QrCode.encodeText("https://my.omp.sh/#demo", "M"); + expect(qr.mask).toBe(1); + expect(matrixFingerprint(qr)).toBe("ee820c588fe36d99"); + }); + + it("throws when the payload exceeds version 40 at the chosen EC level", () => { + // v40-H holds 1273 data bytes. + expect(() => QrCode.encodeText("a".repeat(1274), "H")).toThrow(/too long/); + }); + + it("places the three finder patterns at the symbol corners", () => { + const qr = QrCode.encodeText("finder", "M"); + // A finder is a 7x7 dark ring: the outer corner is dark and the module one + // step diagonally inward (the separating gap) is light. + const finderCorner = (ox: number, oy: number): boolean => + qr.module(ox, oy) && !qr.module(ox + (ox === 0 ? 1 : -1), oy + (oy === 0 ? 1 : -1)); + expect(finderCorner(0, 0)).toBe(true); + expect(finderCorner(qr.size - 1, 0)).toBe(true); + expect(finderCorner(0, qr.size - 1)).toBe(true); + }); +}); + +describe("renderQrHalfBlocks", () => { + it("frames the symbol in a light quiet zone wide enough for the margin", () => { + const qr = QrCode.encodeText("https://omp.sh/#demo", "M"); + const margin = 3; + const lines = renderQrHalfBlocks(qr, { margin }); + // Visible cell width = symbol + both margins. + const stripped = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "")); + expect(stripped[0]!.length).toBe(qr.size + margin * 2); + // Top quiet rows (margin/2 paired rows) carry no dark half-blocks. + const quietRows = Math.floor(margin / 2); + for (let i = 0; i < quietRows; i++) { + expect(stripped[i]).toBe(" ".repeat(qr.size + margin * 2)); + } + // Body rows use half-block glyphs and a white-background/black-foreground prefix. + const body = lines[quietRows + 2]!; + expect(body).toContain("\x1b[47m"); + expect(body).toMatch(/[▀▄█]/); + }); + + it("emits ceil((size + 2*margin)/2) rows", () => { + const qr = QrCode.encodeText("rows", "M"); + const margin = 4; + const lines = renderQrHalfBlocks(qr, { margin }); + expect(lines.length).toBe(Math.ceil((qr.size + margin * 2) / 2)); + }); +}); diff --git a/packages/coding-agent/test/workspace-tree.test.ts b/packages/coding-agent/test/workspace-tree.test.ts index c03423375..72cd0767d 100644 --- a/packages/coding-agent/test/workspace-tree.test.ts +++ b/packages/coding-agent/test/workspace-tree.test.ts @@ -120,7 +120,7 @@ describe("buildWorkspaceTree", () => { expect(tree.truncated).toBe(true); expect(tree.totalLines).toBeLessThanOrEqual(120); expect(renderedLines.length).toBeLessThanOrEqual(120); - expect(tree.rendered).toContain("lines elided beyond depth/cap"); + expect(tree.rendered).toContain("ln elided…]"); }); it("can keep root entries uncapped while truncating child directories", async () => { diff --git a/packages/collab-web/CHANGELOG.md b/packages/collab-web/CHANGELOG.md index 55fa798e6..22f3eee13 100644 --- a/packages/collab-web/CHANGELOG.md +++ b/packages/collab-web/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [16.0.10] - 2026-06-18 + +### Added + +- Added support for collab browser wrapper links whose web UI host differs from the relay host, so the connect screen joins the relay encoded in the URL fragment. + ## [16.0.5] - 2026-06-17 ### Fixed diff --git a/packages/collab-web/src/lib/link.ts b/packages/collab-web/src/lib/link.ts index 2c3ccd447..f90697689 100644 --- a/packages/collab-web/src/lib/link.ts +++ b/packages/collab-web/src/lib/link.ts @@ -157,15 +157,20 @@ export function parseCollabLink(link: string): ParsedCollabLink | { error: strin } catch { return { error: `Invalid collab link: ${link}` }; } + if ((url.protocol === "http:" || url.protocol === "https:") && url.hash) { + const inner = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash; + const parsed = parseCollabLink(inner); + if (!("error" in parsed)) return parsed; + } const normalized = normalizeRelayOrigin(url.origin); if ("error" in normalized) return normalized; const match = ROOM_PATH_RE.exec(url.pathname); if (!match) { - // Web deep link: `http(s):///#` — the fragment holds - // the whole link, so recurse on it. The recursion terminates because - // the inner text is a strict suffix of the input. + // Non-http(s) deep links may also carry a complete collab link in the + // fragment. http(s) links are handled once above so invalid fragments + // fall through to direct relay validation instead of double-recursing. const inner = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash; - if (inner) return parseCollabLink(inner); + if (inner && url.protocol !== "http:" && url.protocol !== "https:") return parseCollabLink(inner); return { error: "Collab link must contain a /r/ path" }; } const roomId = match[1] as string; diff --git a/packages/collab-web/src/tool-render/ToolView.tsx b/packages/collab-web/src/tool-render/ToolView.tsx index dda08ff12..02268058a 100644 --- a/packages/collab-web/src/tool-render/ToolView.tsx +++ b/packages/collab-web/src/tool-render/ToolView.tsx @@ -2,6 +2,7 @@ * Tool card chrome + per-tool dispatch. Works in the collab-web app and inside * the `` web component embedded in HTML session exports. */ +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import type { ReactNode } from "react"; import { useState } from "react"; import { resolveToolRenderer } from "./registry"; @@ -26,11 +27,11 @@ export interface ToolViewProps { function normalizeArgs(raw: unknown): { args: Record; intent: string | undefined } { if (!isRecord(raw)) return { args: {}, intent: undefined }; - const intent = typeof raw._i === "string" ? raw._i.trim() : undefined; - if (!("_i" in raw)) return { args: raw, intent }; + const intent = typeof raw[INTENT_FIELD] === "string" ? (raw[INTENT_FIELD] as string).trim() : undefined; + if (!(INTENT_FIELD in raw)) return { args: raw, intent }; const args: Record = {}; for (const k in raw) { - if (k !== "_i") args[k] = raw[k]; + if (k !== INTENT_FIELD) args[k] = raw[k]; } return { args, intent }; } diff --git a/packages/collab-web/test/link.test.ts b/packages/collab-web/test/link.test.ts index cfe4c9fa0..ece05c2f9 100644 --- a/packages/collab-web/test/link.test.ts +++ b/packages/collab-web/test/link.test.ts @@ -82,6 +82,31 @@ describe("collab link parsing", () => { expect(local.wsUrl).toBe(`ws://localhost:7466/r/${ROOM}`); }); + it("parses custom web UI wrappers by their relay fragment", () => { + const parsed = parseCollabLink(`http://web.example/collab/#relay.example.com:8443/r/${ROOM}.${KEY_TEXT}`); + if ("error" in parsed) throw new Error(parsed.error); + expect(parsed.wsUrl).toBe(`wss://relay.example.com:8443/r/${ROOM}`); + }); + + it("parses split web UI wrappers with full relay URLs in the fragment", () => { + const parsed = parseCollabLink(`https://web.example/collab/#wss://relay.example.com/r/${ROOM}.${KEY_TEXT}`); + if ("error" in parsed) throw new Error(parsed.error); + expect(parsed.wsUrl).toBe(`wss://relay.example.com/r/${ROOM}`); + }); + + it("falls through invalid http wrapper fragments without reparsing them", () => { + expect(parseCollabLink("https://web.example/#not-a-collab-link")).toEqual({ + error: "Collab link must contain a /r/ path", + }); + }); + + it("prefers browser wrapper fragments over relay-like web paths", () => { + const inner = formatCollabLink("wss://relay.example.com", ROOM, KEY); + const parsed = parseCollabLink(`https://web.example/r/abcdefghij#${inner}`); + if ("error" in parsed) throw new Error(parsed.error); + expect(parsed.wsUrl).toBe(`wss://relay.example.com/r/${ROOM}`); + }); + it("parses dot-joined web deep links (https:///#.)", () => { const parsed = parseCollabLink(`https://my.omp.sh/#${ROOM}.${KEY_TEXT}`); if ("error" in parsed) throw new Error(parsed.error); @@ -89,6 +114,13 @@ describe("collab link parsing", () => { expect(parsed.key).toEqual(KEY); }); + it("parses legacy https direct relay links with key-only fragments", () => { + const parsed = parseCollabLink(`https://relay.example.com/r/${ROOM}#${KEY_TEXT}`); + if ("error" in parsed) throw new Error(parsed.error); + expect(parsed.wsUrl).toBe(`wss://relay.example.com/r/${ROOM}`); + expect(parsed.key).toEqual(KEY); + }); + it("accepts %23-mangled legacy deep links (macOS Foundation re-encoding)", () => { const parsed = parseCollabLink(`https://my.omp.sh/#${ROOM}%23${KEY_TEXT}`); if ("error" in parsed) throw new Error(parsed.error); diff --git a/packages/hashline/package.json b/packages/hashline/package.json index 8fa1e2b56..f58dcb1ff 100644 --- a/packages/hashline/package.json +++ b/packages/hashline/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/hashline", - "version": "16.0.9", + "version": "16.1.1", "description": "Hashline: a compact, line-anchored patch language and applier. Pluggable FS/IO so it works over disk, in-memory, or any custom backend.", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/mnemopi/package.json b/packages/mnemopi/package.json index 83d26eb95..924471acd 100644 --- a/packages/mnemopi/package.json +++ b/packages/mnemopi/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-mnemopi", - "version": "16.0.9", + "version": "16.1.1", "description": "Local SQLite memory engine for Oh My Pi agents", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index 448a14838..9a16cea5b 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [16.0.11] - 2026-06-19 + +### Fixed + +- Fixed native shell execution reporting `pi-natives:command: syntax error at end of input` for a valid `&&`/`;` chain whose later pipeline stage is a compound command, e.g. `echo x && git log | while read h; do …; done | head`. The output minimizer's segmented-chain runner rebuilds each chain segment from the brush-parser AST via `pipeline.to_string()` and re-executes that string, but `simple_segment` only validated the *first* pipeline stage — so a compound later stage (`while`/`for`/`if`/subshell) was re-serialized without its terminator (`Display` drops it) and re-run as broken shell. `simple_segment` now requires every stage to be a `Display`-safe simple command, and — closing the recurring class of brush `Display` round-trip divergences (here-doc close-tag quoting, multi-byte char/byte offsets) at its root — each reconstructed segment is re-parsed and must match the original pipeline shape before the chain runner executes it; any divergence runs the command whole via the unsegmented path instead of corrupting it. + ## [16.0.7] - 2026-06-18 ### Added diff --git a/packages/natives/native/index.d.ts b/packages/natives/native/index.d.ts index 17a151803..9ce7b7252 100644 --- a/packages/natives/native/index.d.ts +++ b/packages/natives/native/index.d.ts @@ -155,7 +155,7 @@ export declare function __ompInstallTokioRuntime(): void * `packages/natives/native/index.js` (which derives the name from * `package.json#version`). */ -export declare function __piNativesV16_0_9(): void +export declare function __piNativesV16_1_1(): void /** * Apply conservative pre-execution rewrites to a bash command. diff --git a/packages/natives/native/index.js b/packages/natives/native/index.js index bd5f972cf..de7be0937 100644 --- a/packages/natives/native/index.js +++ b/packages/natives/native/index.js @@ -24,7 +24,7 @@ export const Shell = nativeBindings.Shell; // functions export const __ompInstallTokioRuntime = nativeBindings.__ompInstallTokioRuntime; -export const __piNativesV16_0_9 = nativeBindings.__piNativesV16_0_9; +export const __piNativesV16_1_1 = nativeBindings.__piNativesV16_1_1; export const applyBashFixups = nativeBindings.applyBashFixups; export const astEdit = nativeBindings.astEdit; export const astGrep = nativeBindings.astGrep; diff --git a/packages/natives/package.json b/packages/natives/package.json index 256afa65e..e315c75ed 100644 --- a/packages/natives/package.json +++ b/packages/natives/package.json @@ -1,6 +1,6 @@ { "name": "@oh-my-pi/pi-natives", - "version": "16.0.9", + "version": "16.1.1", "description": "Native Rust bindings for grep, clipboard, image processing, syntax highlighting, PTY, and shell operations via N-API", "type": "module", "homepage": "https://omp.sh", diff --git a/packages/snapcompact/CHANGELOG.md b/packages/snapcompact/CHANGELOG.md index 2d79cbaa5..91804552b 100644 --- a/packages/snapcompact/CHANGELOG.md +++ b/packages/snapcompact/CHANGELOG.md @@ -2,6 +2,35 @@ ## [Unreleased] +## [16.1.0] - 2026-06-19 + +### Added + +- Added `historyBlocks(archive)` to reconstruct ordered history blocks from archive data + +### Changed + +- Refactored compaction to be text-sourced, re-rendering from unified `Archive.text` source +- Implemented foveated archive layout (HQ edges, dense LQ middle) for optimized context usage +- Raised `MAX_FRAMES_DEFAULT` to 80 and consolidated `PROVIDER_IMAGE_BUDGETS` +- Updated OpenRouter to use standard 90-image budget +- Updated prompt instructions to clearly distinguish between plain-text and image history regions +- `Options.maxFrames` is now an upper limit clamped to `MAX_FRAMES_DEFAULT`, not a per-call default +- Rewrote the resume summary prompt into a structured reading guide (turn headings, grid/two-column layout, ink notes) and render file operations inline as a `FILES` section instead of a spliced `` tag + +### Fixed + +- Fixed context budget undercounting by raising `FRAME_TOKEN_ESTIMATE` to 5024 +- Improved file list formatting in compaction summaries + +## [16.0.11] - 2026-06-19 + +### Changed + +- Refined elision markers for file operations and truncated text for better display consistency +- Updated summary text for consistent descriptions of archived tool output +- Folded a much wider range of Unicode to ASCII in `normalize()` before native rendering: added a per-character Unicode NFKD decomposition fallback (fullwidth forms, super/subscripts, ligatures, circled and math-styled alphanumerics, Roman numerals, vulgar fractions) and expanded the `CHAR_FOLD` punctuation table (more quotes/primes, hyphens, the fraction slash, dot leaders, bullets, and arrows) so undrawable glyphs land on close ASCII equivalents instead of `?` + ## [16.0.8] - 2026-06-18 ### Added diff --git a/packages/snapcompact/README.md b/packages/snapcompact/README.md index d6a4968ec..3901e50e9 100644 --- a/packages/snapcompact/README.md +++ b/packages/snapcompact/README.md @@ -49,18 +49,18 @@ Run a full compaction pass over prepared messages: ```ts import { compact } from "@oh-my-pi/snapcompact"; -const result = await compact(preparation, { model, maxFrames: 8 }); -// result.summary — text summary with operations block -// result.preserveData — frame archive, re-attachable via getPreservedArchive() + images() +const result = await compact(preparation, { model }); +// result.summary — short "resume prior conversation" lead-in, reading guide, and FILES section +// result.preserveData — bounded archive source + rendered image middle ``` ## API surface -- **Compaction**: `compact`, `CompactionPreparation`, `CompactionResult`, `getPreservedArchive`, `images` +- **Compaction**: `compact`, `CompactionPreparation`, `CompactionResult`, `getPreservedArchive`, `images`, `historyBlocks` - **Rendering**: `render`, `renderMany`, `frames`, `geometry` - **Shapes**: `SHAPES`, `SHAPE_VARIANTS`, `resolveShape`, `idealShapeVariant`, `isShape`, `isShapeVariantName` - **Text**: `serializeConversation`, `normalize`, `dimStopwords`, `wrap` -- **Budgets**: `providerImageBudget`, `providerFrameBudget`, `MAX_FRAMES`, `FRAME_TOKEN_ESTIMATE` +- **Budgets**: `providerImageBudget`, `MAX_FRAMES_DEFAULT`, `FRAME_TOKEN_ESTIMATE`, `HQ_EDGE_FRAMES` - **File ops**: `createFileOps`, `computeFileLists`, `upsertFileOperations` ## References diff --git a/packages/snapcompact/package.json b/packages/snapcompact/package.json index c07b82db2..98a9c62e9 100644 --- a/packages/snapcompact/package.json +++ b/packages/snapcompact/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/snapcompact", - "version": "16.0.9", + "version": "16.1.1", "description": "Bitmap-frame context compression for vision-capable LLMs", "homepage": "https://omp.sh", "author": "Can Boluk", @@ -33,7 +33,8 @@ "dependencies": { "@oh-my-pi/pi-ai": "catalog:", "@oh-my-pi/pi-natives": "catalog:", - "@oh-my-pi/pi-utils": "catalog:" + "@oh-my-pi/pi-utils": "catalog:", + "@oh-my-pi/pi-wire": "catalog:" }, "devDependencies": { "@types/bun": "catalog:" diff --git a/packages/snapcompact/src/prompts/snapcompact-summary.md b/packages/snapcompact/src/prompts/snapcompact-summary.md index 182f23d0a..286ee0c94 100644 --- a/packages/snapcompact/src/prompts/snapcompact-summary.md +++ b/packages/snapcompact/src/prompts/snapcompact-summary.md @@ -1,24 +1,24 @@ -Prior conversation history has been archived verbatim onto {{frameCount}} snapcompact frame{{#if multipleFrames}}s{{/if}} — the bitmap image{{#if multipleFrames}}s{{/if}} attached below{{#if multipleFrames}}, ordered oldest to newest{{/if}}. +You are resuming a prior conversation. Its earlier turns were archived to reclaim context and are reproduced under HISTORY below, oldest to newest. Read HISTORY in full, then continue from the live conversation that follows it. -Reading a frame: monospace {{fontCell}} pixel font on a white background, {{#if docColumns}}typeset as two word-wrapped newspaper columns of {{cols}} characters by {{rows}} lines each — read the left column top to bottom, then the right column{{else}}{{cols}} characters per row, {{rows}} text rows per frame; read left to right, top to bottom. Text flows continuously with no word wrap, so words may break across row ends{{/if}}. Horizontal whitespace runs were collapsed to single spaces; line breaks print as a solid black cell (one character wide) — treat each as a newline. {{#if sentenceInk}}Ink color cycles through six colors, advancing at sentence boundaries — a color change marks a new sentence.{{else}}Glyphs are plain black ink.{{/if}}{{#if stopwordDimmed}} Common function words (the, of, and, …) are printed in dim gray; content words carry the full ink.{{/if}}{{#if dimmedToolResults}} Tool output is printed in dim gray ink — gray text is archived tool output, not conversation.{{/if}}{{#if lineRepeated}} Every text line is printed twice in a row — first on the white background, then repeated on a pale yellow band. The copies are identical: read each line once and use the duplicate only to double-check hard glyphs.{{/if}} Roles are tagged inline as [User]:, [Assistant]:, [Think]:, [Tool Call]:, and [Tool Result]:. -{{#if mixedShapes}} +The archived transcript is compact: each turn opens with a heading — `# User ¶`, `# Assistant ¶`, or `# Tool call ¶` — assistant reasoning is wrapped in _italics_, and tool output sits inside `…`. -Older frames may use a different font, grid, or ink coloring than described above; the reading order is always the same (left to right, top to bottom, oldest frame first). -{{/if}} -{{#if includedPreviousSummary}} +Reading HISTORY: +- Plain-text sections are the verbatim transcript — rely on them exactly. +{{#if frameCount}}- Some middle sections are attached as images instead of text. Each image is a page of that same transcript and belongs at its place in the reading order, between marked delimiters. Within an image, a solid black cell marks a newline and runs of spaces collapse to one. +{{#if docColumns}} - A frame holds two side-by-side columns, each {{cols}} characters wide and up to {{rows}} rows tall: read the left column top to bottom, then the right. +{{else}} - A frame is one grid {{cols}} characters wide and up to {{rows}} rows tall: read left to right, top to bottom — there is no word wrap, so a word may break across rows. +{{/if}}{{#if sentenceInk}} - Ink cycles through six colors, one per sentence. +{{/if}}{{#if stopwordDimmed}} - Function words are dim gray; content words keep full ink. +{{/if}}{{#if dimmedToolResults}} - Text inside `` is dim gray — that gray is archived tool output, not conversation. +{{/if}}{{#if lineRepeated}} - Each line is printed twice (white, then a pale-yellow band); the two copies are identical. +{{/if}}{{#if mixedShapes}} - The compressed middle frames use a smaller, denser font than the edge frames; the reading order is unchanged. +{{/if}}{{/if}}{{#if includedPreviousSummary}}- HISTORY opens with a condensed digest of still-older context that predates the archived turns. +{{/if}}{{#if truncatedChars}}- About {{truncatedChars}} characters of older middle history were dropped to fit the archive budget. +{{/if}}- When an exact earlier detail matters and a section reads unclearly, re-derive it from the workspace (re-read files, re-run commands) rather than guessing. -The earliest frame begins with "[Summary of earlier history]" — a condensed digest of context that predates the archived conversation. -{{/if}} -{{#if truncatedChars}} +{{#if files}}FILES +=================== +{{files}} -{{truncatedChars}} characters of older history were dropped to respect the frame budget. The first frame (session start) is always kept, so the missing span sits between the first frame and the next. -{{/if}} - -Total archived: {{totalChars}} characters. Consult the frames whenever you need exact earlier details (user wording, decisions, file paths, tool output). If a region is hard to read, re-derive the fact from the workspace (re-read files, re-run commands) rather than guessing. -{{#if textTail}} - -The frame budget ran out before the newest part of the archive. That remainder continues below as plain text — it is newer than every frame and ends where the live conversation resumes. - -[Archived history, continued as text] -{{textTail}} -{{/if}} +{{/if}}HISTORY +=================== diff --git a/packages/snapcompact/src/snapcompact.ts b/packages/snapcompact/src/snapcompact.ts index bfdd71fe1..5f46e9912 100644 --- a/packages/snapcompact/src/snapcompact.ts +++ b/packages/snapcompact/src/snapcompact.ts @@ -31,14 +31,11 @@ * billing (32px × 1.2, 10k-patch budget at `detail: "original"`) is * area-proportional, so resolution cannot improve chars/$ — 1568 stays. * `detail: "high"` would downgrade (2,500-patch cap); `original` is sent. - * - **Unknown providers** default to the Anthropic shape. Gateways can - * defeat any shape silently: OpenRouter enforces a per-model image cap - * (measured: 8 images for glm-4.6v — frames past the cap are dropped with - * no error, billed tokens plateau exactly at 8x frame cost). The same - * frames routed direct to the vendor read fine (glm f1 .20 -> .78), so - * `providerImageBudget` caps per-request images per provider (OpenRouter - * 8, unknown 5) and `compact()` keeps any archive overflow as a text tail - * on the summary instead of rendering frames that would be dropped. + * - **Unknown providers** default to the Anthropic shape. `providerImageBudget` + * still caps per-request images per provider so inline imaging cannot flood a + * request with attachments, but the old OpenRouter-specific 8-image cap is + * gone; routers now use the same permissive budget as direct Anthropic/Claude + * lines unless configured otherwise upstream. * * The whole pass is local and deterministic — no LLM call, no API key, no * latency beyond rendering. Rasterization and PNG encoding happen in native @@ -47,9 +44,10 @@ * re-attached to the compaction summary message on every context rebuild. */ -import type { Api, ImageContent, Message, Model } from "@oh-my-pi/pi-ai"; +import type { Api, ImageContent, Message, Model, TextContent } from "@oh-my-pi/pi-ai"; import { renderSnapcompactPng } from "@oh-my-pi/pi-natives"; import { formatGroupedPaths, prompt } from "@oh-my-pi/pi-utils"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import fileOperationsTemplate from "./prompts/file-operations.md" with { type: "text" }; import snapcompactSummaryPrompt from "./prompts/snapcompact-summary.md" with { type: "text" }; @@ -300,6 +298,16 @@ const FAMILY_VARIANT: Record = { openai: "8on22-bw", }; +/** Denser companion variant per family for the foveated archive middle: same + * pixels (identical per-frame bill) but a tighter 8px cell, trading some + * legibility for ~40% more chars per frame so the least-important middle of a + * long archive compresses into fewer frames. */ +const FAMILY_VARIANT_LOW: Record = { + anthropic: "8on16-bw", + google: "8on16-bw", + openai: "8on16-bw", +}; + const FAMILY_SHAPE: Record = { anthropic: SHAPES.anthropic, google: SHAPES.google, @@ -376,22 +384,32 @@ export function resolveShape(model?: ShapeTarget, variant?: ShapeVariantName | " * shapes carry their own `frameSize`. */ export const FRAME_SIZE = 2576; -/** Maximum frames carried on a compaction entry. Oldest frames are dropped - * first once the budget is exceeded (mirrors how iterative text summaries - * fade the oldest detail). */ -export const MAX_FRAMES = 8; +/** Default upper bound on archive frames carried per compaction. Sized to hold + * ~400k tokens of the high-res Anthropic frame Opus reads (1932px ≈ 5,000 + * billed tokens each → 80 frames) while staying under the ~100-image + * per-request wire cap. Oldest frames are dropped first once the budget is + * exceeded (mirrors how iterative text summaries fade the oldest detail); a + * caller may pass a lower `maxFrames` upper limit, and per-model context + * fitting is handled by the caller's overflow guard. */ +export const MAX_FRAMES_DEFAULT = 80; -/** Conservative per-frame token estimate used for context budgeting - * (upper bound across shapes: Anthropic bills 1568*1568/750 ≈ 3,278). */ -export const FRAME_TOKEN_ESTIMATE = 3300; +/** High-quality (legible) frames rendered at each chronological edge of a + * foveated archive — the session head (oldest) and the slice just before the + * text region (newest) — with the denser low-quality tier filling the middle. */ +export const HQ_EDGE_FRAMES = 3; + +/** Conservative per-frame token estimate used for context budgeting — the + * upper bound across shapes: high-res Claude frames hit the 4,784 visual-token + * cap, billed at +5% margin (ceil(4784 * 1.05)). Keeps the overflow guard from + * undercounting a high-res archive at the raised {@link MAX_FRAMES_DEFAULT}. */ +export const FRAME_TOKEN_ESTIMATE = 5024; /** - * Per-request image-count budgets by provider id. Routers and smaller - * providers enforce hard caps and silently DROP images past them (measured: - * OpenRouter caps at 8 — images 9+ vanish with no error and billed tokens - * plateau at 8x frame cost). First-party APIs allow far more; their values - * are conservative policy caps well under the measured hard limits - * (Anthropic 100, OpenAI 500, Gemini ~2500). + * Per-request image-count budgets by provider id. These cap how many images an + * entire request may carry (archive/system-prompt/tool-result imaging combined). + * The values are conservative policy caps under the vendor hard limits + * (Anthropic 100, OpenAI 500, Gemini ~2500); unknown providers fall to a safe + * floor rather than sending unbounded attachments. */ export const PROVIDER_IMAGE_BUDGETS: Record = { anthropic: 90, @@ -401,7 +419,7 @@ export const PROVIDER_IMAGE_BUDGETS: Record = { google: 200, "google-vertex": 200, "google-gemini-cli": 200, - openrouter: 8, + openrouter: 90, }; /** Safe floor for unknown providers (strictest mainstream measured: Groq ~5). */ @@ -412,11 +430,6 @@ export function providerImageBudget(provider: string | undefined): number { return (provider !== undefined ? PROVIDER_IMAGE_BUDGETS[provider] : undefined) ?? DEFAULT_PROVIDER_IMAGE_BUDGET; } -/** Archive frame budget for `provider`: its image budget clamped to {@link MAX_FRAMES}. */ -export function providerFrameBudget(provider: string | undefined): number { - return Math.min(MAX_FRAMES, providerImageBudget(provider)); -} - /** Key under `CompactionEntry.preserveData` holding the frame archive. */ export const PRESERVE_KEY = "snapcompact"; @@ -449,16 +462,20 @@ export interface Frame { /** Frame archive persisted under `preserveData[PRESERVE_KEY]`. */ export interface Archive { - /** Frames ordered oldest to newest. */ + /** Rendered frames ordered oldest to newest, re-derived from {@link text} + * each compaction with foveated quality tiers (HQ/LQ/HQ inside the imaged + * middle). May be empty when the whole archive fits in text. */ frames: Frame[]; - /** Characters currently readable across all frames. */ + /** Characters currently readable across all frames plus the text regions. */ totalChars: number; - /** Characters dropped so far to respect the frame budget. */ + /** Characters dropped so far to respect the archive budget. */ truncatedChars: number; - /** Most recent slice of archived history that exceeded the frame budget, - * kept verbatim as normalized text (dim markers and newline glyphs - * included). Shipped as plain text in the compaction summary and folded - * back into frames by the next compaction. */ + /** Full kept archive source (oldest to newest, normalized, bounded to the + * rendered budget) — the single source re-rendered each compaction. */ + text?: string; + /** Oldest text region kept verbatim around the imaged middle. */ + textHead?: string; + /** Newest text region kept verbatim around the imaged middle. */ textTail?: string; } @@ -480,7 +497,7 @@ export interface Options extends SerializeOptions { shape?: Shape; /** Frame edge in pixels. Defaults to the shape's `frameSize`. */ frameSize?: number; - /** Frame budget. Defaults to {@link MAX_FRAMES}. */ + /** Upper limit on archive frames; clamped to (and defaulting to) {@link MAX_FRAMES_DEFAULT}. */ maxFrames?: number; } @@ -584,7 +601,7 @@ function stripFileOperationTags(summary: string): string { .trimEnd(); } -function formatFileOperations(readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet): string { +function formatFileList(readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet): string { if (readFiles.length === 0 && modifiedFiles.length === 0) return ""; const mode = new Map(); for (const file of readFiles) mode.set(file, "Read"); @@ -592,9 +609,14 @@ function formatFileOperations(readFiles: string[], modifiedFiles: string[], read const all = [...mode.keys()].sort(); let files = formatGroupedPaths(all.slice(0, FILE_OPERATION_SUMMARY_LIMIT), path => ` (${mode.get(path)})`); if (all.length > FILE_OPERATION_SUMMARY_LIMIT) { - files += `\n… (${all.length - FILE_OPERATION_SUMMARY_LIMIT} more files omitted)`; + files += `\n[…${all.length - FILE_OPERATION_SUMMARY_LIMIT} files elided…]`; } - return prompt.render(fileOperationsTemplate, { files }); + return files; +} + +function formatFileOperations(readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet): string { + const files = formatFileList(readFiles, modifiedFiles, readSet); + return files.length > 0 ? prompt.render(fileOperationsTemplate, { files }) : ""; } export function upsertFileOperations( @@ -657,15 +679,16 @@ function truncateForSummary(text: string, maxChars: number, headRatio: number): const tailChars = maxChars - headChars; const elided = text.length - maxChars; const tail = tailChars > 0 ? text.slice(-tailChars) : ""; - return `${text.slice(0, headChars)} [... ${elided} chars elided ...] ${tail}`; + return `${text.slice(0, headChars)} […${elided}ch elided…] ${tail}`; } const DIM_MARKERS = /[\u000e\u000f]/g; -/** Cap on the unrendered archive text tail, in frame-capacity units: enough - * to keep the newest discarded history readable without re-inflating the - * context a compaction just shrank. */ -const TEXT_TAIL_MAX_PAGES = 2; +/** Plain-text history kept verbatim at each chronological edge, in HQ-frame- + * capacity units per edge. One page at the start and one at the end preserves + * high-fidelity context around the imaged middle while keeping the total text + * budget equal to the prior 2-page tail-only scheme. */ +const TEXT_EDGE_PAGES = 1; /** Normalized archive text → plain text: drop zero-width dim toggles and * print newline glyphs as real newlines. */ @@ -740,9 +763,11 @@ export function serializeConversation(messages: Message[], options?: SerializeOp for (const block of msg.content) { if (block.type === "text") { - pendingText.push(stripDimMarkers(block.text)); + const text = stripDimMarkers(block.text); + if (text.trim()) pendingText.push(text); } else if (block.type === "thinking") { - pendingThinking.push(stripDimMarkers(block.thinking)); + const thinking = stripDimMarkers(block.thinking); + if (thinking.trim()) pendingThinking.push(thinking); } else if (block.type === "toolCall") { if (uselessCallIds.has(block.id)) continue; flushAssistant(); @@ -750,11 +775,15 @@ export function serializeConversation(messages: Message[], options?: SerializeOp // Prefer the harness-derived intent, else the raw `_i` arg; render it as // a one-line `//comment` and drop `_i` from the args below. const rawIntent = - typeof block.intent === "string" ? block.intent : typeof args._i === "string" ? args._i : ""; + typeof block.intent === "string" + ? block.intent + : typeof args[INTENT_FIELD] === "string" + ? (args[INTENT_FIELD] as string) + : ""; const intent = stripDimMarkers(rawIntent).replace(/\s+/g, " ").trim(); const argsStr = truncateForSummary( Object.entries(args) - .filter(([key]) => key !== "_i") + .filter(([key]) => key !== INTENT_FIELD) .map( ([key, value]) => `${key}=${truncateForSummary(JSON.stringify(value) ?? "undefined", toolArgMaxChars, headRatio)}`, @@ -807,8 +836,11 @@ function stripOpenAiRemoteCompactionPreserveData( // Text normalization // ============================================================================ -/** Folds for common non-Latin-1 characters the bundled fonts cannot draw. */ +/** Punctuation and symbol folds applied before the NFKD fallback in + * {@link normalize}: quotes, dashes, bullets, arrows, and dot leaders that + * have no compatibility decomposition (or one that is itself non-ASCII). */ const CHAR_FOLD: Record = { + // Quotation marks and primes. "\u2018": "'", "\u2019": "'", "\u201a": "'", @@ -816,18 +848,44 @@ const CHAR_FOLD: Record = { "\u201c": '"', "\u201d": '"', "\u201e": '"', + "\u2032": "'", + "\u2033": '"', + "\u2035": "'", + "\u2036": '"', + "\u2039": "<", + "\u203a": ">", + // Dashes, hyphens, and the fraction slash NFKD leaves in vulgar fractions. + "\u2010": "-", + "\u2011": "-", + "\u2012": "-", "\u2013": "-", "\u2014": "-", "\u2015": "-", "\u2212": "-", + "\u2044": "/", + // Dot leaders and ellipses. + "\u2024": ".", + "\u2025": "..", "\u2026": "...", + "\u22ef": "...", + // Bullets. "\u2022": "*", + "\u2023": "*", + "\u2043": "-", + "\u2219": "*", "\u25cf": "*", "\u25a0": "*", "\u25aa": "*", + // Arrows. "\u2190": "<-", + "\u2191": "^", "\u2192": "->", + "\u2193": "v", + "\u2194": "<->", + "\u21d0": "<=", "\u21d2": "=>", + "\u21d4": "<=>", + // Check marks and crosses. "\u2713": "v", "\u2714": "v", "\u2717": "x", @@ -855,15 +913,48 @@ const EDGE_RUNS = /^[ \u2588]+|[ \u2588]+$/g; * combining marks the fonts cannot compose, and lone surrogates. */ const UNRENDERABLE = /[\p{Cc}\p{Mn}\p{Me}\p{Cs}]/u; +/** Combining marks NFKD splits off accented letters; dropped so the base + * letter prints without the diacritic the bundled fonts cannot compose. */ +const COMBINING_MARKS = /\p{M}+/gu; + +/** + * Aggressive single-code-point ASCII fold via Unicode NFKD: decompose the + * compatibility form (fullwidth, super/subscripts, ligatures, circled and + * math-styled alphanumerics, Roman numerals, vulgar fractions, …), strip the + * combining marks, and keep the ASCII/Latin-1 skeleton — routing any residual + * punctuation back through {@link CHAR_FOLD}. Returns `undefined` when the code + * point has no decomposition or still leaves an undrawable glyph, so the + * caller falls back to `?`. + */ +function foldToAscii(ch: string): string | undefined { + const decomposed = ch.normalize("NFKD").replace(COMBINING_MARKS, ""); + if (decomposed === ch) return undefined; + let out = ""; + for (const part of decomposed) { + const cp = part.codePointAt(0) as number; + if ((cp >= 0x20 && cp < 0x7f) || (cp >= 0xa0 && cp <= 0xff)) { + out += part; + continue; + } + const fold = CHAR_FOLD[part]; + if (fold === undefined) return undefined; + out += fold; + } + return out; +} + /** * Prepare text for printing: strip ANSI escape sequences, collapse horizontal * whitespace runs to single spaces and newline-bearing runs to one * {@link NEWLINE_GLYPH} (drawn as a pitch-black cell), then fold everything - * outside the fonts' ASCII + Latin-1 coverage to ASCII approximations. - * Unrenderable control/format/combining characters are dropped without - * occupying a cell; `?` remains the fallback for unsupported graphic - * characters. The zero-width ink toggles {@link DIM_ON}/{@link DIM_OFF} pass - * through untouched. + * outside the fonts' ASCII + Latin-1 coverage to ASCII approximations — first + * through the {@link CHAR_FOLD} punctuation table, then via an NFKD + * decomposition that recovers the ASCII skeleton of compatibility characters + * (fullwidth, super/subscripts, ligatures, circled/math-styled alphanumerics, + * Roman numerals, vulgar fractions). Unrenderable control/format/combining + * characters are dropped without occupying a cell; `?` remains the fallback + * for unsupported graphic characters. The zero-width ink toggles + * {@link DIM_ON}/{@link DIM_OFF} pass through untouched. */ export function normalize(text: string): string { const stripped = text.includes("\u001b") ? Bun.stripANSI(text) : text; @@ -889,8 +980,10 @@ export function normalize(text: string): string { } else if (cp >= 0x2500 && cp <= 0x257f) { // Box drawing: keep table skeletons legible. out += cp === 0x2502 || cp === 0x2503 ? "|" : cp === 0x2500 || cp === 0x2501 ? "-" : "+"; - } else if (!UNRENDERABLE.test(ch)) { - out += "?"; + } else { + const folded = foldToAscii(ch); + if (folded !== undefined) out += folded; + else if (!UNRENDERABLE.test(ch)) out += "?"; } } return out; @@ -1118,23 +1211,31 @@ export function getPreservedArchive(preserveData: Record | unde const candidate = preserveData?.[PRESERVE_KEY]; if (!candidate || typeof candidate !== "object") return undefined; const archive = candidate as Archive; - if (!Array.isArray(archive.frames)) return undefined; - const frames = archive.frames.filter( - frame => - !!frame && - typeof frame.data === "string" && - frame.data.length > 0 && - typeof frame.mimeType === "string" && - typeof frame.cols === "number" && - typeof frame.rows === "number" && - typeof frame.chars === "number", - ); - if (frames.length === 0) return undefined; + const frames = Array.isArray(archive.frames) + ? archive.frames.filter( + frame => + !!frame && + typeof frame.data === "string" && + frame.data.length > 0 && + typeof frame.mimeType === "string" && + typeof frame.cols === "number" && + typeof frame.rows === "number" && + typeof frame.chars === "number", + ) + : []; + const text = typeof archive.text === "string" && archive.text.length > 0 ? archive.text : undefined; + const textHead = typeof archive.textHead === "string" && archive.textHead.length > 0 ? archive.textHead : undefined; + const textTail = typeof archive.textTail === "string" && archive.textTail.length > 0 ? archive.textTail : undefined; + // A text-only archive (everything fit in the plain-text regions) is valid; + // only an archive carrying neither frames nor text is empty. + if (frames.length === 0 && text === undefined && textHead === undefined && textTail === undefined) return undefined; return { frames, totalChars: typeof archive.totalChars === "number" ? archive.totalChars : 0, truncatedChars: typeof archive.truncatedChars === "number" ? archive.truncatedChars : 0, - ...(typeof archive.textTail === "string" && archive.textTail.length > 0 ? { textTail: archive.textTail } : {}), + ...(text !== undefined ? { text } : {}), + ...(textHead !== undefined ? { textHead } : {}), + ...(textTail !== undefined ? { textTail } : {}), }; } @@ -1147,28 +1248,176 @@ export function images(archive: Archive): ImageContent[] { ...(frame.detail ? { detail: frame.detail } : {}), })); } +/** Ordered archive blocks for a compaction summary message, oldest to newest: + * the oldest text region, the imaged middle, then the newest text region. + * Runtime-only; reconstructed from {@link Archive} on each context rebuild + * instead of persisted on the session entry. */ +export function historyBlocks(archive: Archive): (TextContent | ImageContent)[] { + const blocks: (TextContent | ImageContent)[] = []; + const hasImages = archive.frames.length > 0; + if (archive.textHead) { + const suffix = hasImages ? "\n-------------- imaged middle below\n" : ""; + blocks.push({ type: "text", text: toPlainText(archive.textHead) + suffix }); + } + blocks.push(...images(archive)); + if (archive.textTail) { + const prefix = hasImages + ? "-------------- imaged middle above\n" + : archive.truncatedChars > 0 + ? "\n-------------- middle history omitted above\n" + : ""; + const tail = prefix + toPlainText(archive.textTail); + if (blocks.length > 0 && blocks[blocks.length - 1]?.type === "text") { + (blocks[blocks.length - 1] as TextContent).text += tail; + } else { + blocks.push({ type: "text", text: tail }); + } + } + return blocks; +} // ============================================================================ // Compaction entry point // ============================================================================ +/** Denser companion of `high` for the foveated archive middle: same family and + * frame size (identical per-frame bill) but a tighter cell. Returns `high` + * unchanged for doc layouts or when no denser variant exists (foveation off). */ +function denseCompanion(high: Shape, api: Api | undefined): Shape { + if (high.columns === 2) return high; + const family = billingFamily(api); + const low = priceShape({ ...SHAPE_VARIANTS[FAMILY_VARIANT_LOW[family]], frameSize: high.frameSize }, family); + return geometry(low).capacity > geometry(high).capacity ? low : high; +} + +/** One planned frame: the source slice and the shape (quality tier) to render. */ +interface PlanFrame { + text: string; + shape: Shape; +} + +/** A foveated archive layout: frames oldest→newest for the imaged middle, the + * verbatim text kept at both chronological edges, the flat kept source to + * persist, and the chars dropped this round to fit the budget. */ +interface ArchiveLayout { + frames: PlanFrame[]; + textHead: string; + textTail: string; + keptText: string; + truncatedChars: number; +} + +/** Slice `text` into `capacity`-char frames at one shape (tier). */ +function sliceFrames(text: string, capacity: number, shape: Shape): PlanFrame[] { + const out: PlanFrame[] = []; + for (let offset = 0; offset < text.length; offset += capacity) { + out.push({ text: text.slice(offset, offset + capacity), shape }); + } + return out; +} + +/** + * Lay out the accumulated archive `text` (oldest→newest) with text at both + * chronological edges and images in the middle. One HQ-capacity stays verbatim + * at the oldest edge, one at the newest edge, and the middle between them is + * imaged. If the imaged middle itself overflows `maxFrames`, foveate it + * internally (HQ/LQ/HQ) and drop the oldest slice of its dense center. + */ +function planArchive(text: string, high: Shape, low: Shape, maxFrames: number): ArchiveLayout { + const capHi = geometry(high).capacity; + const edgeCap = TEXT_EDGE_PAGES * capHi; + if (text.length <= 2 * edgeCap) { + return { frames: [], textHead: text, textTail: "", keptText: text, truncatedChars: 0 }; + } + if (maxFrames < 1) { + const textHead = text.slice(0, edgeCap); + const textTail = text.slice(text.length - edgeCap); + return { + frames: [], + textHead, + textTail, + keptText: textHead + textTail, + truncatedChars: text.length - textHead.length - textTail.length, + }; + } + + const textHead = text.slice(0, edgeCap); + const textTail = text.slice(text.length - edgeCap); + const imageText = text.slice(edgeCap, text.length - edgeCap); + if (imageText.length === 0) { + return { frames: [], textHead: text, textTail: "", keptText: text, truncatedChars: 0 }; + } + + // Doc layouts wrap (no char-slicing) and don't foveate: one tier, keep the + // newest pages with the session head pinned, drop the oldest middle. + if (high.columns === 2) { + const pages = docPages(imageText, geometry(high)); + let kept = pages; + let truncatedChars = 0; + if (pages.length > maxFrames) { + const dropped = pages.slice(1, pages.length - (maxFrames - 1)); + truncatedChars = dropped.reduce((sum, page) => sum + page.length, 0); + kept = [...pages.slice(0, 1), ...pages.slice(pages.length - (maxFrames - 1))]; + } + const flat = kept.map(page => page.replaceAll("\n", " ")).join(" "); + return { + frames: kept.map(page => ({ text: page, shape: high })), + textHead, + textTail, + keptText: textHead + flat + textTail, + truncatedChars, + }; + } + + // Grid: render all-HQ when the image region fits the budget outright. + if (Math.ceil(imageText.length / capHi) <= maxFrames) { + return { + frames: sliceFrames(imageText, capHi, high), + textHead, + textTail, + keptText: textHead + imageText + textTail, + truncatedChars: 0, + }; + } + + // Foveate the imaged middle: HQ edges, dense center, drop the oldest dense slice. + const capLo = geometry(low).capacity; + const imageEdgeFrames = Math.min(HQ_EDGE_FRAMES, Math.floor((maxFrames - 1) / 2)); + const imageEdgeCap = imageEdgeFrames * capHi; + const imageHead = imageText.slice(0, imageEdgeCap); + const imageTail = imageEdgeCap > 0 ? imageText.slice(imageText.length - imageEdgeCap) : ""; + let middleText = imageText.slice(imageEdgeCap, imageText.length - imageEdgeCap); + let truncatedChars = 0; + const middleCap = (maxFrames - 2 * imageEdgeFrames) * capLo; + if (middleText.length > middleCap) { + truncatedChars = middleText.length - middleCap; + middleText = middleText.slice(truncatedChars); + } + return { + frames: [ + ...sliceFrames(imageHead, capHi, high), + ...sliceFrames(middleText, capLo, low), + ...sliceFrames(imageTail, capHi, high), + ], + textHead, + textTail, + keptText: textHead + imageHead + middleText + imageTail + textTail, + truncatedChars, + }; +} + /** * Run a snapcompact compaction over prepared messages. Fully local: serializes - * the discarded history, prints it onto PNG frames in the provider-optimal - * shape, merges previously archived frames (oldest dropped beyond the - * budget), and produces a deterministic summary explaining how to read the - * frames. Pages past the frame budget are never rendered (providers with - * hard image caps silently drop excess frames on the wire) — the newest - * unrendered slice survives verbatim as a text tail on the summary and is - * folded back into frames by the next compaction. + * the discarded history, appends it to the accumulated archive source text, and + * re-renders that source into an ordered history layout: plain text at the + * oldest edge, imaged middle, then plain text at the newest edge. The imaged + * middle itself foveates (HQ/LQ/HQ) when it grows large. * - * Frames archived under a different shape (provider switches, legacy 5x8 - * sessions) are kept as-is — each frame carries its own geometry, and the - * summary describes the newest shape while noting that older frames may - * differ. + * The full kept source persists on the archive (`text`) so each later compaction + * unfolds and re-renders it coherently alongside the newly archived history. * - * If the previous compaction was text-based, its summary is printed at the - * head of the frame archive as `[Summary of earlier history]` so no continuity is lost. + * If the previous compaction was text-based, its summary is printed at the head + * of the archive as `[Summary of earlier history]` so no continuity is lost. */ export async function compact( preparation: CompactionPreparation, @@ -1178,10 +1427,14 @@ export async function compact( if (!firstKeptEntryId) { throw new Error("First kept entry has no ID - session may need migration"); } - const shape = options?.shape ?? resolveShape(options?.model); - const frameSize = options?.frameSize ?? shape.frameSize; - const maxFrames = Math.max(1, options?.maxFrames ?? MAX_FRAMES); - const geo = geometry(shape, frameSize); + const baseShape = options?.shape ?? resolveShape(options?.model); + const frameSize = options?.frameSize ?? baseShape.frameSize; + const high = frameSize === baseShape.frameSize ? baseShape : { ...baseShape, frameSize }; + const low = denseCompanion(high, options?.model?.api); + const geo = geometry(high); + // The engine default caps archive growth; a caller-supplied maxFrames only + // lowers it further (an upper limit), never raising it past the default. + const maxFrames = Math.max(1, Math.min(options?.maxFrames ?? MAX_FRAMES_DEFAULT, MAX_FRAMES_DEFAULT)); const messages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages); const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(messages); @@ -1196,127 +1449,103 @@ export async function compact( let truncatedChars = previousArchive?.truncatedChars ?? 0; - // The previous compaction's unframed text tail is the oldest part of this - // archive slice — prepend it so it ages into frames first. - if (previousArchive?.textTail) { - archiveText = - archiveText.length > 0 - ? `${previousArchive.textTail}${NEWLINE_GLYPH}${archiveText}` - : previousArchive.textTail; + // Re-compacting a snapcompacted history unfolds the prior archive's source + // text and treats it as one coherent transcript: the previous kept source + // ages in ahead of the new history, then the whole thing is re-rendered. + const previousText = previousArchive?.text; + if (previousText) { + archiveText = archiveText.length > 0 ? `${previousText}${NEWLINE_GLYPH}${archiveText}` : previousText; } - const pages: string[] = []; - if (shape.columns === 2) { - pages.push(...docPages(archiveText, geo)); - } else { - for (let offset = 0; offset < archiveText.length; offset += geo.capacity) { - pages.push(archiveText.slice(offset, offset + geo.capacity)); - } - } - - // Fit the merged archive into the frame budget BEFORE rendering: pages - // that cannot ship are never rasterized. Old unpinned frames evict first - // (the archive fades oldest-first, as before); new pages that still do - // not fit stay behind as a verbatim text tail instead of being dropped. - const prevFrames = previousArchive?.frames ?? []; - let keptPrev = prevFrames; - if (prevFrames.length + pages.length > maxFrames) { - // Pin the earliest frame: it anchors the session head (the original - // request, or the filmed summary of even older history) the way the - // LLM-summary strategies keep the original goal alive across rounds. - // With a budget of one frame the pin is moot. - const pinCount = maxFrames >= 2 && prevFrames.length > 0 ? 1 : 0; - const evictable = prevFrames.slice(pinCount); - const surviving = Math.min(evictable.length, Math.max(0, maxFrames - pages.length - pinCount)); - const dropped = evictable.slice(0, evictable.length - surviving); - for (const frame of dropped) truncatedChars += frame.chars; - keptPrev = [...prevFrames.slice(0, pinCount), ...evictable.slice(evictable.length - surviving)]; - } - const renderPages = pages.slice(0, maxFrames - keptPrev.length); - const tailPages = pages.slice(renderPages.length); + const layout = planArchive(archiveText, high, low, maxFrames); + truncatedChars += layout.truncatedChars; + // Re-render the planned frames, carrying any open dim span across every + // boundary: textHead → frames → textTail. + let dimOpen = layout.textHead.lastIndexOf(DIM_ON) > layout.textHead.lastIndexOf(DIM_OFF); const newFrames: Frame[] = []; - const finish = pageFinisher(shape); - for (const page of renderPages) { - const rendered = render(finish(page), shape, frameSize); + for (const planned of layout.frames) { + let pageText: string = dimOpen ? DIM_ON + planned.text : planned.text; + dimOpen = pageText.lastIndexOf(DIM_ON) > pageText.lastIndexOf(DIM_OFF); + if (planned.shape.stopwordDim) pageText = dimStopwords(pageText); + const rendered = render(pageText, planned.shape); newFrames.push({ data: rendered.data, mimeType: "image/png", cols: rendered.cols, rows: rendered.rows, chars: rendered.chars, - font: shape.font, - variant: shape.variant, - lineRepeat: shape.lineRepeat, - ...(shape.columns === 2 ? { columns: 2 } : {}), - ...(shape.stopwordDim ? { stopwordDim: true } : {}), - ...(shape.imageDetail ? { detail: shape.imageDetail } : {}), + font: planned.shape.font, + variant: planned.shape.variant, + lineRepeat: planned.shape.lineRepeat, + ...(planned.shape.columns === 2 ? { columns: 2 } : {}), + ...(planned.shape.stopwordDim ? { stopwordDim: true } : {}), + ...(planned.shape.imageDetail ? { detail: planned.shape.imageDetail } : {}), }); // Keep the event loop responsive between native render passes. await Bun.sleep(0); } - // Pages past the budget survive as text, capped at two frames' capacity - // (middle-elided) so an oversized archive cannot blow the context back up. - let textTail = ""; - if (tailPages.length > 0) { - const raw = - shape.columns === 2 ? tailPages.map(page => page.replaceAll("\n", " ")).join(" ") : tailPages.join(""); - const tailCap = TEXT_TAIL_MAX_PAGES * geo.capacity; - if (raw.length > tailCap) truncatedChars += raw.length - tailCap; - // Re-open a dim span the render boundary cut through, so the carried - // tail keeps tool output dim when it lands on frames next compaction. - const renderedText = shape.columns === 2 ? renderPages.join("\n") : renderPages.join(""); - const dimOpen = renderedText.lastIndexOf(DIM_ON) > renderedText.lastIndexOf(DIM_OFF); - textTail = (dimOpen ? DIM_ON : "") + truncateForSummary(raw, tailCap, TRUNCATE_HEAD_RATIO); - } + const textHead = layout.textHead; + const textTail = layout.textTail.length > 0 ? (dimOpen ? DIM_ON : "") + layout.textTail : ""; + const textChars = textHead.length + textTail.length; - const frames = [...keptPrev, ...newFrames]; - const totalChars = frames.reduce((sum, frame) => sum + frame.chars, 0); + const frames = newFrames; + const totalChars = frames.reduce((sum, frame) => sum + frame.chars, 0) + textChars; const mixedShapes = frames.some( frame => frame.cols !== geo.cols || frame.rows !== geo.rows || - (frame.variant ?? "sent") !== shape.variant || - (frame.lineRepeat ?? 1) !== shape.lineRepeat || - (frame.columns ?? 1) !== (shape.columns ?? 1) || - (frame.stopwordDim ?? false) !== (shape.stopwordDim ?? false), + (frame.variant ?? "sent") !== high.variant || + (frame.lineRepeat ?? 1) !== high.lineRepeat || + (frame.columns ?? 1) !== (high.columns ?? 1) || + (frame.stopwordDim ?? false) !== (high.stopwordDim ?? false), ); + const { readFiles, modifiedFiles } = computeFileLists(fileOps); + const files = formatFileList(readFiles, modifiedFiles, fileOps.read); + let summary: string; - if (frames.length === 0) { + if (frames.length === 0 && textHead.length === 0 && textTail.length === 0 && files.length === 0) { summary = "No prior history."; } else { summary = prompt.render(snapcompactSummaryPrompt, { frameCount: frames.length, multipleFrames: frames.length > 1, - fontCell: `${shape.cellWidth}x${shape.cellHeight}`, + docColumns: high.columns === 2, cols: geo.cols, rows: geo.rows, - sentenceInk: shape.variant === "sent", - lineRepeated: shape.lineRepeat > 1, - docColumns: shape.columns === 2, - stopwordDimmed: shape.stopwordDim === true, + sentenceInk: high.variant === "sent", + stopwordDimmed: high.stopwordDim === true, dimmedToolResults: options?.dimToolResults !== false, + lineRepeated: high.lineRepeat > 1, mixedShapes, - totalChars, truncatedChars, includedPreviousSummary, - textTail: textTail.length > 0 ? toPlainText(textTail) : undefined, + files: files.length > 0 ? files : undefined, }); } - const { readFiles, modifiedFiles } = computeFileLists(fileOps); - summary = upsertFileOperations(summary, readFiles, modifiedFiles, fileOps.read); // A snapcompact pass replaces any provider-side replacement history; strip the // OpenAI remote-compaction payload like the default summarizer path does. const basePreserve = stripOpenAiRemoteCompactionPreserveData(previousPreserveData) ?? {}; - const archive: Archive = { frames, totalChars, truncatedChars, ...(textTail ? { textTail } : {}) }; + const persistedText = + layout.keptText.length > 0 && layout.textTail.length > 0 + ? `${layout.keptText.slice(0, layout.keptText.length - layout.textTail.length)}${textTail}` + : layout.keptText; + const archive: Archive = { + frames, + totalChars, + truncatedChars, + ...(persistedText.length > 0 ? { text: persistedText } : {}), + ...(textHead ? { textHead } : {}), + ...(textTail ? { textTail } : {}), + }; - const textTailNote = textTail ? ` (+${textTail.length.toLocaleString()} chars as text)` : ""; + const textNote = textChars > 0 ? ` (+${textChars.toLocaleString()} chars as text)` : ""; return { summary, - shortSummary: `Archived ${totalChars.toLocaleString()} chars of history onto ${frames.length} snapcompact frame${frames.length === 1 ? "" : "s"}${textTailNote}`, + shortSummary: `Archived ${totalChars.toLocaleString()} chars of history onto ${frames.length} snapcompact frame${frames.length === 1 ? "" : "s"}${textNote}`, firstKeptEntryId, tokensBefore, details: { readFiles, modifiedFiles }, diff --git a/packages/snapcompact/test/snapcompact.test.ts b/packages/snapcompact/test/snapcompact.test.ts index b4fb20b97..b7dbe8ffa 100644 --- a/packages/snapcompact/test/snapcompact.test.ts +++ b/packages/snapcompact/test/snapcompact.test.ts @@ -200,6 +200,19 @@ describe("normalize", () => { `x ${snapcompact.DIM_ON}y${snapcompact.DIM_OFF} z`, ); }); + + it("folds compatibility characters to their ASCII skeleton via NFKD", () => { + expect(snapcompact.normalize("x⁵")).toBe("x5"); // superscript outside Latin-1 + expect(snapcompact.normalize("HELLO")).toBe("HELLO"); // fullwidth forms + expect(snapcompact.normalize("file")).toBe("file"); // fi ligature + expect(snapcompact.normalize("step ① then ②")).toBe("step 1 then 2"); // circled digits + expect(snapcompact.normalize("section Ⅻ")).toBe("section XII"); // roman numeral + expect(snapcompact.normalize("⅓ cup")).toBe("1/3 cup"); // vulgar fraction + expect(snapcompact.normalize("𝐇𝐞𝐥𝐥𝐨")).toBe("Hello"); // math-styled alphanumerics + expect(snapcompact.normalize("™ ‹q› ′ ″ ⇐ ↑")).toBe(`TM ' " <= ^`); + // No decomposition and no glyph still falls back to ? (unchanged contract). + expect(snapcompact.normalize("emoji 🎞")).toBe("emoji ?"); + }); }); describe("shape resolution", () => { @@ -476,7 +489,7 @@ describe("serializeConversation", () => { // Default cap 2000 at 0.6 head ratio: 1200 head + 800 tail survive. expect(out).toContain(""); expect(out).toContain("HEAD-"); - expect(out).toContain("[... 3010 chars elided ...]"); + expect(out).toContain("[…3010ch elided…]"); expect(out.endsWith(`-TAIL${snapcompact.DIM_OFF}\n`)).toBe(true); }); @@ -486,7 +499,7 @@ describe("serializeConversation", () => { toolResultMaxChars: 10, truncateHeadRatio: 0.5, }); - expect(tight).toContain("[... 90 chars elided ...]"); + expect(tight).toContain("[…90ch elided…]"); const off = snapcompact.serializeConversation([createToolResultMessage(text)], { toolResultMaxChars: Number.POSITIVE_INFINITY, }); @@ -501,7 +514,7 @@ describe("serializeConversation", () => { ]); // JSON-encoded content is 3002 chars; per-value cap 500 elides 2502. expect(out).toContain('write(path="a.ts", content='); - expect(out).toContain("[... 2502 chars elided ...]"); + expect(out).toContain("[…2502ch elided…]"); }); it("caps the whole serialized argument list per call", () => { @@ -511,7 +524,7 @@ describe("serializeConversation", () => { createAssistantMessage([{ type: "toolCall", id: "c1", name: "tool", arguments: args }]), ]); expect(out).toContain("arg0="); - expect(out).toContain("chars elided"); + expect(out).toContain("ch elided"); // 10 values x ~400 chars collapse to the 2000-char call budget plus markers. expect(out.length).toBeLessThan(2200); }); @@ -609,6 +622,22 @@ describe("serializeConversation", () => { expect(out).toBe("# Assistant ¶\nbefore\nafter"); }); + it("drops blank text/thinking blocks instead of emitting an empty assistant heading", () => { + const out = snapcompact.serializeConversation( + [ + createAssistantMessage([ + { type: "thinking", thinking: " " }, + { type: "text", text: "" }, + { type: "toolCall", id: "c1", name: "read", arguments: { path: "a.ts" } }, + ]), + { ...createToolResultMessage("body"), toolCallId: "c1" } as Message, + ], + { dimToolResults: false }, + ); + expect(out).toBe('# Tool call ¶\nread(path="a.ts")\n\nbody\n'); + expect(out).not.toContain("# Assistant ¶"); + }); + it("wraps tool-result bodies in dim toggles by default and strips stray toggles from content", () => { const out = snapcompact.serializeConversation([ createUserMessage(`hello ${snapcompact.DIM_ON}world`), @@ -636,7 +665,7 @@ describe("serializeConversation", () => { }); describe("compact", () => { - it("archives history onto frames with a self-describing summary", async () => { + it("stores small archives as plain text with no frames", async () => { const fileOps = snapcompact.createFileOps(); fileOps.read.add("src/auth.ts"); fileOps.edited.add("src/login.ts"); @@ -644,164 +673,102 @@ describe("compact", () => { expect(result.firstKeptEntryId).toBe("kept-1"); expect(result.tokensBefore).toBe(99000); - // Reading instructions reflect the default (anthropic 11on16-bw) shape. - expect(result.summary).toContain("29 characters per row"); - expect(result.summary).toContain("dim gray"); - expect(result.summary).toContain("plain black ink"); - expect(result.summary).toContain("snapcompact frame"); - // File operations are upserted like every other compaction summary: - // one grouped tree with per-file access markers. - expect(result.summary).toContain("\n# src/\nauth.ts (Read)\nlogin.ts (Write)\n"); - expect(result.shortSummary).toContain("snapcompact frame"); + expect(result.summary).toContain("You are resuming a prior conversation."); + expect(result.summary).toContain("HISTORY"); + expect(result.summary).toContain("FILES\n===================\n# src/\nauth.ts (Read)\nlogin.ts (Write)"); const archive = snapcompact.getPreservedArchive(result.preserveData); expect(archive).toBeDefined(); - expect(archive?.frames.length).toBe(1); - expect(archive?.frames[0].mimeType).toBe("image/png"); - expect(archive?.frames[0].chars).toBe(archive?.totalChars); - expect(archive?.frames[0].font).toBe("8x13"); - expect(archive?.frames[0].variant).toBe("bw"); - expect(archive?.frames[0].stopwordDim).toBeUndefined(); + expect(archive?.frames).toHaveLength(0); + expect(archive?.textHead).toBeTruthy(); + expect(archive?.textTail).toBeUndefined(); expect(archive?.truncatedChars).toBe(0); - // Frame data round-trips as a decodable PNG. - const decoded = decodePng(Buffer.from(archive?.frames[0].data ?? "", "base64")); - expect(decoded.width).toBe(TEST_FRAME_SIZE); + + const blocks = archive ? snapcompact.historyBlocks(archive) : []; + expect(blocks).toHaveLength(1); + expect(blocks[0]?.type).toBe("text"); }); - it("prints tool results in dim gray ink, persisting the span across frame boundaries", async () => { - // Anthropic shape at 320px holds 800 chars/frame; a 1650-char tool - // result spans three frames, so the reopened span must dim in each. + it("carries dim tool-output spans from text into the first image frame", async () => { const result = await snapcompact.compact( makePreparation({ messagesToSummarize: [createUserMessage("Run the suite."), createToolResultMessage("FAIL ".repeat(330))], }), - { frameSize: TEST_FRAME_SIZE }, + { frameSize: TEST_FRAME_SIZE, maxFrames: 2 }, ); const archive = snapcompact.getPreservedArchive(result.preserveData); - expect(archive?.frames.length).toBeGreaterThanOrEqual(2); - for (const frame of archive?.frames ?? []) { - const decoded = decodePng(Buffer.from(frame.data, "base64")); - // Palette index 9 is the dim tool-output ink. - expect(new Set(decoded.pixels).has(9)).toBe(true); - } - // Conversation text outside the span stays in black bw ink (frame 1). - const first = decodePng(Buffer.from(archive?.frames[0].data ?? "", "base64")); - expect(new Set(first.pixels).has(7)).toBe(true); - expect(result.summary).toContain("dim gray ink"); + expect(archive?.frames.length).toBeGreaterThanOrEqual(1); + const decoded = decodePng(Buffer.from(archive?.frames[0].data ?? "", "base64")); + // Palette index 9 is the dim tool-output ink. + expect(new Set(decoded.pixels).has(9)).toBe(true); }); - it("keeps frames free of dim ink when dimToolResults is false", async () => { + it("keeps image frames free of dim ink when dimToolResults is false", async () => { const result = await snapcompact.compact( makePreparation({ - messagesToSummarize: [createUserMessage("Run."), createToolResultMessage("all good")], + messagesToSummarize: [createUserMessage("Run."), createToolResultMessage("all good ".repeat(200))], }), - { frameSize: TEST_FRAME_SIZE, dimToolResults: false }, + { frameSize: TEST_FRAME_SIZE, maxFrames: 2, dimToolResults: false }, ); const archive = snapcompact.getPreservedArchive(result.preserveData); + expect(archive?.frames.length).toBeGreaterThanOrEqual(1); const decoded = decodePng(Buffer.from(archive?.frames[0].data ?? "", "base64")); expect(new Set(decoded.pixels).has(9)).toBe(false); - expect(result.summary).not.toContain("dim gray ink"); }); - it("keeps history past the frame budget as a text tail instead of dropping it", async () => { - const { capacity } = snapcompact.geometry(snapcompact.SHAPES.anthropic, TEST_FRAME_SIZE); - // Sentences avoid whitespace collapse shrinking the payload below 2.5 frames. - const longText = `${"Important fact number one. ".repeat(Math.ceil((capacity * 2.5) / 28))}Tail sentinel QQZZ.`; + it("keeps plain text at both edges and images in the middle", async () => { + const longText = `HEAD sentinel AA. ${"Important fact number one. ".repeat(400)}TAIL sentinel QQZZ.`; const result = await snapcompact.compact( makePreparation({ messagesToSummarize: [createUserMessage(longText)] }), - { - frameSize: TEST_FRAME_SIZE, - maxFrames: 2, - }, + { frameSize: TEST_FRAME_SIZE, maxFrames: 5 }, ); const archive = snapcompact.getPreservedArchive(result.preserveData); - expect(archive?.frames.length).toBe(2); - // Nothing is dropped: the unrendered remainder ships as text. - expect(archive?.truncatedChars).toBe(0); - expect(archive?.textTail).toContain("QQZZ"); - expect(result.summary).toContain("[Archived history, continued as text]"); - expect(result.summary).toContain("Tail sentinel QQZZ."); - expect(result.shortSummary).toContain("chars as text"); + expect(archive?.frames).toHaveLength(5); + expect(archive?.textHead).toContain("HEAD sentinel AA"); + expect(archive?.textTail).toContain("TAIL sentinel QQZZ"); + + const blocks = archive ? snapcompact.historyBlocks(archive) : []; + expect(blocks[0]?.type).toBe("text"); + expect((blocks[0] as { text: string }).text).toContain("imaged middle below"); + expect(blocks.at(-1)?.type).toBe("text"); + expect((blocks.at(-1) as { text: string }).text).toContain("imaged middle above"); + expect(blocks.filter(block => block.type === "image")).toHaveLength(5); }); - it("folds the previous text tail back into frames on the next compaction", async () => { - const { capacity } = snapcompact.geometry(snapcompact.SHAPES.anthropic, TEST_FRAME_SIZE); - const longText = "Important fact number one. ".repeat(Math.ceil((capacity * 2.5) / 28)); - const first = await snapcompact.compact(makePreparation({ messagesToSummarize: [createUserMessage(longText)] }), { - frameSize: TEST_FRAME_SIZE, - maxFrames: 2, - }); - expect(snapcompact.getPreservedArchive(first.preserveData)?.textTail).toBeTruthy(); + it("uses three HQ image frames on each edge when the budget allows", async () => { + const hugeText = `HEAD sentinel. ${"Important fact number one. ".repeat(1000)}TAIL sentinel.`; + const result = await snapcompact.compact( + makePreparation({ messagesToSummarize: [createUserMessage(hugeText)] }), + { frameSize: TEST_FRAME_SIZE, maxFrames: 7 }, + ); + const archive = snapcompact.getPreservedArchive(result.preserveData); + expect(archive?.frames).toHaveLength(7); + const hiCols = snapcompact.geometry(snapcompact.SHAPES.anthropic, TEST_FRAME_SIZE).cols; + const cols = archive?.frames.map(frame => frame.cols) ?? []; + expect(cols.slice(0, 3)).toEqual([hiCols, hiCols, hiCols]); + expect(cols.slice(-3)).toEqual([hiCols, hiCols, hiCols]); + expect(cols[3]).toBeGreaterThan(hiCols); + }); + it("re-renders later compactions from the kept source text", async () => { + const first = await snapcompact.compact( + makePreparation({ + messagesToSummarize: [createUserMessage("A long first turn. ".repeat(500))], + }), + { frameSize: TEST_FRAME_SIZE, maxFrames: 5 }, + ); const second = await snapcompact.compact( makePreparation({ messagesToSummarize: [createUserMessage("A short follow-up turn.")], previousSummary: first.summary, previousPreserveData: first.preserveData, }), - { frameSize: TEST_FRAME_SIZE, maxFrames: 8 }, + { frameSize: TEST_FRAME_SIZE, maxFrames: 5 }, ); const archive = snapcompact.getPreservedArchive(second.preserveData); - // 2 carried frames + 1 new frame holding (old tail + new turn); no tail left. - expect(archive?.frames.length).toBe(3); - expect(archive?.textTail).toBeUndefined(); - expect(second.summary).not.toContain("[Archived history, continued as text]"); - }); - - it("caps the text tail and counts the elided middle as truncated", async () => { - const { capacity } = snapcompact.geometry(snapcompact.SHAPES.anthropic, TEST_FRAME_SIZE); - // 6 frames of payload at a 1-frame budget → tail capped at 2 frame capacities. - const longText = "Important fact number one. ".repeat(Math.ceil((capacity * 6) / 28)); - const result = await snapcompact.compact( - makePreparation({ messagesToSummarize: [createUserMessage(longText)] }), - { frameSize: TEST_FRAME_SIZE, maxFrames: 1 }, - ); - const archive = snapcompact.getPreservedArchive(result.preserveData); - expect(archive?.frames.length).toBe(1); - expect(archive?.textTail).toContain("chars elided"); - expect(archive?.textTail?.length).toBeLessThan(capacity * 2.5); - expect(archive?.truncatedChars).toBeGreaterThan(0); - }); - - it("keeps a text tail on two-column doc shapes (wrapped pages rejoined flat)", async () => { - const geo = snapcompact.geometry(snapcompact.SHAPES.google, TEST_FRAME_SIZE); - const longText = `${"Important fact number one. ".repeat(Math.ceil((geo.capacity * 3.5) / 28))}Tail sentinel QQZZ.`; - const result = await snapcompact.compact( - makePreparation({ messagesToSummarize: [createUserMessage(longText)] }), - { frameSize: TEST_FRAME_SIZE, maxFrames: 2, shape: snapcompact.SHAPES.google }, - ); - const archive = snapcompact.getPreservedArchive(result.preserveData); - expect(archive?.frames.length).toBe(2); - // The sentinel ends the archive, so it survives any tail cap. - expect(archive?.textTail).toContain("QQZZ"); - // Wrap-induced line breaks flatten to spaces in the tail. - expect(archive?.textTail).not.toContain("\n"); - }); - - it("evicts the oldest unpinned frames, keeping the session-head frame alive", async () => { - let previous: snapcompact.CompactionResult | undefined; - let headFrameData = ""; - let secondFrameData = ""; - for (let pass = 1; pass <= 4; pass++) { - previous = await snapcompact.compact( - makePreparation({ - messagesToSummarize: [createUserMessage(`Distinct turn number ${pass}.`)], - previousSummary: previous?.summary, - previousPreserveData: previous?.preserveData, - }), - { frameSize: TEST_FRAME_SIZE, maxFrames: 3 }, - ); - const archive = snapcompact.getPreservedArchive(previous.preserveData); - if (pass === 1) headFrameData = archive?.frames[0].data ?? ""; - if (pass === 2) secondFrameData = archive?.frames[1].data ?? ""; - } - const final = snapcompact.getPreservedArchive(previous?.preserveData); - expect(final?.frames.length).toBe(3); - // The head frame (original request) is pinned through every eviction; - // the archive fades from the middle out. - expect(final?.frames[0].data).toBe(headFrameData); - expect(final?.frames.some(frame => frame.data === secondFrameData)).toBe(false); - expect(final?.truncatedChars).toBeGreaterThan(0); + expect(archive?.text).toContain("A short follow-up turn."); + expect(archive?.textTail ?? archive?.textHead).toContain("A short follow-up turn."); + expect(archive?.frames.length).toBe(5); }); it("includes the previous text summary when the prior compaction was not snapcompact", async () => { @@ -809,13 +776,11 @@ describe("compact", () => { makePreparation({ previousSummary: "Older context: project scaffolding done." }), { frameSize: TEST_FRAME_SIZE }, ); - expect(result.summary).toContain("[Summary of earlier history]"); + expect(result.summary).toContain("condensed digest of still-older context"); }); - it("carries previous frames forward and strips the OpenAI remote payload", async () => { + it("strips the OpenAI remote payload and preserves unrelated preserveData", async () => { const first = await snapcompact.compact(makePreparation(), { frameSize: TEST_FRAME_SIZE }); - const firstArchive = snapcompact.getPreservedArchive(first.preserveData); - const second = await snapcompact.compact( makePreparation({ messagesToSummarize: [createUserMessage("A new turn happened after the first compaction.")], @@ -829,33 +794,10 @@ describe("compact", () => { { frameSize: TEST_FRAME_SIZE }, ); - const archive = snapcompact.getPreservedArchive(second.preserveData); - expect(archive?.frames.length).toBe(2); - // Oldest frame rides along unchanged, new frame appended after it. - expect(archive?.frames[0].data).toBe(firstArchive?.frames[0].data ?? ""); - // Previous archive present → previous summary is snapcompact boilerplate, not re-archived. expect(second.summary).not.toContain("[Summary of earlier history]"); expect(second.preserveData?.openaiRemoteCompaction).toBeUndefined(); expect(second.preserveData?.appKey).toBe("kept"); }); - - it("flags mixed shapes when merged frames disagree with the active shape", async () => { - const first = await snapcompact.compact(makePreparation(), { - frameSize: TEST_FRAME_SIZE, - shape: snapcompact.SHAPES.legacy, - }); - const second = await snapcompact.compact( - makePreparation({ - messagesToSummarize: [createUserMessage("Another turn after a provider switch.")], - previousSummary: first.summary, - previousPreserveData: first.preserveData, - }), - { frameSize: TEST_FRAME_SIZE, model: { api: "anthropic-messages" } }, - ); - expect(second.summary).toContain("Older frames may use a different font"); - // Same-shape merges stay silent. - expect(first.summary).not.toContain("Older frames may use a different font"); - }); }); describe("archive helpers", () => { @@ -871,7 +813,16 @@ describe("archive helpers", () => { expect(snapcompact.getPreservedArchive({ [snapcompact.PRESERVE_KEY]: valid })).toEqual(valid); }); - it("getPreservedArchive round-trips a persisted text tail", () => { + it("getPreservedArchive round-trips text-only and text-tail archives", () => { + const textOnly: snapcompact.Archive = { + frames: [], + totalChars: 21, + truncatedChars: 0, + text: "older history newer history", + textHead: "older history newer history", + }; + expect(snapcompact.getPreservedArchive({ [snapcompact.PRESERVE_KEY]: textOnly })).toEqual(textOnly); + const archive: snapcompact.Archive = { frames: [{ data: "ZmFrZQ==", mimeType: "image/png", cols: 64, rows: 40, chars: 10 }], totalChars: 10, @@ -881,19 +832,32 @@ describe("archive helpers", () => { expect(snapcompact.getPreservedArchive({ [snapcompact.PRESERVE_KEY]: archive })).toEqual(archive); }); - it("provider budgets respect hard image caps and clamp the frame budget", () => { - // OpenRouter silently drops images past 8 — the budget must match. - expect(snapcompact.providerImageBudget("openrouter")).toBe(8); + it("historyBlocks orders text head, imaged middle, then text tail", () => { + const archive: snapcompact.Archive = { + frames: [{ data: btoa("middle"), mimeType: "image/png", cols: 8, rows: 8, chars: 4 }], + totalChars: 40, + truncatedChars: 0, + text: "head text middle tail text", + textHead: "head text", + textTail: "tail text", + }; + const blocks = snapcompact.historyBlocks(archive); + expect(blocks.map(block => block.type)).toEqual(["text", "image", "text"]); + expect((blocks[0] as { text: string }).text).toContain("head text"); + expect((blocks[1] as { data: string }).data).toBe(btoa("middle")); + expect((blocks[2] as { text: string }).text).toContain("tail text"); + }); + + it("provider image budgets stay permissive while unknown providers keep the safe floor", () => { + expect(snapcompact.providerImageBudget("openrouter")).toBe(90); // Unknown providers fall to the safe floor. expect(snapcompact.providerImageBudget(undefined)).toBe(snapcompact.DEFAULT_PROVIDER_IMAGE_BUDGET); expect(snapcompact.providerImageBudget("some-new-router")).toBe(snapcompact.DEFAULT_PROVIDER_IMAGE_BUDGET); - // Archive frames never exceed MAX_FRAMES even on permissive providers, - // and never exceed the provider's own cap on strict ones. - expect(snapcompact.providerFrameBudget("anthropic")).toBe(snapcompact.MAX_FRAMES); - expect(snapcompact.providerFrameBudget("openrouter")).toBeLessThanOrEqual(8); - expect(snapcompact.providerFrameBudget("some-new-router")).toBe(snapcompact.DEFAULT_PROVIDER_IMAGE_BUDGET); expect(snapcompact.providerImageBudget("openai-codex")).toBe(200); - expect(snapcompact.providerFrameBudget("openai-codex")).toBe(snapcompact.MAX_FRAMES); + // The default frame budget must stay under the Anthropic image wire cap: + // compaction no longer clamps the archive per provider, so a default above + // the cap would silently drop frames or error on large-window Claude. + expect(snapcompact.MAX_FRAMES_DEFAULT).toBeLessThanOrEqual(snapcompact.providerImageBudget("anthropic")); }); }); diff --git a/packages/stats/CHANGELOG.md b/packages/stats/CHANGELOG.md index 39bdbe834..fb58ff0f9 100644 --- a/packages/stats/CHANGELOG.md +++ b/packages/stats/CHANGELOG.md @@ -2,6 +2,16 @@ ## [Unreleased] +## [16.0.10] - 2026-06-18 + +### Changed + +- Updated description of moderated content categories to use more inclusive terminology + +### Fixed + +- Wide data tables (Requests, Errors, Overview, Projects) overflowed the page horizontally at narrow-desktop widths (768-1023px): the `.stats-table-desktop-only` wrapper used for mobile-card tables lacked the `overflow-x: auto` containment that `.stats-table-container` already has. They now scroll within their own bounds instead of spilling the page body. + ## [16.0.5] - 2026-06-17 ### Added diff --git a/packages/stats/package.json b/packages/stats/package.json index 58fef81c7..7ac16c7a7 100644 --- a/packages/stats/package.json +++ b/packages/stats/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/omp-stats", - "version": "16.0.9", + "version": "16.1.1", "description": "Local observability dashboard for pi AI usage statistics", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/stats/src/client/styles.css b/packages/stats/src/client/styles.css index 0cfd21d13..72474a539 100644 --- a/packages/stats/src/client/styles.css +++ b/packages/stats/src/client/styles.css @@ -729,6 +729,13 @@ display: none; } +/* Match .stats-table-container so mobile-card tables also contain horizontal + overflow instead of spilling the page body on narrow desktops (768-1023px). */ +.stats-table-desktop-only { + width: 100%; + overflow-x: auto; +} + @media (max-width: 767px) { .stats-table-desktop-only { display: none; diff --git a/packages/stats/src/user-metrics.ts b/packages/stats/src/user-metrics.ts index f09bffbe9..438346729 100644 --- a/packages/stats/src/user-metrics.ts +++ b/packages/stats/src/user-metrics.ts @@ -68,7 +68,7 @@ export interface UserMessageMetrics { * Words considered profane/aggressive. Word-boundary, case-insensitive. * * Broad English coverage: f-/s-word families and their censored variants, - * mild swears, intelligence-based insults, body-part epithets, British/ + * mild swears, intelligence-based insults, body-part insults, British/ * Australian/Irish slang, religious exclamations, chat acronyms, and * frustration interjections. Curated to exclude racial, homophobic, and * other identity slurs. diff --git a/packages/swarm-extension/package.json b/packages/swarm-extension/package.json index 30de2d119..98fce3f35 100644 --- a/packages/swarm-extension/package.json +++ b/packages/swarm-extension/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/swarm-extension", - "version": "16.0.9", + "version": "16.1.1", "description": "Swarm orchestration extension for omp", "homepage": "https://omp.sh", "author": "Derek Rynd", diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 2cd34b89b..ad1296c53 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,36 @@ ## [Unreleased] +## [16.1.0] - 2026-06-19 + +### Added + +- `Box` now accepts an optional `border` (box-drawing glyphs + colorizer) and exposes `setBorder()`, drawing a colored outline around its padded/background content. The border is automatically dropped at widths too narrow to frame so a bordered box never overflows its given width. + +## [16.0.11] - 2026-06-19 + +### Breaking Changes + +- Removed `getIndentation` and `getIndentationNoescape` exported utilities +- Tab-related operations no longer respect per-file or globally configured indentation settings + +### Changed + +- Standardized tab expansion to use a fixed display width instead of configurable settings +- Removed support for custom tab width configuration in text rendering and input handling + +### Fixed + +- Corrected logic in string truncation to prevent improper truncation of short strings +- Fixed a one-frame transcript flash during a non-multiplexer resize drag: while the drag borrowed the alternate screen and painted only the viewport, any ordinary (non-forced) render from a still-animating block — a tool spinner tick, a streamed token, a cursor blink — fell through to the deferred geometry-rebuild full paint, which left the alternate screen to repaint the whole transcript on the normal screen for a single frame before the next SIGWINCH re-entered the viewport fast path, so a live tool block flashed in and vanished. Ordinary renders mid-drag now stay on the viewport fast path; only forced renders (tool finalization, reset, image reconciliation) still preempt it. + +## [16.0.10] - 2026-06-18 + +### Fixed + +- Fixed Markdown renderer rendering raw HTML tags (like `
`, `
  • `, `
      `, `
        `, and `

        `) literally in the terminal by parsing and converting them to appropriate terminal formatting, preserving repeated HTML line breaks, nested HTML list indentation, ordered list numbering, paragraph-wrapped list item markers, paragraph separation, and table sizing after HTML line breaks. +- Fixed animated working-message loader frames repainting at 30fps on terminals without synchronized-output support, which could cause visible flicker during normal prompt rendering ([#2771](https://github.com/can1357/oh-my-pi/issues/2771)). + ## [16.0.9] - 2026-06-18 ### Fixed diff --git a/packages/tui/package.json b/packages/tui/package.json index 735784469..960c66ffc 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-tui", - "version": "16.0.9", + "version": "16.1.1", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/tui/src/components/box.ts b/packages/tui/src/components/box.ts index 2959147a4..1ba1c7212 100644 --- a/packages/tui/src/components/box.ts +++ b/packages/tui/src/components/box.ts @@ -4,10 +4,24 @@ import { applyBackgroundToLine, getPaddingX, padding, visibleWidth } from "../ut type Cache = { width: number; bgSample: string | undefined; + borderSample: string | undefined; childLines: (readonly string[])[]; result: string[]; }; +/** Box-drawing glyphs plus an optional colorizer for an outline drawn around a {@link Box}. */ +export interface BoxBorder { + chars: { + topLeft: string; + topRight: string; + bottomLeft: string; + bottomRight: string; + horizontal: string; + vertical: string; + }; + color?: (text: string) => string; +} + /** * Box component - a container that applies padding and background to all children */ @@ -16,6 +30,7 @@ export class Box implements Component { #paddingX: number; #paddingY: number; #bgFn?: (text: string) => string; + #border?: BoxBorder; #ignoreTight = false; @@ -28,10 +43,11 @@ export class Box implements Component { // Cache for rendered output #cached?: Cache; - constructor(paddingX = 1, paddingY = 1, bgFn?: (text: string) => string) { + constructor(paddingX = 1, paddingY = 1, bgFn?: (text: string) => string, border?: BoxBorder) { this.#paddingX = paddingX; this.#paddingY = paddingY; this.#bgFn = bgFn; + this.#border = border; } addChild(component: Component): void { @@ -72,6 +88,11 @@ export class Box implements Component { // Don't invalidate here - we'll detect bgFn changes by sampling output } + setBorder(border?: BoxBorder): void { + this.#border = border; + this.#invalidateCache(); + } + #invalidateCache(): void { this.#cached = undefined; } @@ -87,10 +108,18 @@ export class Box implements Component { const children = this.children; const count = children.length; const paddingX = this.#ignoreTight ? this.#paddingX : getPaddingX(this.#paddingX); - const contentWidth = Math.max(1, width - paddingX * 2); - // bgFn output can change without the function reference changing (theme - // mutation); sample it so a silent palette swap still misses the cache. + // A border eats one column on each side; skip it unless the interior can still + // hold the horizontal padding plus at least one content column, so a bordered + // Box never overflows the width it was given. + const border = this.#border && width - 2 >= paddingX * 2 + 1 ? this.#border : undefined; + const innerWidth = border ? width - 2 : width; + const contentWidth = Math.max(1, innerWidth - paddingX * 2); + // bgFn / border output can change without the function reference changing + // (theme mutation); sample both so a silent palette swap still misses the cache. const bgSample = this.#bgFn ? this.#bgFn("test") : undefined; + const borderSample = border + ? `${border.color ? border.color("|") : "|"}${border.chars.topLeft}${border.chars.vertical}` + : undefined; // Render every child every frame (renders may carry side effects); the // memo only skips re-deriving the padded/background rows. Per the @@ -101,6 +130,7 @@ export class Box implements Component { cached !== undefined && cached.width === width && cached.bgSample === bgSample && + cached.borderSample === borderSample && cached.childLines.length === count; const childLines: (readonly string[])[] = new Array(count); let contentRows = 0; @@ -115,23 +145,39 @@ export class Box implements Component { const result: string[] = []; if (contentRows > 0) { const leftPad = padding(paddingX); + const interior: string[] = []; // Top padding for (let i = 0; i < this.#paddingY; i++) { - result.push(this.#applyBg("", width)); + interior.push(this.#applyBg("", innerWidth)); } // Content for (const lines of childLines) { for (const line of lines) { - result.push(this.#applyBg(leftPad + line, width)); + interior.push(this.#applyBg(leftPad + line, innerWidth)); } } // Bottom padding for (let i = 0; i < this.#paddingY; i++) { - result.push(this.#applyBg("", width)); + interior.push(this.#applyBg("", innerWidth)); + } + + if (border) { + const paint = border.color ?? (s => s); + const rule = border.chars.horizontal.repeat(Math.max(0, innerWidth)); + const side = paint(border.chars.vertical); + result.push(paint(border.chars.topLeft + rule + border.chars.topRight)); + for (const row of interior) { + result.push(side + row + side); + } + result.push(paint(border.chars.bottomLeft + rule + border.chars.bottomRight)); + } else { + for (const row of interior) { + result.push(row); + } } } - this.#cached = { width, bgSample, childLines, result }; + this.#cached = { width, bgSample, borderSample, childLines, result }; return result; } diff --git a/packages/tui/src/components/loader.ts b/packages/tui/src/components/loader.ts index 65a5c1292..387d5c4c7 100644 --- a/packages/tui/src/components/loader.ts +++ b/packages/tui/src/components/loader.ts @@ -57,12 +57,15 @@ export class Loader extends Text { this.#intervalId = setInterval(() => { const now = performance.now(); const elapsed = now - this.#lastSpinnerTick; - if (elapsed >= SPINNER_ADVANCE_MS) { + const shouldAdvanceSpinner = elapsed >= SPINNER_ADVANCE_MS; + if (shouldAdvanceSpinner) { const steps = Math.floor(elapsed / SPINNER_ADVANCE_MS); this.#currentFrame = (this.#currentFrame + steps) % this.#frames.length; this.#lastSpinnerTick += steps * SPINNER_ADVANCE_MS; } - this.#updateDisplay(); + if (shouldAdvanceSpinner || this.#ui?.synchronizedOutput === true) { + this.#updateDisplay(); + } }, intervalMs); } diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 92afbb928..ef5187972 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -31,6 +31,167 @@ function isOsc66Line(line: string): boolean { return line.includes(OSC66_LINE_PREFIX); } +function normalizeHtmlEntitiesForTerminal(raw: string): string { + return raw.replace(/ /gi, " "); +} + +interface HtmlListState { + type: "ol" | "ul"; + next: number; +} + +interface HtmlNormalizationState { + lists: HtmlListState[]; + openItems: boolean[]; + itemHasContent: boolean[]; +} + +function createHtmlNormalizationState(): HtmlNormalizationState { + return { lists: [], openItems: [], itemHasContent: [] }; +} + +const HTML_TAG_REGEX = /<\/?(?:br|p|ol|ul|li)\b(?:\s[^>]*)?\s*\/?>/gi; + +function htmlTagName(tag: string): string { + const match = /^<\/?\s*([A-Za-z][A-Za-z0-9:-]*)/.exec(tag); + return match ? match[1].toLowerCase() : ""; +} + +function htmlOlStart(tag: string): number { + const match = /\bstart\s*=\s*(?:"(\d+)"|'(\d+)'|(\d+))/i.exec(tag); + if (!match) return 1; + return Number(match[1] ?? match[2] ?? match[3]); +} + +function appendHtmlLineBreak(output: string, force: boolean = false): string { + const trimmed = output.replace(/[ \t]+$/u, ""); + return !force && trimmed.endsWith("\n") ? trimmed : `${trimmed}\n`; +} + +function htmlListIndent(state: HtmlNormalizationState): string { + return " ".repeat(Math.max(0, state.lists.length - 1)); +} + +function appendHtmlListBreak(output: string, state: HtmlNormalizationState): string { + const indent = htmlListIndent(state); + return output.endsWith(`${indent}\n`) ? output : appendHtmlLineBreak(output); +} + +function markCurrentHtmlItemContent(state: HtmlNormalizationState, text: string): void { + if (text.trim() !== "" && state.itemHasContent.length > 0) { + state.itemHasContent[state.itemHasContent.length - 1] = true; + } +} + +function isAtEmptyHtmlListItem(state: HtmlNormalizationState): boolean { + const itemIndex = state.itemHasContent.length - 1; + return state.openItems[itemIndex] === true && state.itemHasContent[itemIndex] !== true; +} + +function normalizeHtmlForTerminal(raw: string, state: HtmlNormalizationState = createHtmlNormalizationState()): string { + let output = ""; + let lastIndex = 0; + + for (const match of raw.matchAll(HTML_TAG_REGEX)) { + const tag = match[0]; + const index = match.index ?? 0; + const textBeforeTag = normalizeHtmlEntitiesForTerminal(raw.slice(lastIndex, index)); + // HTML formatting whitespace between block/list tags (e.g. the newlines and + // indentation in pretty-printed `

          \n
        • …`) is not rendered content; + // appending it literally would leak source indentation before bullets and + // blank rows between items. Every tag handled here is block-level, so a + // whitespace-only slice is always insignificant formatting and is dropped. + if (textBeforeTag.trim() !== "") { + output += textBeforeTag; + markCurrentHtmlItemContent(state, textBeforeTag); + } + lastIndex = index + tag.length; + + const name = htmlTagName(tag); + const isClosing = /^<\//.test(tag); + const isSelfClosing = /\/\s*>$/.test(tag); + + switch (name) { + case "br": + output = appendHtmlLineBreak(output, true); + break; + case "p": + if (isClosing) { + output = appendHtmlLineBreak(output); + } else if (output.trim() !== "" && !output.endsWith("\n") && !isAtEmptyHtmlListItem(state)) { + output = appendHtmlLineBreak(output); + } + break; + case "ol": + if (isClosing) { + state.lists.pop(); + state.openItems.pop(); + state.itemHasContent.pop(); + } else if (!isSelfClosing) { + if (state.openItems.length > 0 && state.openItems[state.openItems.length - 1]) { + output = appendHtmlListBreak(output, state); + } + state.lists.push({ type: "ol", next: htmlOlStart(tag) }); + state.openItems.push(false); + state.itemHasContent.push(false); + } + break; + case "ul": + if (isClosing) { + state.lists.pop(); + state.openItems.pop(); + state.itemHasContent.pop(); + } else if (!isSelfClosing) { + if (state.openItems.length > 0 && state.openItems[state.openItems.length - 1]) { + output = appendHtmlListBreak(output, state); + } + state.lists.push({ type: "ul", next: 1 }); + state.openItems.push(false); + state.itemHasContent.push(false); + } + break; + case "li": { + if (isClosing) { + output = appendHtmlLineBreak(output); + break; + } + if (state.openItems.length > 0) { + const itemOpenIndex = state.openItems.length - 1; + if (state.openItems[itemOpenIndex]) output = appendHtmlListBreak(output, state); + state.openItems[itemOpenIndex] = true; + state.itemHasContent[itemOpenIndex] = false; + } else if (output.trim() !== "" && !output.endsWith("\n")) { + output = appendHtmlLineBreak(output); + } + const list = state.lists[state.lists.length - 1]; + const indent = htmlListIndent(state); + if (list?.type === "ol") { + output += `${indent}${list.next}. `; + list.next++; + } else { + output += `${indent}• `; + } + break; + } + default: + output += tag; + break; + } + } + + const remainingText = normalizeHtmlEntitiesForTerminal(raw.slice(lastIndex)); + markCurrentHtmlItemContent(state, remainingText); + return output + remainingText; +} + +function splitTerminalLines(text: string): string[] { + const lines = text.split("\n"); + while (lines.length > 1 && lines[lines.length - 1] === "") { + lines.pop(); + } + return lines; +} + class StrictStrikethroughTokenizer extends Tokenizer { override del(src: string): Tokens.Del | undefined { const match = STRICT_STRIKETHROUGH_REGEX.exec(src); @@ -61,6 +222,56 @@ markdownParser.setOptions({ // never math. Inline extensions run before marked's escape tokenizer, so // `\(…\)` becomes math while a genuinely escaped `\$` is left to `escape` and // renders as a literal dollar. +const CUSTOM_HR_START_REGEX = /(?:^|\n) {0,3}([-*_─━═=–—])[ \t]*(?:\1[ \t]*){2,}(?:\n+|$)/; +const CUSTOM_HR_TOKENIZER_REGEX = /^ {0,3}([-*_─━═=–—])[ \t]*(?:\1[ \t]*){2,}(?:\n+|$)/; + +function getHrChar(char: string, hrChar: string): string { + const isAscii = hrChar === "-"; + switch (char) { + case "=": + return "="; + case "═": + return isAscii ? "=" : "═"; + case "━": + return isAscii ? "-" : "━"; + case "─": + return isAscii ? "-" : "─"; + case "–": + return isAscii ? "-" : "–"; + case "—": + return isAscii ? "-" : "—"; + default: + return hrChar; + } +} + +const customHrExtension: TokenizerAndRendererExtension = { + name: "customHr", + level: "block", + start(src) { + const match = CUSTOM_HR_START_REGEX.exec(src); + if (!match) return undefined; + let idx = match.index; + if (src[idx] === "\n") { + idx += 1; + } + return idx; + }, + tokenizer(src) { + const match = CUSTOM_HR_TOKENIZER_REGEX.exec(src); + if (match) { + return { + type: "hr", + raw: match[0], + }; + } + return undefined; + }, + renderer() { + return ""; + }, +}; + const mathExtension: TokenizerAndRendererExtension = { name: "math", level: "inline", @@ -171,7 +382,7 @@ const mathEnvBlockExtension: TokenizerAndRendererExtension = { return (token as { text?: string }).text ?? ""; }, }; -markdownParser.use({ extensions: [mathBlockExtension, mathEnvBlockExtension, mathExtension] }); +markdownParser.use({ extensions: [customHrExtension, mathBlockExtension, mathEnvBlockExtension, mathExtension] }); // --------------------------------------------------------------------------- // Module-level LRU render cache @@ -199,16 +410,14 @@ export function clearRenderCache(): void { } // Stable numeric IDs for structural theme/style objects (no ID field on type). -// Symbol-keyed so the id travels with the object and is invisible to consumers. -const kObjectId = Symbol("markdown.objectId"); -type WithObjectId = object & { [kObjectId]?: number }; +// WeakMap-keyed so the ID matches strict object identity and doesn't get copied by spread/cloning. +const themeObjectIds = new WeakMap(); let nextObjectId = 0; function objectId(o: object): number { - const tagged = o as WithObjectId; - let id = tagged[kObjectId]; + let id = themeObjectIds.get(o); if (id === undefined) { id = nextObjectId++; - tagged[kObjectId] = id; + themeObjectIds.set(o, id); } return id; } @@ -989,17 +1198,25 @@ export class Markdown implements Component { break; } - case "hr": - lines.push(this.#theme.hr(this.#theme.symbols.hrChar.repeat(Math.min(width, 80)))); + case "hr": { + const raw = "raw" in token && typeof token.raw === "string" ? token.raw.trim() : ""; + const char = raw[0] || ""; + const fillChar = getHrChar(char, this.#theme.symbols.hrChar); + lines.push(this.#theme.hr(fillChar.repeat(Math.min(width, 80)))); if (nextTokenType && nextTokenType !== "space") { lines.push(""); // Add spacing after horizontal rules (unless space token follows) } break; + } case "html": - // Render HTML as plain text (escaped for terminal) if ("raw" in token && typeof token.raw === "string") { - lines.push(this.#applyDefaultStyle(token.raw.trim())); + const cleaned = normalizeHtmlForTerminal(token.raw); + const blockLines = splitTerminalLines(cleaned); + for (const line of blockLines) { + const trimmed = line.trimEnd(); + lines.push(trimmed.trim() === "" ? "" : this.#applyDefaultStyle(trimmed)); + } } break; @@ -1024,31 +1241,45 @@ export class Markdown implements Component { const { applyText, stylePrefix } = resolvedStyleContext; const applyTextWithNewlines = (text: string): string => { const segments: string[] = text.split("\n"); - return segments.map((segment: string) => applyText(segment)).join("\n"); + return segments.map((segment: string) => (segment === "" ? "" : applyText(segment))).join("\n"); }; const swatchGlyph = this.#theme.symbols.colorSwatch || DEFAULT_COLOR_SWATCH_GLYPH; + let trimLeadingWhitespace = false; + const htmlState = createHtmlNormalizationState(); + const markHtmlItemWhenContent = (text: string): void => { + markCurrentHtmlItemContent(htmlState, text); + }; for (const token of tokens) { if (isMathToken(token)) { + markHtmlItemWhenContent(token.text); result += applyTextWithNewlines(renderMathToken(token.text)); continue; } switch (token.type) { - case "text": + case "text": { + const rawText = trimLeadingWhitespace ? token.text.replace(/^\s+/, "") : token.text; + const text = normalizeHtmlEntitiesForTerminal(rawText); + trimLeadingWhitespace = false; + markHtmlItemWhenContent(text); + if (token.tokens) markHtmlItemWhenContent(plainInlineTokens(token.tokens)); // Text tokens in list items can have nested tokens for inline formatting if (token.tokens && token.tokens.length > 0) { result += this.#renderInlineTokens(token.tokens, resolvedStyleContext); } else { - result += renderTextWithSwatches(token.text, applyTextWithNewlines, swatchGlyph); + result += renderTextWithSwatches(text, applyTextWithNewlines, swatchGlyph); } break; + } case "paragraph": // Paragraph tokens contain nested inline tokens + markHtmlItemWhenContent(plainInlineTokens(token.tokens || [])); result += this.#renderInlineTokens(token.tokens || [], resolvedStyleContext); break; case "strong": { + markHtmlItemWhenContent(plainInlineTokens(token.tokens || [])); const boldContent = this.#renderInlineTokens(token.tokens || [], resolvedStyleContext); result += this.#theme.bold(boldContent) + stylePrefix; break; @@ -1056,16 +1287,19 @@ export class Markdown implements Component { case "em": { const italicContent = this.#renderInlineTokens(token.tokens || [], resolvedStyleContext); + markHtmlItemWhenContent(plainInlineTokens(token.tokens || [])); result += this.#theme.italic(italicContent) + stylePrefix; break; } case "codespan": { + markHtmlItemWhenContent(token.text); result += codespanSwatch(token.text, swatchGlyph) + this.#theme.code(token.text) + stylePrefix; break; } case "link": { + markHtmlItemWhenContent(token.text); const linkText = this.#renderInlineTokens(token.tokens || [], resolvedStyleContext); const styledLinkText = this.#theme.link(this.#theme.underline(linkText)); const clickableLinkText = formatHyperlink(styledLinkText, token.href); @@ -1085,25 +1319,36 @@ export class Markdown implements Component { case "br": result += "\n"; + trimLeadingWhitespace = true; break; case "del": { const delContent = this.#renderInlineTokens(token.tokens || [], resolvedStyleContext); + markHtmlItemWhenContent(plainInlineTokens(token.tokens || [])); result += this.#theme.strikethrough(delContent) + stylePrefix; break; } case "html": - // Render inline HTML as plain text if ("raw" in token && typeof token.raw === "string") { - result += applyTextWithNewlines(token.raw); + const cleaned = normalizeHtmlForTerminal(token.raw, htmlState); + result += applyTextWithNewlines(cleaned); + if (cleaned.endsWith("\n")) { + trimLeadingWhitespace = true; + } else if (cleaned.length > 0) { + trimLeadingWhitespace = false; + } } break; default: // Handle any other inline token types as plain text if ("text" in token && typeof token.text === "string") { - result += applyTextWithNewlines(token.text); + const rawText = trimLeadingWhitespace ? token.text.replace(/^\s+/, "") : token.text; + const text = normalizeHtmlEntitiesForTerminal(rawText); + trimLeadingWhitespace = false; + markHtmlItemWhenContent(text); + result += applyTextWithNewlines(text); } } } @@ -1260,6 +1505,10 @@ export class Markdown implements Component { return Math.min(longest, maxWidth); } + #terminalLineWidths(text: string): number[] { + return splitTerminalLines(text).map(line => visibleWidth(line)); + } + /** * Wrap a table cell to fit into a column. * @@ -1267,7 +1516,8 @@ export class Markdown implements Component { * consistently with the rest of the renderer. */ #wrapCellText(text: string, maxWidth: number): string[] { - return wrapTextWithAnsi(text, Math.max(1, maxWidth)); + const cellWidth = Math.max(1, maxWidth); + return splitTerminalLines(text).flatMap(line => wrapTextWithAnsi(line, cellWidth)); } /** @@ -1307,13 +1557,15 @@ export class Markdown implements Component { const minWordWidths: number[] = []; for (let i = 0; i < numCols; i++) { const headerText = this.#renderInlineTokens(token.header[i].tokens || [], styleContext); - naturalWidths[i] = visibleWidth(headerText); + const headerLineWidths = this.#terminalLineWidths(headerText); + naturalWidths[i] = Math.max(...headerLineWidths, 0); minWordWidths[i] = Math.max(1, this.#getLongestWordWidth(headerText, maxUnbrokenWordWidth)); } for (const row of token.rows) { for (let i = 0; i < row.length; i++) { const cellText = this.#renderInlineTokens(row[i].tokens || [], styleContext); - naturalWidths[i] = Math.max(naturalWidths[i] || 0, visibleWidth(cellText)); + const cellLineWidths = this.#terminalLineWidths(cellText); + naturalWidths[i] = Math.max(naturalWidths[i] || 0, ...cellLineWidths); minWordWidths[i] = Math.max( minWordWidths[i] || 1, this.#getLongestWordWidth(cellText, maxUnbrokenWordWidth), diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 137c2dcd0..23b22cde7 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -1005,11 +1005,6 @@ export class TUI extends Container { // fast path (`#renderResizeViewport`) instead of an authoritative full // paint, and no commit/window/diff state is advanced. #resizeViewportActive = false; - // Set only by the resize callback's cheap-paint request. A concurrent - // caller-forced render (tool finalization, reset, image reconciliation) must - // not be downgraded to the throwaway viewport path just because a resize - // settle window is active. - #resizeViewportPaintPending = false; // Quiet-window timer that ends the drag: its callback clears the flag and // drives the one authoritative full paint. Reset on every resize event so it // only fires once the drag stops. Cancelled on stop(). @@ -1792,7 +1787,6 @@ export class TUI extends Container { // Any non-component-scoped request makes the pending frame a full one. this.#pendingRenderComponentsOnly = false; if (force) { - this.#resizeViewportPaintPending = false; // Forced repaints landing inside the multiplexer resize debounce // (e.g. `#finishSixelProbe`, image-budget eviction, a programmatic // `requestRender(true)`) would paint into a still-reflowing pane @@ -2471,16 +2465,30 @@ export class TUI extends Container { // Strictly state-isolated: it never consumes #resizeEventPending nor // advances any commit/window/diff field, so the authoritative full paint // the settle timer queues reconciles as if these throwaway frames never - // ran. A visible overlay composites over the transcript and needs the - // whole window, so fall through to the normal forced paint when one is up - // (overlay resizes are not on the drag-cost hot path). + // ran. Two render sources reach here mid-drag and BOTH must stay on this + // path: + // - the resize callback's own cheap paint after each SIGWINCH; + // - an ordinary (non-forced) render from a live block that keeps + // animating through the drag — a spinner tick, a streamed token, a + // cursor blink — firing requestRender(false)/requestComponentRender. + // #resizeEventPending is still set (the fast path never consumed it), + // so without this branch the ordinary render falls through to the + // geometry-rebuild full paint below, which LEAVES the borrowed + // alternate screen to repaint the whole transcript on the normal + // screen — then the next SIGWINCH re-enters the alt screen and paints + // only the tail, so the block flashes in for one frame and vanishes. + // A forced render (tool finalization, reset, image reconciliation) must + // still preempt: it set #forceViewportRepaintOnNextRender via + // #prepareForcedRender and owns the next authoritative paint, so it falls + // through. A visible overlay composites over the transcript and needs the + // whole window, so it also falls through (overlay resizes are not on the + // drag-cost hot path). if ( - this.#resizeViewportPaintPending && this.#resizeViewportActive && + !this.#forceViewportRepaintOnNextRender && this.#hasEverRendered && this.#getTopmostVisibleOverlay() === undefined ) { - this.#resizeViewportPaintPending = false; this.#componentRenderTargets.clear(); this.#renderResizeViewport(width, height); return; @@ -3145,7 +3153,6 @@ export class TUI extends Container { #requestResizeViewportPaint(): void { if (this.#stopped) return; - this.#resizeViewportPaintPending = true; this.#renderRequested = false; this.#lastRenderAt = this.#renderScheduler.now(); this.#doRender(); diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index d38dd3353..3ff0d8a1f 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -7,11 +7,11 @@ import { wrapTextWithAnsi as nativeWrapTextWithAnsi, type SliceResult, } from "@oh-my-pi/pi-natives"; -import { getDefaultTabWidth, getIndentation } from "@oh-my-pi/pi-utils"; +import { DEFAULT_TAB_WIDTH } from "@oh-my-pi/pi-utils"; export { Ellipsis } from "@oh-my-pi/pi-natives"; -export { getDefaultTabWidth, getIndentation } from "@oh-my-pi/pi-utils"; +export { DEFAULT_TAB_WIDTH } from "@oh-my-pi/pi-utils"; export type TextSizingScale = 1 | 2 | 3; export type TextSizingVerticalAlign = "top" | "bottom" | "center"; @@ -74,35 +74,32 @@ export function encodeTextSized(text: string, options: TextSizingOptions = {}): } export function sliceWithWidth(line: string, startCol: number, length: number, strict?: boolean | null): SliceResult { - return nativeSliceWithWidth(line, startCol, length, strict ?? null, getDefaultTabWidth()); + return nativeSliceWithWidth(line, startCol, length, strict ?? null, DEFAULT_TAB_WIDTH); } export function truncateToWidth( text: string, maxWidth: number, - ellipsisKind?: Ellipsis | null, + ellipsisKind?: Ellipsis | null | "", pad?: boolean | null, ): string { - // Guard nullish napi inputs: napi-rs 3 on the Windows prebuilt rejects - // `null` for `Option` (Ellipsis) / `Option` (pad) (issue #848), - // and `maxWidth` is a required `u32` that throws on `null`/`undefined` - // everywhere. Pass concrete defaults that mirror the Rust `unwrap_or`s. - const safeWidth = Number.isFinite(maxWidth) ? Math.max(0, Math.trunc(maxWidth)) : 0; - let resolvedEllipsis: Ellipsis | null | undefined | string = ellipsisKind; - if (typeof resolvedEllipsis === "string") { - resolvedEllipsis = resolvedEllipsis === "" ? Ellipsis.Omit : Ellipsis.Unicode; + maxWidth = Math.max(0, maxWidth | 0); + // Fast path: every UTF-16 unit is at most 3 cells wide, so a string whose + // `length * 3` already fits within `safeWidth` cannot need truncation. + if (!pad && text.length * 3 <= maxWidth) { + return text; } return nativeTruncateToWidth( text, - safeWidth, - resolvedEllipsis ?? Ellipsis.Unicode, + maxWidth, + (ellipsisKind === "" ? Ellipsis.Omit : ellipsisKind) ?? Ellipsis.Unicode, pad ?? false, - getDefaultTabWidth(), + DEFAULT_TAB_WIDTH, ); } export function wrapTextWithAnsi(text: string, width: number): string[] { - return nativeWrapTextWithAnsi(text, width, getDefaultTabWidth()); + return nativeWrapTextWithAnsi(text, width, DEFAULT_TAB_WIDTH); } export function extractSegments( @@ -112,24 +109,17 @@ export function extractSegments( afterLen: number, strictAfter: boolean, ): ExtractSegmentsResult { - return nativeExtractSegments(line, beforeEnd, afterStart, afterLen, strictAfter, getDefaultTabWidth()); + return nativeExtractSegments(line, beforeEnd, afterStart, afterLen, strictAfter, DEFAULT_TAB_WIDTH); } // Pre-allocated space buffer for padding const SPACE_BUFFER = " ".repeat(512); -/** - * Tab width in columns for `file`, using `process.cwd()` as the project root for relative paths. - */ -export function getIndentationNoescape(file?: string): number { - return getIndentation(file, process.cwd()); -} - /* - * Replace tabs with configured spacing for consistent rendering. + * Replace tabs with the fixed display tab width for consistent rendering. */ -export function replaceTabs(text: string, file?: string): string { - return text.replaceAll("\t", " ".repeat(getIndentation(file))); +export function replaceTabs(text: string): string { + return text.replaceAll("\t", " ".repeat(DEFAULT_TAB_WIDTH)); } /** @@ -186,7 +176,7 @@ export function visibleWidth(str: string): number { for (let tabIndex = str.indexOf(TAB); tabIndex !== -1; tabIndex = str.indexOf(TAB, tabIndex + 1)) { tabCount++; } - if (tabCount > 0) width += tabCount * getDefaultTabWidth(); + if (tabCount > 0) width += tabCount * DEFAULT_TAB_WIDTH; return width; } @@ -203,7 +193,7 @@ export function visibleWidth(str: string): number { } } if (i === str.length) { - return tabCount === 0 ? str.length : str.length + tabCount * (getDefaultTabWidth() - 1); + return tabCount === 0 ? str.length : str.length + tabCount * (DEFAULT_TAB_WIDTH - 1); } if (tabCount === 0) { @@ -224,7 +214,7 @@ export function visibleWidth(str: string): number { // the native scanner that traps under Bun 1.3.x GC/N-API load). It strips // CSI/OSC to zero cells and shares the native engine's UAX#11 width tables. let width = Bun.stringWidth(str, STRING_WIDTH_OPTS); - if (tabCount > 0) width += tabCount * getDefaultTabWidth(); + if (tabCount > 0) width += tabCount * DEFAULT_TAB_WIDTH; // OSC 66: add back each stripped span as `scale * (explicit w ?? payload // width)`. Matched rather than replaced to avoid reallocating the string. diff --git a/packages/tui/test/box-border.test.ts b/packages/tui/test/box-border.test.ts new file mode 100644 index 000000000..68bbe4307 --- /dev/null +++ b/packages/tui/test/box-border.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "bun:test"; +import { Box, type BoxBorder, Text } from "@oh-my-pi/pi-tui"; + +const CHARS: BoxBorder["chars"] = { + topLeft: "+", + topRight: "+", + bottomLeft: "+", + bottomRight: "+", + horizontal: "-", + vertical: "|", +}; + +function borderedBox(border?: Partial): Box { + // paddingY 0 keeps the row count predictable; ignoreTight pins paddingX to exactly 1. + // Real SGR escapes so Bun.stripANSI removes both bg and border color before width checks. + const box = new Box(1, 0, t => `\x1b[48;5;236m${t}\x1b[49m`, { + chars: CHARS, + color: t => `\x1b[31m${t}\x1b[39m`, + ...border, + }); + box.setIgnoreTight(true); + box.addChild(new Text("hi", 0, 0)); + return box; +} + +const widths = (rows: readonly string[]): number[] => rows.map(r => Bun.stringWidth(Bun.stripANSI(r))); +const plain = (rows: readonly string[]): string[] => rows.map(r => Bun.stripANSI(r)); + +describe("Box border", () => { + it("frames content without exceeding the given width", () => { + const rows = borderedBox().render(20); + // top rule + single content row + bottom rule + expect(rows.length).toBe(3); + for (const w of widths(rows)) expect(w).toBe(20); + + const flat = plain(rows); + expect(flat[0]).toBe(`+${"-".repeat(18)}+`); + expect(flat[2]).toBe(`+${"-".repeat(18)}+`); + // Interior rows are wrapped by the vertical glyph on both edges. + expect(flat[1]!.startsWith("|")).toBe(true); + expect(flat[1]!.endsWith("|")).toBe(true); + expect(flat[1]).toContain("hi"); + }); + + it("paints border glyphs with the supplied colorizer", () => { + const rows = borderedBox().render(20); + // The top rule is emitted through the color fn (red SGR), the interior is not. + expect(rows[0]).toContain("\x1b[31m"); + }); + + it("recomputes width when the border is toggled off", () => { + const box = borderedBox(); + expect(widths(box.render(20))).toEqual([20, 20, 20]); + + box.setBorder(undefined); + const rows = box.render(20); + // No border rows now; the single content row fills the full width. + expect(rows.length).toBe(1); + expect(Bun.stringWidth(Bun.stripANSI(rows[0]!))).toBe(20); + expect(Bun.stripANSI(rows[0]!)).not.toContain("+"); + }); + + it("frames at the boundary width without overflowing", () => { + // paddingX 1 → the border needs width >= 5 (2 borders + 2 padding + 1 content col). + const rows = borderedBox().render(5); + for (const w of widths(rows)) expect(w).toBe(5); + expect(plain(rows)[0]).toBe(`+${"-".repeat(3)}+`); + }); + + it("drops the border when the interior can't fit padding + content", () => { + for (const width of [3, 4]) { + const rows = borderedBox().render(width); + for (const w of widths(rows)) expect(w).toBeLessThanOrEqual(width); + for (const line of plain(rows)) expect(line).not.toContain("+"); + } + }); +}); diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index bbc3c4468..6ab2b78ca 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -6,7 +6,6 @@ import { Editor } from "@oh-my-pi/pi-tui/components/editor"; import { KeybindingsManager, setKeybindings, TUI_KEYBINDINGS } from "@oh-my-pi/pi-tui/keybindings"; import { setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys"; import { visibleWidth } from "@oh-my-pi/pi-tui/utils"; -import { setDefaultTabWidth } from "@oh-my-pi/pi-utils"; import { defaultEditorTheme } from "./test-themes"; describe("Editor component", () => { @@ -545,16 +544,10 @@ describe("Editor component", () => { expect(text).toBe("Hällö Wörld! 😀 äöüÄÖÜß"); }); - it("uses the configured tab width when loading text programmatically", () => { + it("expands tabs to the fixed display width when loading text programmatically", () => { const editor = new Editor(defaultEditorTheme); - - try { - setDefaultTabWidth(5); - editor.setText("foo\tbar"); - expect(editor.getText()).toBe("foo bar"); - } finally { - setDefaultTabWidth(3); - } + editor.setText("foo\tbar"); + expect(editor.getText()).toBe("foo bar"); }); it("strips control characters from programmatically loaded text before render", () => { diff --git a/packages/tui/test/input.test.ts b/packages/tui/test/input.test.ts index 39cf31b60..9311cab60 100644 --- a/packages/tui/test/input.test.ts +++ b/packages/tui/test/input.test.ts @@ -3,7 +3,7 @@ import { CURSOR_MARKER } from "@oh-my-pi/pi-tui"; import { Input } from "@oh-my-pi/pi-tui/components/input"; import { setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys"; import { visibleWidth } from "@oh-my-pi/pi-tui/utils"; -import { getIndentation } from "@oh-my-pi/pi-utils"; +import { DEFAULT_TAB_WIDTH } from "@oh-my-pi/pi-utils"; function renderedWidth(input: Input, width: number): number { const [line] = input.render(width); @@ -155,7 +155,7 @@ describe("Input component", () => { setKittyProtocolActive(false); }); - it("normalizes tabs in buffered bracketed paste using configured indentation", () => { + it("normalizes tabs in buffered bracketed paste using the fixed display width", () => { const input = setupAtEnd(""); input.handleInput("\x1b[200~a\t"); @@ -165,7 +165,7 @@ describe("Input component", () => { expect(input.getValue()).toBe(""); input.handleInput("c\x1b[201~"); - expect(input.getValue()).toBe(`a${" ".repeat(getIndentation())}bc`); + expect(input.getValue()).toBe(`a${" ".repeat(DEFAULT_TAB_WIDTH)}bc`); }); it("decodes tmux re-encoded control bytes in bracketed paste without leaking tails or storing raw C0", () => { diff --git a/packages/tui/test/loader.test.ts b/packages/tui/test/loader.test.ts index 77c341a1f..e200e1b24 100644 --- a/packages/tui/test/loader.test.ts +++ b/packages/tui/test/loader.test.ts @@ -92,7 +92,7 @@ describe("Loader component", () => { it("requests render when animated message bytes change between spinner frames", () => { vi.useFakeTimers(); setSystemTime(new Date(1_000)); - const ui = { requestComponentRender: vi.fn() } as unknown as TUI; + const ui = { synchronizedOutput: true, requestComponentRender: vi.fn() } as unknown as TUI; const colorMessage = ((text: string) => `${text}-${Date.now()}`) as LoaderMessageColorFn & { animated: true }; colorMessage.animated = true; const loader = new Loader(ui, text => text, colorMessage, "Checking", ["0"]); @@ -106,6 +106,26 @@ describe("Loader component", () => { loader.stop(); }); + it("holds animated message-only frames when synchronized output is unavailable", () => { + vi.useFakeTimers(); + setSystemTime(new Date(1_000)); + const ui = { synchronizedOutput: false, requestComponentRender: vi.fn() } as unknown as TUI; + const colorMessage = ((text: string) => `${text}-${Date.now()}`) as LoaderMessageColorFn & { animated: true }; + colorMessage.animated = true; + const loader = new Loader(ui, text => text, colorMessage, "Checking", ["0", "1"]); + + expect(ui.requestComponentRender).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(34); + expect(ui.requestComponentRender).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(67); + expect(ui.requestComponentRender).toHaveBeenCalledTimes(2); + expect(loader.render(40).join("\n")).toContain("1 Checking-"); + + loader.stop(); + }); + it("dispose() stops the animation so no further renders are scheduled", async () => { const term = new VirtualTerminal(20, 4); const tui = new TUI(term); diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index ea7c3baff..ecb13a8c2 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -752,6 +752,73 @@ again, hello world`, }); }); + describe("Custom section dividers", () => { + it("should render Unicode light line ───────────── as a horizontal rule with ─", () => { + const unicodeTheme = { + ...defaultMarkdownTheme, + symbols: { + ...defaultMarkdownTheme.symbols, + hrChar: "─", + }, + }; + const markdown = new Markdown("─────────────", 0, 0, unicodeTheme); + const lines = markdown.render(80); + const plainLines = lines.map(line => stripVTControlCharacters(line).trimEnd()); + expect(plainLines[0]).toBe("─".repeat(80)); + }); + + it("should render double line ============ as a horizontal rule with =", () => { + const markdown = new Markdown("============", 0, 0, defaultMarkdownTheme); + const lines = markdown.render(80); + const plainLines = lines.map(line => stripVTControlCharacters(line).trimEnd()); + expect(plainLines[0]).toBe("=".repeat(80)); + }); + + it("should render em dash line ——— as a horizontal rule with —", () => { + const unicodeTheme = { + ...defaultMarkdownTheme, + symbols: { + ...defaultMarkdownTheme.symbols, + hrChar: "─", + }, + }; + const markdown = new Markdown("———", 0, 0, unicodeTheme); + const lines = markdown.render(80); + const plainLines = lines.map(line => stripVTControlCharacters(line).trimEnd()); + expect(plainLines[0]).toBe("—".repeat(80)); + }); + + it("should render em dash line ——— as a horizontal rule with - in ASCII theme", () => { + const asciiTheme = { + ...defaultMarkdownTheme, + symbols: { + ...defaultMarkdownTheme.symbols, + hrChar: "-", + }, + }; + const markdown = new Markdown("———", 0, 0, asciiTheme); + const lines = markdown.render(80); + const plainLines = lines.map(line => stripVTControlCharacters(line).trimEnd()); + expect(plainLines[0]).toBe("-".repeat(80)); + }); + + it("should preserve Setext H1 headings for Title\\n===", () => { + const markdown = new Markdown("Title\n===", 0, 0, defaultMarkdownTheme); + const lines = markdown.render(80); + const plainLines = lines.map(line => stripVTControlCharacters(line).trimEnd()); + expect(plainLines.some(line => line.includes("Title"))).toBeTruthy(); + expect(plainLines.includes("=".repeat(80))).toBeFalsy(); + }); + + it("should preserve Setext H2 headings for Title\\n---", () => { + const markdown = new Markdown("Title\n---", 0, 0, defaultMarkdownTheme); + const lines = markdown.render(80); + const plainLines = lines.map(line => stripVTControlCharacters(line).trimEnd()); + expect(plainLines.some(line => line.includes("Title"))).toBeTruthy(); + expect(plainLines.includes("─".repeat(80))).toBeFalsy(); + }); + }); + describe("Spacing after headings", () => { it("should have only one blank line between heading and following paragraph", () => { const markdown = new Markdown( @@ -1435,6 +1502,177 @@ describe("Markdown.render reference stability", () => { expect(narrow.every(line => visibleWidth(line) <= 30)).toBe(true); expect(wide.every(line => visibleWidth(line) <= 60)).toBe(true); }); + + it("formats common HTML tags inside table cells", () => { + const md = new Markdown( + "| Gemini result |\n| --- |\n|
          • None. Static checks
            are green.
          |", + 0, + 0, + defaultMarkdownTheme, + ); + const lines = md.render(80).map(line => stripVTControlCharacters(line).trimEnd()); + const bulletLineIndex = lines.findIndex(line => line.includes("• None. Static checks")); + const continuationLineIndex = lines.findIndex(line => line.includes("are green.")); + + expect(lines.some(line => /<\/?(?:br|ul|li)\b/i.test(line))).toBe(false); + expect(bulletLineIndex).toBeGreaterThan(-1); + expect(continuationLineIndex).toBeGreaterThan(bulletLineIndex); + }); + + it("preserves separators between adjacent HTML tags inside table cells", () => { + const md = new Markdown( + "| Result |\n| --- |\n|
          • First

          Second result.

          |", + 0, + 0, + defaultMarkdownTheme, + ); + const lines = md.render(80).map(line => stripVTControlCharacters(line).trimEnd()); + const firstLineIndex = lines.findIndex(line => line.includes("• First")); + const secondLineIndex = lines.findIndex(line => line.includes("Second result.")); + + expect(lines.some(line => /<\/?(?:p|ul|li)\b| /i.test(line))).toBe(false); + expect(firstLineIndex).toBeGreaterThan(-1); + expect(secondLineIndex).toBeGreaterThan(firstLineIndex); + }); + + it("preserves ordered HTML list numbering", () => { + const md = new Markdown("
          1. First
          2. Second
          ", 0, 0, defaultMarkdownTheme); + const lines = md.render(80).map(line => stripVTControlCharacters(line).trimEnd()); + + expect(lines).toContain("1. First"); + expect(lines).toContain("2. Second"); + expect(lines.some(line => line.includes("• First") || line.includes("• Second"))).toBe(false); + }); + + it("keeps HTML list markers with paragraph-wrapped list text", () => { + const unordered = new Markdown("
          • First

          ", 0, 0, defaultMarkdownTheme) + .render(80) + .map(line => stripVTControlCharacters(line).trimEnd()); + const ordered = new Markdown('
          1. Third

          ', 0, 0, defaultMarkdownTheme) + .render(80) + .map(line => stripVTControlCharacters(line).trimEnd()); + const table = new Markdown("| Result |\n| --- |\n|
          • First

          |", 0, 0, defaultMarkdownTheme) + .render(80) + .map(line => stripVTControlCharacters(line).trimEnd()); + + expect(unordered).toContain("• First"); + expect(unordered).not.toContain("•"); + expect(unordered).not.toContain("First"); + expect(ordered).toContain("3. Third"); + expect(ordered).not.toContain("3."); + expect(ordered).not.toContain("Third"); + expect(table).toContain("| • First |"); + expect(table).not.toContain("| • |"); + }); + + it("fits table columns to split HTML lines", () => { + const md = new Markdown("| Result |\n| --- |\n|
          • Pass
            OK
          |", 0, 0, defaultMarkdownTheme); + const lines = md.render(80).map(line => stripVTControlCharacters(line).trimEnd()); + const topBorder = lines.find(line => line.startsWith("+")); + + expect(topBorder).toBe("+--------+"); + expect(lines).toContain("| • Pass |"); + expect(lines).toContain("| OK |"); + }); + + it("preserves repeated HTML line breaks as intentional blank spacing", () => { + const cases = ["First

          Second", "First

          Second"]; + + for (const input of cases) { + const md = new Markdown(input, 0, 0, defaultMarkdownTheme); + const lines = md.render(80).map(line => stripVTControlCharacters(line).trimEnd()); + const firstLineIndex = lines.indexOf("First"); + + expect(firstLineIndex).toBeGreaterThan(-1); + expect(lines[firstLineIndex + 1]).toBe(""); + expect(lines[firstLineIndex + 2]).toBe("Second"); + } + }); + + it("preserves repeated HTML line breaks inside table cells", () => { + const md = new Markdown("| Result |\n| --- |\n| First

          Second |", 0, 0, defaultMarkdownTheme); + const lines = md.render(80).map(line => stripVTControlCharacters(line).trimEnd()); + const firstLineIndex = lines.findIndex(line => line.includes("| First")); + + expect(firstLineIndex).toBeGreaterThan(-1); + expect(lines[firstLineIndex + 1]).toContain("| |"); + expect(lines[firstLineIndex + 2]).toContain("| Second |"); + }); + + it("indents nested HTML list items by list stack depth", () => { + const md = new Markdown( + '
          • Parent
            • Child
            • Second child
            1. Ordered child
          • Sibling
          ', + 0, + 0, + defaultMarkdownTheme, + ); + const lines = md.render(80).map(line => stripVTControlCharacters(line).trimEnd()); + + expect(lines).toContain("• Parent"); + expect(lines).toContain(" • Child"); + expect(lines).toContain(" • Second child"); + expect(lines).toContain(" 3. Ordered child"); + expect(lines).toContain("• Sibling"); + expect(lines).not.toContain("• Child"); + expect(lines).not.toContain("• Second child"); + expect(lines).not.toContain("3. Ordered child"); + }); + + it("indents nested HTML list items inside table cells", () => { + const md = new Markdown( + "| Result |\n| --- |\n|
          • Parent
            • Child
          |", + 0, + 0, + defaultMarkdownTheme, + ); + const lines = md.render(80).map(line => stripVTControlCharacters(line).trimEnd()); + + expect(lines.some(line => line.includes("| • Parent"))).toBe(true); + expect(lines.some(line => line.includes("| • Child"))).toBe(true); + expect(lines.some(line => line.includes("| • Child"))).toBe(false); + }); + + it("does not emit ANSI-only lines for empty styled HTML replacements", () => { + const md = new Markdown("

          Visible", 0, 0, defaultMarkdownTheme, { + color: text => chalk.gray(text), + italic: true, + }); + const lines = md.render(80); + + const blankLines = lines.filter(line => stripVTControlCharacters(line).trimEnd() === ""); + expect(blankLines.length).toBeGreaterThan(0); + expect(blankLines.every(line => !line.includes("\x1b["))).toBe(true); + expect(lines.some(line => stripVTControlCharacters(line).trimEnd() === "Visible")).toBe(true); + }); + + it("decodes non-breaking spaces in table text", () => { + const md = new Markdown("| Result |\n| --- |\n| A B |", 0, 0, defaultMarkdownTheme); + const lines = md.render(80).map(line => stripVTControlCharacters(line).trimEnd()); + + expect(lines.some(line => line.includes("A B"))).toBe(true); + expect(lines.some(line => line.includes(" "))).toBe(false); + }); + + it("separates paragraph HTML tags instead of concatenating text", () => { + const md = new Markdown("

          First result.

          Second result.

          ", 0, 0, defaultMarkdownTheme); + const lines = md.render(80).map(line => stripVTControlCharacters(line).trimEnd()); + + expect(lines).toContain("First result."); + expect(lines).toContain("Second result."); + expect(lines).not.toContain("First result.Second result."); + }); + + it("drops HTML formatting whitespace between pretty-printed list tags", () => { + const md = new Markdown("
            \n
          • First
          • \n
          • Second
          • \n
          ", 0, 0, defaultMarkdownTheme); + const lines = md.render(80).map(line => stripVTControlCharacters(line).trimEnd()); + + // Source indentation must not leak in front of the bullets, and the + // formatting newlines between items must not become blank rows. + expect(lines).toContain("• First"); + expect(lines).toContain("• Second"); + expect(lines.some(line => line.startsWith(" ") && line.includes("•"))).toBe(false); + expect(lines.filter(line => line === "").length).toBe(0); + }); }); describe("Math rendering", () => { diff --git a/packages/tui/test/resize-viewport-defer.test.ts b/packages/tui/test/resize-viewport-defer.test.ts index c3ef58b83..a0ceac04d 100644 --- a/packages/tui/test/resize-viewport-defer.test.ts +++ b/packages/tui/test/resize-viewport-defer.test.ts @@ -57,7 +57,7 @@ async function withEnvPatch(patch: Record, run: ( class DeferScheduler implements RenderScheduler { #time = 0; #immediates: (() => void)[] = []; - #renders = new Map void>(); + #renders = new Map void; delay: number }>(); #nextId = 0; now(): number { @@ -69,9 +69,9 @@ class DeferScheduler implements RenderScheduler { this.#immediates.push(callback); } - scheduleRender(callback: () => void, _delayMs: number): RenderTimer { + scheduleRender(callback: () => void, delayMs: number): RenderTimer { const id = this.#nextId++; - this.#renders.set(id, callback); + this.#renders.set(id, { run: callback, delay: delayMs }); return { cancel: () => { this.#renders.delete(id); @@ -94,6 +94,28 @@ class DeferScheduler implements RenderScheduler { await term.flush(); } + // Fire immediates plus any throttled render whose delay is below `maxDelayMs`, + // leaving longer timers (the 120ms resize settle) pending. Lets a test drive + // an interleaved ordinary render — a spinner tick / streamed token, scheduled + // at the ~33ms render throttle — mid-drag without ending the drag. + async flushOrdinaryRenders(term: VirtualTerminal, maxDelayMs = 100): Promise { + let rounds = 0; + for (;;) { + if (++rounds > 100) throw new Error("ordinary renders did not settle"); + const immediates = this.#immediates; + this.#immediates = []; + for (const callback of immediates) callback(); + if (this.#immediates.length > 0) continue; + const due = [...this.#renders.entries()].filter(([, entry]) => entry.delay < maxDelayMs); + if (due.length === 0) break; + for (const [id, entry] of due) { + this.#renders.delete(id); + entry.run(); + } + } + await term.flush(); + } + async flushAll(term: VirtualTerminal): Promise { let rounds = 0; while (this.#immediates.length > 0 || this.#renders.size > 0) { @@ -104,7 +126,7 @@ class DeferScheduler implements RenderScheduler { if (this.#immediates.length > 0) continue; const renders = [...this.#renders.values()]; this.#renders.clear(); - for (const callback of renders) callback(); + for (const entry of renders) entry.run(); } await term.flush(); } @@ -269,6 +291,53 @@ describe("non-multiplexer resize viewport fast path", () => { }); }); + it("keeps an interleaved live-block render on the viewport fast path instead of flashing a normal-screen full paint", async () => { + await withEnvPatch(NO_MULTIPLEXER_ENV, async () => { + const term = new VirtualTerminal(40, 10, 1000); + const { tui, scheduler } = makeTui(term); + try { + tui.start(); + await scheduler.flushImmediates(term); + + // One drag SIGWINCH enters the fast path and borrows the alt screen. + term.resize(60, 10); + await scheduler.flushImmediates(term); + expect(tui.resizeViewportActive).toBe(true); + + const baselineFull = tui.fullRedraws; + const baselinePaints = tui.resizeViewportPaints; + const writes = captureWrites(term); + + // A live block keeps animating mid-drag: a spinner tick / streamed + // token fires an ordinary (non-forced) render before the 120ms settle + // elapses. It must stay on the viewport fast path. Without the guard it + // falls through to the geometry-rebuild full paint, which leaves the + // borrowed alternate screen (ALT_SCREEN_EXIT) and erases native + // scrollback (ED3) to repaint the whole transcript on the normal screen + // for one frame — the flash — before the next SIGWINCH hides it again. + tui.requestRender(); + await scheduler.flushOrdinaryRenders(term); + + // Still mid-drag, still on the alternate screen: a viewport-only paint, + // no authoritative full redraw, no scrollback erase, no alt-screen exit. + expect(tui.resizeViewportActive).toBe(true); + expect(tui.resizeViewportPaints).toBeGreaterThan(baselinePaints); + expect(tui.fullRedraws).toBe(baselineFull); + expect(writes.join("")).not.toContain(ALT_SCREEN_EXIT); + expect(eraseScrollbackCount(writes)).toBe(0); + + // The settle still fires exactly one authoritative full paint once the + // drag goes quiet — the interleaved render did not consume or corrupt + // the deferred geometry rebuild. + await scheduler.flushAll(term); + expect(tui.resizeViewportActive).toBe(false); + expect(tui.fullRedraws).toBe(baselineFull + 1); + } finally { + tui.stop(); + } + }); + }); + it("does not leave a pending settle paint after stop()", async () => { await withEnvPatch(NO_MULTIPLEXER_ENV, async () => { const term = new VirtualTerminal(40, 10, 1000); diff --git a/packages/tui/test/visible-width.test.ts b/packages/tui/test/visible-width.test.ts index d1bfd8faa..98afd6a67 100644 --- a/packages/tui/test/visible-width.test.ts +++ b/packages/tui/test/visible-width.test.ts @@ -12,12 +12,12 @@ */ import { describe, expect, it } from "bun:test"; import { visibleWidth as nativeVisibleWidth } from "@oh-my-pi/pi-natives"; -import { getDefaultTabWidth, visibleWidth } from "@oh-my-pi/pi-tui/utils"; +import { DEFAULT_TAB_WIDTH, visibleWidth } from "@oh-my-pi/pi-tui/utils"; const ESC = "\x1b"; const ST = "\x1b\\"; const BEL = "\x07"; -const TAB = getDefaultTabWidth(); +const TAB = DEFAULT_TAB_WIDTH; describe("visibleWidth — parity with the native width engine", () => { const corpus: [string, string][] = [ diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index d4465c334..db821153b 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [16.0.11] - 2026-06-19 + +### Removed + +- Removed `getIndentation`, `setDefaultTabWidth`, and `getDefaultTabWidth` helpers + ## [16.0.8] - 2026-06-18 ### Changed diff --git a/packages/utils/package.json b/packages/utils/package.json index 0c1f6ee21..79f242b57 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-utils", - "version": "16.0.9", + "version": "16.1.1", "description": "Shared utilities for pi packages", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/utils/src/tab-spacing.ts b/packages/utils/src/tab-spacing.ts index ac1522123..31f680144 100644 --- a/packages/utils/src/tab-spacing.ts +++ b/packages/utils/src/tab-spacing.ts @@ -20,11 +20,8 @@ const NAME_MAX_BYTES = 255; const EDITORCONFIG_NAME = ".editorconfig"; -let defaultTabWidth = DEFAULT_TAB_WIDTH; - const editorConfigCache = new Map(); const editorConfigChainCache = new Map(); -const indentationCache = new Map(); interface EditorConfigSection { pattern: string; @@ -266,31 +263,6 @@ function resolveEditorConfigMatch(absoluteFile: string): EditorConfigMatch | und return match; } -function resolveEditorConfigTabWidth(match: EditorConfigMatch | undefined, fallback: number): number | undefined { - if (match === undefined) return undefined; - - if (match.indentSize?.kind === "spaces") { - return match.indentSize.n; - } - - if (match.indentSize?.kind === "tab") { - if (match.tabWidth !== undefined) { - return match.tabWidth; - } - return fallback; - } - - if (match.tabWidth !== undefined) { - return match.tabWidth; - } - - if (match.indentStyle === IndentStyle.Tab) { - return fallback; - } - - return undefined; -} - function hasOverlongPathComponent(filePath: string): boolean { for (const part of filePath.split(/[\\/]/)) { if (part.length > 0 && Buffer.byteLength(part) > NAME_MAX_BYTES) { @@ -300,53 +272,12 @@ function hasOverlongPathComponent(filePath: string): boolean { return false; } -export function getDefaultTabWidth(): number { - return defaultTabWidth; -} - -export function setDefaultTabWidth(width: number): void { - defaultTabWidth = clampTabWidth(width); -} - -/** - * Visible tab width in columns for `file` (from `.editorconfig` + default), or the default when `file` is omitted. - */ -export function getIndentation(file?: string | null, projectDir?: string | null): number { - const fallback = defaultTabWidth; - if (file === undefined || file === null || file === "") { - return fallback; - } - - const cwd = projectDir ?? process.cwd(); - const absoluteFile = resolveFilePath(cwd, file); - - // Renderers can hand us arbitrary strings (e.g. a malformed edit tool - // call whose `file_path` is gibberish). Reject paths whose normalized - // absolute form still has any component longer than `NAME_MAX_BYTES` — - // the editorconfig chain would only trip `ENAMETOOLONG` from - // `readFileSync` and escape. - if (hasOverlongPathComponent(absoluteFile)) { - return fallback; - } - const absKey = absoluteFile; - const cached = indentationCache.get(absKey); - if (cached !== undefined) { - return cached; - } - - const editorMatch = resolveEditorConfigMatch(absoluteFile); - const resolved = resolveEditorConfigTabWidth(editorMatch, fallback) ?? fallback; - const clamped = clampTabWidth(resolved); - indentationCache.set(absKey, clamped); - return clamped; -} - /** * `.editorconfig`-derived formatting options for an LSP `textDocument/formatting` request. * * Both fields are absent when the resolved `.editorconfig` chain does not pin them, so callers * can layer their own fallbacks (content sniffing, project defaults) underneath. Returned values - * are clamped to {@link MIN_TAB_WIDTH}..{@link MAX_TAB_WIDTH} for parity with {@link getIndentation}. + * are clamped to {@link MIN_TAB_WIDTH}..{@link MAX_TAB_WIDTH}. */ export interface EditorConfigFormatting { /** Effective indent width in columns, from `indent_size` or `tab_width`. */ @@ -359,8 +290,8 @@ export interface EditorConfigFormatting { * Resolve `.editorconfig` formatting hints for `file` without falling back to any default. * * Used by the LSP format-on-write path so a missing `.editorconfig` declaration falls through - * to caller-provided defaults instead of clobbering the file with the renderer's display - * `defaultTabWidth` (issue #2329). + * to caller-provided defaults instead of clobbering the file with the renderer's + * display tab width (issue #2329). */ export function getEditorConfigFormatting(file?: string | null, projectDir?: string | null): EditorConfigFormatting { if (file === undefined || file === null || file === "") { @@ -370,7 +301,7 @@ export function getEditorConfigFormatting(file?: string | null, projectDir?: str const cwd = projectDir ?? process.cwd(); const absoluteFile = resolveFilePath(cwd, file); - // Same NAME_MAX guard as `getIndentation`: editorconfig discovery is + // NAME_MAX guard: editorconfig discovery is // best-effort and must never escape as `ENAMETOOLONG` from a renderer's // stray gibberish path. if (hasOverlongPathComponent(absoluteFile)) { diff --git a/packages/utils/test/spacing.test.ts b/packages/utils/test/spacing.test.ts index 45726c80e..077e8d522 100644 --- a/packages/utils/test/spacing.test.ts +++ b/packages/utils/test/spacing.test.ts @@ -4,96 +4,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { getProjectDir, setProjectDir } from "@oh-my-pi/pi-utils/dirs"; import { Snowflake } from "@oh-my-pi/pi-utils/snowflake"; -import { getEditorConfigFormatting, getIndentation, setDefaultTabWidth } from "@oh-my-pi/pi-utils/tab-spacing"; - -describe("spacing", () => { - let tempDir = ""; - let previousProjectDir = ""; - - beforeEach(async () => { - previousProjectDir = getProjectDir(); - tempDir = path.join(os.tmpdir(), "pi-utils-spacing", Snowflake.next()); - await fs.mkdir(tempDir, { recursive: true }); - setProjectDir(tempDir); - setDefaultTabWidth(3); - }); - - afterEach(async () => { - setDefaultTabWidth(3); - setProjectDir(previousProjectDir); - await fs.rm(tempDir, { recursive: true, force: true }); - }); - - it("resolves editorconfig rules for file path and falls back to default", async () => { - const filePath = path.join(tempDir, "src", "feature.ts"); - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile( - path.join(tempDir, ".editorconfig"), - ["root = true", "", "[*]", "indent_size = 2", "", "[*.md]", "indent_size = 4"].join("\n"), - ); - - expect(" ".repeat(getIndentation(filePath))).toBe(" "); - expect(" ".repeat(getIndentation(path.join(tempDir, "README.md")))).toBe(" "); - expect(" ".repeat(getIndentation(path.join(tempDir, "missing.txt")))).toBe(" "); - }); - - it("merges nested editorconfig files from root to leaf", async () => { - const nestedDir = path.join(tempDir, "packages", "feature"); - const filePath = path.join(nestedDir, "index.ts"); - await fs.mkdir(nestedDir, { recursive: true }); - - await fs.writeFile(path.join(tempDir, ".editorconfig"), ["root = true", "", "[*]", "indent_size = 2"].join("\n")); - await fs.writeFile(path.join(tempDir, "packages", ".editorconfig"), ["[*.ts]", "indent_size = 6"].join("\n")); - - expect(" ".repeat(getIndentation(filePath))).toBe(" "); - }); - - it("does not throw when the path's segment exceeds NAME_MAX (#1871)", () => { - // A garbage path segment (e.g. 2KiB of garbage Unicode produced by a - // hallucinating model) makes `fs.readFileSync` reject with - // ENAMETOOLONG. The editorconfig probe MUST swallow it and fall back to - // the default tab width — anything else crashes the TUI mid-render. - const huge = "a".repeat(2048); - const phonyPath = path.join(tempDir, huge, "leaf.ts"); - expect(() => getIndentation(phonyPath)).not.toThrow(); - expect(getIndentation(phonyPath)).toBe(3); - }); - - it("returns the default tab width for paths with an overlong component (no syscall)", () => { - // Repro of #1872: a malformed edit tool call lands a long gibberish - // string in `file_path`, the renderer routes it through `replaceTabs -> - // getIndentation`, and `readFileSync` of `/.editorconfig` would - // throw `ENAMETOOLONG`. The path gate must short-circuit before any - // syscall so renderers never see the exception. - const longSegment = "amálpthgadasJennzier".repeat(40); - const overlong = `${longSegment}/inner.ts`; - expect(Buffer.byteLength(longSegment)).toBeGreaterThan(255); - expect(() => getIndentation(overlong)).not.toThrow(); - expect(getIndentation(overlong)).toBe(3); - }); - - it("normalizes paths before rejecting overlong components", async () => { - const longSegment = "amálpthgadasJennzier".repeat(40); - const noisyPath = path.join(tempDir, longSegment, "..", "src", "feature.ts"); - await fs.writeFile(path.join(tempDir, ".editorconfig"), ["root = true", "", "[*]", "indent_size = 2"].join("\n")); - - expect(Buffer.byteLength(longSegment)).toBeGreaterThan(255); - expect(" ".repeat(getIndentation(noisyPath))).toBe(" "); - }); - - it("tolerates filesystem errors while walking the editorconfig chain (ENOTDIR)", async () => { - // Defense in depth: when a non-directory sits where a directory is - // expected, `parseCachedEditorConfig` previously caught only `ENOENT` - // and let `ENOTDIR` (and `ENAMETOOLONG`, `EACCES`, `ELOOP`, …) - // escape. Editorconfig discovery is best-effort and must absorb any - // `FsError`. - const notADir = path.join(tempDir, "not-a-dir"); - await fs.writeFile(notADir, ""); - const fakeChild = path.join(notADir, "inner.ts"); - expect(() => getIndentation(fakeChild)).not.toThrow(); - expect(getIndentation(fakeChild)).toBe(3); - }); -}); +import { getEditorConfigFormatting } from "@oh-my-pi/pi-utils/tab-spacing"; describe("getEditorConfigFormatting", () => { let tempDir = ""; @@ -176,4 +87,12 @@ describe("getEditorConfigFormatting", () => { expect(() => getEditorConfigFormatting(overlong)).not.toThrow(); expect(getEditorConfigFormatting(overlong)).toEqual({}); }); + + it("tolerates filesystem errors while walking the editorconfig chain (ENOTDIR)", async () => { + const notADir = path.join(tempDir, "not-a-dir"); + await fs.writeFile(notADir, ""); + const fakeChild = path.join(notADir, "inner.ts"); + expect(() => getEditorConfigFormatting(fakeChild)).not.toThrow(); + expect(getEditorConfigFormatting(fakeChild)).toEqual({}); + }); }); diff --git a/packages/wire/package.json b/packages/wire/package.json index 812ffabc8..c838f339f 100644 --- a/packages/wire/package.json +++ b/packages/wire/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-wire", - "version": "16.0.9", + "version": "16.1.1", "description": "Shared wire protocol types for Oh My Pi packages", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/wire/src/index.ts b/packages/wire/src/index.ts index c4822838c..e6ce733a9 100644 --- a/packages/wire/src/index.ts +++ b/packages/wire/src/index.ts @@ -345,6 +345,9 @@ export type WireFrame = GuestFrame | HostFrame; /** Wire protocol version carried in `hello`; the host rejects mismatches. */ export const COLLAB_PROTO = 1; +/** Parameter key used for intent tracing (e.g. prompt explanation/reasoning) */ +export const INTENT_FIELD = "_i"; + // ═══════════════════════════════════════════════════════════════════════════ // Envelope & link constants // ═══════════════════════════════════════════════════════════════════════════ diff --git a/python/robomp/.env.example b/python/robomp/.env.example index 235181162..2cf2ff218 100644 --- a/python/robomp/.env.example +++ b/python/robomp/.env.example @@ -110,6 +110,26 @@ ROBOMP_TASK_TIMEOUT_HARD_GRACE_SECONDS=60 ROBOMP_REQUEST_TIMEOUT_SECONDS=120 +# ============================================================================= +# --- PR review / vouch gate --- +# ============================================================================= +# robomp reviews incoming contributor PRs. When PRs are gated by the vouch +# GitHub Action (.github/workflows/vouch-pr.yml), set the trigger to +# `vouched_label` so robomp reviews ONLY the PRs the gate let through (the +# workflow labels survivors with ROBOMP_VOUCH_REVIEW_LABEL) instead of racing +# the gate on PR open. Set ROBOMP_PR_REVIEW_ENABLED=false to disable robomp PR +# review entirely. +ROBOMP_PR_REVIEW_ENABLED=true +# open | vouched_label +ROBOMP_PR_REVIEW_TRIGGER=open +ROBOMP_VOUCH_REVIEW_LABEL=vouched +# In vouched_label mode only `labeled` events from this actor trigger review, +# so a manual triage/maintainer label cannot bypass the gate. Default matches +# the stock GITHUB_TOKEN actor; set to your App's bot login if the vouch +# workflow labels via a GitHub App token. +ROBOMP_VOUCH_REVIEW_LABELER=github-actions[bot] + + # ============================================================================= # --- Per-submitter rate limiting --- # ============================================================================= diff --git a/python/robomp/docker-compose.yml b/python/robomp/docker-compose.yml index 4a06e418d..3d8aed813 100644 --- a/python/robomp/docker-compose.yml +++ b/python/robomp/docker-compose.yml @@ -44,6 +44,16 @@ services: ROBOMP_MAINTAINER_LOGINS: ${ROBOMP_MAINTAINER_LOGINS:-} ROBOMP_REVIEWER_BOTS: ${ROBOMP_REVIEWER_BOTS:-} + # --- PR review / vouch gate --- + # When PRs are gated by the vouch GitHub Action, set the trigger to + # `vouched_label` so robomp reviews ONLY PRs that survive the gate + # (the workflow labels survivors `ROBOMP_VOUCH_REVIEW_LABEL`), instead + # of racing the gate on PR open. `vouched_label` keeps review ENABLED. + ROBOMP_PR_REVIEW_ENABLED: ${ROBOMP_PR_REVIEW_ENABLED:-true} + ROBOMP_PR_REVIEW_TRIGGER: ${ROBOMP_PR_REVIEW_TRIGGER:-open} + ROBOMP_VOUCH_REVIEW_LABEL: ${ROBOMP_VOUCH_REVIEW_LABEL:-vouched} + ROBOMP_VOUCH_REVIEW_LABELER: ${ROBOMP_VOUCH_REVIEW_LABELER:-github-actions[bot]} + # --- model selection --- ROBOMP_MODEL: ${ROBOMP_MODEL:-anthropic/claude-sonnet-4-6} ROBOMP_PROVIDER: ${ROBOMP_PROVIDER:-} diff --git a/python/robomp/src/config.py b/python/robomp/src/config.py index 2c8f1494e..1498e86af 100644 --- a/python/robomp/src/config.py +++ b/python/robomp/src/config.py @@ -38,6 +38,18 @@ class Settings(BaseSettings): git_author_email: str = Field(..., alias="ROBOMP_GIT_AUTHOR_EMAIL") repo_allowlist_raw: str = Field("", alias="ROBOMP_REPO_ALLOWLIST") pr_review_enabled: bool = Field(True, alias="ROBOMP_PR_REVIEW_ENABLED") + # PR review trigger. "open" (default) reviews incoming PRs on + # opened/reopened/ready_for_review. "vouched_label" DEFERS review until the + # vouch GitHub Action labels the PR `vouch_review_label`, so robomp reviews + # only PRs that survive the vouch gate. `pr_review_enabled` remains the + # master switch (False disables review under either trigger). + pr_review_trigger: Literal["open", "vouched_label"] = Field("open", alias="ROBOMP_PR_REVIEW_TRIGGER") + vouch_review_label: str = Field("vouched", alias="ROBOMP_VOUCH_REVIEW_LABEL") + # In vouched_label mode, only `labeled` events from this actor trigger a + # review, so a manual label by a triage/maintainer cannot bypass the gate. + # Default is the actor for the stock GITHUB_TOKEN; set to your App's bot + # login (e.g. "vouch-bot[bot]") if the vouch workflow labels via an App. + vouch_review_labeler: str = Field("github-actions[bot]", alias="ROBOMP_VOUCH_REVIEW_LABELER") # gh-proxy. Set BOTH to route GitHub through the proxy; leave both empty # to keep PAT-on-orchestrator behavior. Mixing the two (PAT + proxy) is diff --git a/python/robomp/src/github_events.py b/python/robomp/src/github_events.py index 1e0c08ceb..75007fff2 100644 --- a/python/robomp/src/github_events.py +++ b/python/robomp/src/github_events.py @@ -140,6 +140,23 @@ def is_implementation_authorizer( return False +def _pr_review_pr(pr: Mapping[str, Any], repo: str, action: str, bot_login: str) -> RouteDecision: + """Build a `review_pr` decision for an incoming PR, or the matching skip.""" + if str(pr.get("state") or "open") != "open": + return RouteDecision("skip", None, repo, None, "PR not open") + if bool(pr.get("draft")): + return RouteDecision("skip", None, repo, None, "draft PR") + if _is_bot_account(pr.get("user") or {}, bot_login): + return RouteDecision("skip", None, repo, None, "bot-authored PR") + number = pr.get("number") + if not isinstance(number, int): + return RouteDecision("skip", None, repo, None, "PR missing number") + login, assoc = _submitter_info(pr) + return RouteDecision( + "queue", "review_pr", repo, issue_key(repo, number), f"pull_request.{action}", submitter=login, association=assoc + ) + + def route( event_type: str, payload: Mapping[str, Any], @@ -150,6 +167,9 @@ def route( reviewer_bots: frozenset[str] = frozenset(), resolve_issue_from_pr: PrIssueResolver = None, pr_review_enabled: bool = True, + pr_review_trigger: str = "open", + vouch_review_label: str = "vouched", + vouch_review_labeler: str = "github-actions[bot]", ) -> RouteDecision: """Decide whether and how to handle a webhook event. @@ -281,24 +301,27 @@ def route( if not pr_review_enabled: return RouteDecision("skip", None, repo, None, "PR review disabled") pr = payload.get("pull_request") or {} - if bool(pr.get("draft")): - return RouteDecision("skip", None, repo, None, "draft PR") - pr_user = pr.get("user") or {} - if _is_bot_account(pr_user, bot_login): - return RouteDecision("skip", None, repo, None, "bot-authored PR") - number = pr.get("number") - if not isinstance(number, int): - return RouteDecision("skip", None, repo, None, "PR missing number") - login, assoc = _submitter_info(pr) - return RouteDecision( - "queue", - "review_pr", - repo, - issue_key(repo, number), - f"pull_request.{action}", - submitter=login, - association=assoc, - ) + if pr_review_trigger == "vouched_label": + # Defer to the vouch gate. robomp reviews ONLY on the `labeled` + # event the workflow emits AFTER a fresh check (it re-applies the + # vouch label on every opened/reopened/ready_for_review). Never + # trust a persisted label here: a since-denounced author must not + # slip through on reopen. + return RouteDecision("skip", None, repo, None, "deferred to vouch label") + return _pr_review_pr(pr, repo, action, bot_login) + + if event_type == "pull_request" and action == "labeled" and pr_review_trigger == "vouched_label": + if not pr_review_enabled: + return RouteDecision("skip", None, repo, None, "PR review disabled") + label = payload.get("label") + label_name = str(label.get("name") or "") if isinstance(label, Mapping) else "" + if label_name.lower() != vouch_review_label.lower(): + return RouteDecision("skip", None, repo, None, f"label {label_name!r} not vouch label") + sender = payload.get("sender") + labeler = str(sender.get("login") or "") if isinstance(sender, Mapping) else "" + if labeler.lower() != vouch_review_labeler.lower(): + return RouteDecision("skip", None, repo, None, f"vouch label not from trusted labeler ({labeler!r})") + return _pr_review_pr(payload.get("pull_request") or {}, repo, action, bot_login) if event_type == "pull_request_review_comment" and action == "created": comment = payload.get("comment") or {} diff --git a/python/robomp/src/server.py b/python/robomp/src/server.py index 5d64700a6..0d1978679 100644 --- a/python/robomp/src/server.py +++ b/python/robomp/src/server.py @@ -341,6 +341,9 @@ def create_app(settings: Settings | None = None) -> FastAPI: maintainers=cfg.maintainer_logins, reviewer_bots=cfg.reviewer_bots, pr_review_enabled=cfg.pr_review_enabled, + pr_review_trigger=cfg.pr_review_trigger, + vouch_review_label=cfg.vouch_review_label, + vouch_review_labeler=cfg.vouch_review_labeler, resolve_issue_from_pr=_resolve, ) diff --git a/python/robomp/tests/test_github_events.py b/python/robomp/tests/test_github_events.py index 6acffbb09..67d51f2dc 100644 --- a/python/robomp/tests/test_github_events.py +++ b/python/robomp/tests/test_github_events.py @@ -857,3 +857,197 @@ def test_route_non_directive_comment_carries_no_pragmas() -> None: ) assert decision.directive is False assert decision.directive_pragmas == () + + +# ---------- vouched-label deferred PR review ---------- + + +def test_route_vouched_label_defers_pr_open() -> None: + decision = route( + "pull_request", + { + "action": "opened", + "pull_request": {"number": 9, "draft": False, "user": {"login": "alice", "type": "User"}}, + "repository": {"full_name": "octo/widget"}, + }, + allowlist=ALLOWLIST, + bot_login=BOT, + pr_review_trigger="vouched_label", + ) + assert not decision.should_queue + assert decision.reason == "deferred to vouch label" + + +def test_route_vouched_label_reviews_on_label() -> None: + decision = route( + "pull_request", + { + "action": "labeled", + "label": {"name": "vouched"}, + "pull_request": { + "number": 9, + "draft": False, + "user": {"login": "alice", "type": "User"}, + "author_association": "CONTRIBUTOR", + }, + "repository": {"full_name": "octo/widget"}, + "sender": {"login": "github-actions[bot]"}, + }, + allowlist=ALLOWLIST, + bot_login=BOT, + pr_review_trigger="vouched_label", + ) + assert decision.should_queue + assert decision.task == "review_pr" + assert decision.issue_key == "octo/widget#9" + assert decision.submitter == "alice" + + +def test_route_vouched_label_ignores_other_labels() -> None: + decision = route( + "pull_request", + { + "action": "labeled", + "label": {"name": "bug"}, + "pull_request": {"number": 9, "user": {"login": "alice"}}, + "repository": {"full_name": "octo/widget"}, + }, + allowlist=ALLOWLIST, + bot_login=BOT, + pr_review_trigger="vouched_label", + ) + assert not decision.should_queue + + +def test_route_vouched_label_ready_for_review_defers_even_with_label() -> None: + # Persisted labels are NOT trusted; the workflow re-applies the label (a + # fresh `labeled` event) after re-validating, so ready_for_review defers. + decision = route( + "pull_request", + { + "action": "ready_for_review", + "pull_request": { + "number": 9, + "draft": False, + "user": {"login": "alice", "type": "User"}, + "labels": [{"name": "vouched"}], + }, + "repository": {"full_name": "octo/widget"}, + }, + allowlist=ALLOWLIST, + bot_login=BOT, + pr_review_trigger="vouched_label", + ) + assert not decision.should_queue + assert decision.reason == "deferred to vouch label" + + +def test_route_vouched_label_labeled_skips_draft() -> None: + decision = route( + "pull_request", + { + "action": "labeled", + "label": {"name": "vouched"}, + "pull_request": {"number": 9, "draft": True, "user": {"login": "alice", "type": "User"}}, + "repository": {"full_name": "octo/widget"}, + "sender": {"login": "github-actions[bot]"}, + }, + allowlist=ALLOWLIST, + bot_login=BOT, + pr_review_trigger="vouched_label", + ) + assert not decision.should_queue + assert decision.reason == "draft PR" + + +def test_route_default_trigger_ignores_labeled() -> None: + # Backward-compat: the default "open" trigger does not route `labeled`. + decision = route( + "pull_request", + { + "action": "labeled", + "label": {"name": "vouched"}, + "pull_request": {"number": 9, "user": {"login": "alice"}}, + "repository": {"full_name": "octo/widget"}, + }, + allowlist=ALLOWLIST, + bot_login=BOT, + ) + assert not decision.should_queue + + +def test_route_vouched_label_reopened_defers_even_with_label() -> None: + # A since-denounced author must not slip through on reopen via a stale + # label; reopened always defers to a fresh gate check + re-label. + decision = route( + "pull_request", + { + "action": "reopened", + "pull_request": { + "number": 9, + "draft": False, + "user": {"login": "alice", "type": "User"}, + "labels": [{"name": "vouched"}], + }, + "repository": {"full_name": "octo/widget"}, + }, + allowlist=ALLOWLIST, + bot_login=BOT, + pr_review_trigger="vouched_label", + ) + assert not decision.should_queue + assert decision.reason == "deferred to vouch label" + + +def test_route_vouched_label_reopened_without_label_defers() -> None: + decision = route( + "pull_request", + { + "action": "reopened", + "pull_request": {"number": 9, "draft": False, "user": {"login": "alice", "type": "User"}}, + "repository": {"full_name": "octo/widget"}, + }, + allowlist=ALLOWLIST, + bot_login=BOT, + pr_review_trigger="vouched_label", + ) + assert not decision.should_queue + assert decision.reason == "deferred to vouch label" + + +def test_route_vouched_label_rejects_manual_labeler() -> None: + # A triage/maintainer hand-adding the label must NOT trigger review. + decision = route( + "pull_request", + { + "action": "labeled", + "label": {"name": "vouched"}, + "pull_request": {"number": 9, "draft": False, "user": {"login": "alice", "type": "User"}}, + "repository": {"full_name": "octo/widget"}, + "sender": {"login": "evilmaintainer"}, + }, + allowlist=ALLOWLIST, + bot_login=BOT, + pr_review_trigger="vouched_label", + ) + assert not decision.should_queue + assert "trusted labeler" in decision.reason + + +def test_route_vouched_label_skips_closed_pr() -> None: + # `labeled` can fire on a closed PR; never review one. + decision = route( + "pull_request", + { + "action": "labeled", + "label": {"name": "vouched"}, + "pull_request": {"number": 9, "state": "closed", "user": {"login": "alice", "type": "User"}}, + "repository": {"full_name": "octo/widget"}, + "sender": {"login": "github-actions[bot]"}, + }, + allowlist=ALLOWLIST, + bot_login=BOT, + pr_review_trigger="vouched_label", + ) + assert not decision.should_queue + assert decision.reason == "PR not open" diff --git a/scripts/ci-test-ts.ts b/scripts/ci-test-ts.ts index 5aecdf6da..f81c7a5c4 100755 --- a/scripts/ci-test-ts.ts +++ b/scripts/ci-test-ts.ts @@ -136,7 +136,6 @@ const codingAgentSingletonContentMarkers = [ "Settings.instance", "resetSettingsForTest", "setAgentDir(", - "setDefaultTabWidth(", "vi.useFakeTimers(", "vi.useRealTimers(", "vi.stubEnv(",