Merge remote-tracking branch 'upstream/main' into feat/profiles-and-alias
This commit is contained in:
@@ -179,7 +179,7 @@ runs:
|
||||
name: pi-natives-${{ inputs.platform }}-${{ inputs.arch }}${{ inputs.variant && format('-{0}', inputs.variant) || '' }}-h${{ inputs.hash }}
|
||||
path: packages/natives/native/pi_natives.${{ inputs.platform }}-${{ inputs.arch }}*.node
|
||||
if-no-files-found: error
|
||||
# Explicit so the rust-hash canary lookup keeps working even if org
|
||||
# Explicit so the native_artifact_lookup canary keeps working even if org
|
||||
# defaults shift; bump if Rust source ever stays stable for >90 days
|
||||
# of main pushes and you want to avoid rebuilds.
|
||||
retention-days: 90
|
||||
|
||||
+183
-94
@@ -21,30 +21,32 @@ env:
|
||||
|
||||
jobs:
|
||||
# scripts/release.ts pushes the version-bump commit and its `v*` tag
|
||||
# atomically (`git push --atomic origin main refs/tags/v*`), so a release
|
||||
# now arrives as a single `push` to `refs/heads/main` — we no longer trigger
|
||||
# on the tag ref at all (see `on.push`). This one branch-push run is therefore
|
||||
# authoritative: it runs the full build AND, when HEAD carries a release tag,
|
||||
# the release/publish jobs. `gate` resolves that tag once so downstream jobs
|
||||
# switch on `is-release` and address the tag by name — `github.ref` is
|
||||
# atomically (`git push --atomic origin refs/heads/main:refs/heads/main
|
||||
# <sha>:refs/tags/v<version>`), so a release now arrives as a single `push` to
|
||||
# `refs/heads/main` — we no longer trigger on the tag ref at all (see
|
||||
# `on.push`). This one branch-push run is therefore authoritative: it runs the
|
||||
# full build AND, when HEAD carries a release tag, the release/publish jobs.
|
||||
# `release_metadata` resolves that tag once so downstream jobs switch on
|
||||
# `is-release` and address the tag by name — `github.ref` is
|
||||
# `refs/heads/main` here, not the tag. A `workflow_dispatch` from a `v*` tag
|
||||
# ref is also treated as a release (the manual re-publish escape hatch).
|
||||
gate:
|
||||
# ref (or from a tagged main HEAD) is also treated as a release.
|
||||
release_metadata:
|
||||
name: Resolve release metadata
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
is-release: ${{ steps.check.outputs.is-release }}
|
||||
release-tag: ${{ steps.check.outputs.release-tag }}
|
||||
is-release: ${{ steps.detect.outputs.is-release }}
|
||||
release-tag: ${{ steps.detect.outputs.release-tag }}
|
||||
steps:
|
||||
# Only a main-branch push needs tags fetched, so `git tag --points-at
|
||||
# Only a main-branch run needs tags fetched, so `git tag --points-at
|
||||
# HEAD` can see the freshly-pushed `v*`. A tag-ref dispatch reads the
|
||||
# tag straight from `github.ref_name`, and fetching `--tags` while
|
||||
# checkout uses an explicit tag refspec makes git refuse — so scope
|
||||
# fetch-tags to main pushes.
|
||||
# fetch-tags to main refs.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-tags: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Detect release tag at HEAD
|
||||
id: check
|
||||
id: detect
|
||||
shell: bash
|
||||
run: |
|
||||
is_release=false
|
||||
@@ -71,27 +73,29 @@ jobs:
|
||||
# Compute a stable hash of every input that affects the native cdylib output,
|
||||
# then look for any prior successful main run that already uploaded the
|
||||
# native artifacts for this hash. Two independent outputs:
|
||||
# * `linux-run-id` — set when the linux x64 canary (`pi-natives-linux-x64-modern-h<hash>`)
|
||||
# is present on a prior main run, so `test`/`native_linux` can reuse it.
|
||||
# * `release-run-id` — set when ALL native_release platforms also have
|
||||
# non-expired artifacts on that same prior run, so `native_release` can
|
||||
# skip the cold rebuild on main pushes after dep changes have already
|
||||
# warmed sccache there.
|
||||
# Non-tag native jobs are skipped when their canary hits; the canary
|
||||
# * `linux-x64-run-id` — set when the linux x64 canary
|
||||
# (`pi-natives-linux-x64-modern-h<hash>`) is present on a prior main run,
|
||||
# so `test`/`native_linux_x64` can reuse it.
|
||||
# * `cross-platform-run-id` — set when ALL cross-platform native artifacts
|
||||
# also have non-expired artifacts on that same prior run, so
|
||||
# `native_cross_platform` can skip the cold rebuild on main pushes after
|
||||
# dep changes have already warmed sccache there.
|
||||
# Non-release native jobs are skipped when their canary hits; the canary
|
||||
# retention window (see build-native action) is the effective TTL.
|
||||
rust-hash:
|
||||
native_artifact_lookup:
|
||||
name: Look up cached native artifacts
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
hash: ${{ steps.compute.outputs.hash }}
|
||||
linux-run-id: ${{ steps.find.outputs.linux-run-id }}
|
||||
release-run-id: ${{ steps.find.outputs.release-run-id }}
|
||||
source-hash: ${{ steps.compute.outputs.source-hash }}
|
||||
linux-x64-run-id: ${{ steps.find.outputs.linux-x64-run-id }}
|
||||
cross-platform-run-id: ${{ steps.find.outputs.cross-platform-run-id }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Compute rust source hash
|
||||
- name: Compute native source hash
|
||||
id: compute
|
||||
shell: bash
|
||||
run: |
|
||||
hash=$(find crates Cargo.toml Cargo.lock rust-toolchain.toml \
|
||||
source_hash=$(find crates Cargo.toml Cargo.lock rust-toolchain.toml \
|
||||
packages/natives/scripts packages/natives/package.json \
|
||||
scripts/ci-build-native.ts scripts/host-detect.ts \
|
||||
-type f -print0 \
|
||||
@@ -99,44 +103,46 @@ jobs:
|
||||
| xargs -0 sha256sum \
|
||||
| sha256sum \
|
||||
| cut -c1-16)
|
||||
echo "hash=$hash" >> "$GITHUB_OUTPUT"
|
||||
echo "Rust source hash: $hash"
|
||||
echo "source-hash=$source_hash" >> "$GITHUB_OUTPUT"
|
||||
echo "Native source hash: $source_hash"
|
||||
- name: Find prior main build with matching native artifacts
|
||||
id: find
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
hash="${{ steps.compute.outputs.hash }}"
|
||||
# Canary for native_linux: presence of the modern artifact implies
|
||||
# the baseline sibling is also there (they upload from the same job).
|
||||
hash="${{ steps.compute.outputs.source-hash }}"
|
||||
# Canary for native_linux_x64: presence of the modern artifact
|
||||
# implies the baseline sibling is also there (they upload from the
|
||||
# same job).
|
||||
linux_canary="pi-natives-linux-x64-modern-h${hash}"
|
||||
# Required set for native_release reuse — names must match the
|
||||
# Required set for cross-platform reuse — names must match the
|
||||
# `actions/upload-artifact` `name:` template in build-native action.
|
||||
release_required=(
|
||||
cross_platform_required=(
|
||||
"pi-natives-linux-arm64-h${hash}"
|
||||
"pi-natives-darwin-x64-baseline-h${hash}"
|
||||
"pi-natives-darwin-arm64-h${hash}"
|
||||
"pi-natives-win32-x64-baseline-h${hash}"
|
||||
)
|
||||
linux_run_id=""
|
||||
release_run_id=""
|
||||
linux_x64_run_id=""
|
||||
cross_platform_run_id=""
|
||||
for candidate in $(gh run list \
|
||||
--workflow=ci.yml --branch=main --status=success --event=push \
|
||||
--limit=20 --json databaseId --jq='.[].databaseId'); do
|
||||
names=$(gh api "/repos/${{ github.repository }}/actions/runs/$candidate/artifacts?per_page=100" \
|
||||
--jq '.artifacts[] | select(.expired == false) | .name')
|
||||
if [ -z "$linux_run_id" ] && echo "$names" | grep -qFx "$linux_canary"; then
|
||||
linux_run_id="$candidate"
|
||||
if [ -z "$linux_x64_run_id" ] && echo "$names" | grep -qFx "$linux_canary"; then
|
||||
linux_x64_run_id="$candidate"
|
||||
fi
|
||||
if [ -z "$release_run_id" ]; then
|
||||
if [ -z "$cross_platform_run_id" ]; then
|
||||
all_found=true
|
||||
# Release reuse requires the linux canary AND every cross-platform
|
||||
# artifact, since release_binary downloads them from the same run.
|
||||
# Cross-platform reuse requires the linux canary AND every
|
||||
# cross-platform artifact, since release_binary downloads them
|
||||
# from the same run.
|
||||
if ! echo "$names" | grep -qFx "$linux_canary"; then
|
||||
all_found=false
|
||||
else
|
||||
for req in "${release_required[@]}"; do
|
||||
for req in "${cross_platform_required[@]}"; do
|
||||
if ! echo "$names" | grep -qFx "$req"; then
|
||||
all_found=false
|
||||
break
|
||||
@@ -144,30 +150,31 @@ jobs:
|
||||
done
|
||||
fi
|
||||
if $all_found; then
|
||||
release_run_id="$candidate"
|
||||
cross_platform_run_id="$candidate"
|
||||
fi
|
||||
fi
|
||||
if [ -n "$linux_run_id" ] && [ -n "$release_run_id" ]; then
|
||||
if [ -n "$linux_x64_run_id" ] && [ -n "$cross_platform_run_id" ]; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -n "$linux_run_id" ]; then
|
||||
echo "Reusing native_linux artifacts from run $linux_run_id"
|
||||
if [ -n "$linux_x64_run_id" ]; then
|
||||
echo "Reusing Linux x64 native artifacts from run $linux_x64_run_id"
|
||||
else
|
||||
echo "No cached native_linux artifacts for hash $hash; native_linux will rebuild."
|
||||
echo "No cached Linux x64 native artifacts for hash $hash; native_linux_x64 will rebuild."
|
||||
fi
|
||||
if [ -n "$release_run_id" ]; then
|
||||
echo "Reusing native_release artifacts from run $release_run_id"
|
||||
if [ -n "$cross_platform_run_id" ]; then
|
||||
echo "Reusing cross-platform native artifacts from run $cross_platform_run_id"
|
||||
else
|
||||
echo "No cached native_release artifacts for hash $hash; native_release will rebuild on main."
|
||||
echo "No cached cross-platform native artifacts for hash $hash; native_cross_platform will rebuild on main."
|
||||
fi
|
||||
{
|
||||
echo "linux-run-id=$linux_run_id"
|
||||
echo "release-run-id=$release_run_id"
|
||||
echo "linux-x64-run-id=$linux_x64_run_id"
|
||||
echo "cross-platform-run-id=$cross_platform_run_id"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Fast lint + type check (no Rust, no native build needed)
|
||||
check:
|
||||
name: Lint & type check
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -184,10 +191,12 @@ jobs:
|
||||
run: bun run ci:check:full
|
||||
|
||||
# Linux x64 baseline + modern: required by `test`, so it runs on every PR
|
||||
# unless rust-hash found a cached run. Release pushes always rebuild for fresh artifacts.
|
||||
native_linux:
|
||||
needs: [gate, rust-hash]
|
||||
if: ${{ needs.gate.outputs.is-release == 'true' || needs.rust-hash.outputs.linux-run-id == '' }}
|
||||
# unless native_artifact_lookup found a cached run. Release runs always
|
||||
# rebuild for fresh artifacts.
|
||||
native_linux_x64:
|
||||
name: "Native: Linux x64 (${{ matrix.variant }})"
|
||||
needs: [release_metadata, native_artifact_lookup]
|
||||
if: ${{ needs.release_metadata.outputs.is-release == 'true' || needs.native_artifact_lookup.outputs.linux-x64-run-id == '' }}
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -199,7 +208,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/build-native
|
||||
with:
|
||||
hash: ${{ needs.rust-hash.outputs.hash }}
|
||||
hash: ${{ needs.native_artifact_lookup.outputs.source-hash }}
|
||||
platform: linux
|
||||
arch: x64
|
||||
variant: ${{ matrix.variant }}
|
||||
@@ -207,11 +216,12 @@ jobs:
|
||||
save_cache: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
|
||||
# Pre-warm the cross-platform native build cache on `main`, in addition to
|
||||
# building the artifacts that ship in release tags. Skipped on main when the
|
||||
# rust-hash canary already found a recent run with all artifacts intact.
|
||||
native_release:
|
||||
needs: [gate, rust-hash]
|
||||
if: ${{ needs.gate.outputs.is-release == 'true' || (github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.rust-hash.outputs.release-run-id == '') }}
|
||||
# building the artifacts that ship in releases. Skipped on main when
|
||||
# native_artifact_lookup already found a recent run with all artifacts intact.
|
||||
native_cross_platform:
|
||||
name: "Native: ${{ matrix.platform }} ${{ matrix.arch }}"
|
||||
needs: [release_metadata, native_artifact_lookup]
|
||||
if: ${{ needs.release_metadata.outputs.is-release == 'true' || (github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.native_artifact_lookup.outputs.cross-platform-run-id == '') }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -225,7 +235,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/build-native
|
||||
with:
|
||||
hash: ${{ needs.rust-hash.outputs.hash }}
|
||||
hash: ${{ needs.native_artifact_lookup.outputs.source-hash }}
|
||||
platform: ${{ matrix.platform }}
|
||||
arch: ${{ matrix.arch }}
|
||||
variant: ${{ matrix.variant }}
|
||||
@@ -233,9 +243,10 @@ jobs:
|
||||
save_cache: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
|
||||
test:
|
||||
name: Test & smoke (TS)
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [native_linux, rust-hash]
|
||||
if: ${{ !cancelled() && needs.native_linux.result != 'failure' }}
|
||||
needs: [native_linux_x64, native_artifact_lookup]
|
||||
if: ${{ !cancelled() && needs.native_linux_x64.result != 'failure' }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -251,25 +262,25 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev fd-find ripgrep imagemagick
|
||||
sudo ln -s $(which fdfind) /usr/local/bin/fd
|
||||
sudo ln -sf "$(command -v fdfind)" /usr/local/bin/fd
|
||||
sudo ln -sf /usr/bin/convert /usr/local/bin/magick
|
||||
- run: bun install --frozen-lockfile
|
||||
- name: Resolve native source run
|
||||
- name: Resolve Linux x64 native artifact run
|
||||
id: source
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ needs.native_linux.result }}" = "success" ]; then
|
||||
echo "run-id=${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
if [ "${{ needs.native_linux_x64.result }}" = "success" ]; then
|
||||
echo "artifact-run-id=${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "run-id=${{ needs.rust-hash.outputs.linux-run-id }}" >> "$GITHUB_OUTPUT"
|
||||
echo "artifact-run-id=${{ needs.native_artifact_lookup.outputs.linux-x64-run-id }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Download native addons
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: pi-natives-linux-x64-*-h${{ needs.rust-hash.outputs.hash }}
|
||||
pattern: pi-natives-linux-x64-*-h${{ needs.native_artifact_lookup.outputs.source-hash }}
|
||||
path: packages/natives/native
|
||||
merge-multiple: true
|
||||
run-id: ${{ steps.source.outputs.run-id }}
|
||||
run-id: ${{ steps.source.outputs.artifact-run-id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Test workspace (TS)
|
||||
# `test:ts` sets GITHUB_ACTIONS=0 inline so `bun test` skips its
|
||||
@@ -281,6 +292,7 @@ jobs:
|
||||
run: bun run ci:test:smoke
|
||||
|
||||
install_methods:
|
||||
name: Install method smoke tests
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -297,8 +309,8 @@ jobs:
|
||||
save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
cache-workspace-crates: true
|
||||
# Layer sccache on top of rust-cache for the same reason as the
|
||||
# build-native action: tag pushes bump workspace versions and bust
|
||||
# the target/ cache, but sccache hits at the rustc-unit level survive.
|
||||
# build-native action: release version bumps bust the target/ cache,
|
||||
# but sccache hits at the rustc-unit level survive.
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@v0.0.10
|
||||
- name: Enable sccache for cargo
|
||||
@@ -318,18 +330,19 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev fd-find ripgrep imagemagick
|
||||
sudo ln -s $(which fdfind) /usr/local/bin/fd
|
||||
sudo ln -sf "$(command -v fdfind)" /usr/local/bin/fd
|
||||
sudo ln -sf /usr/bin/convert /usr/local/bin/magick
|
||||
- run: bun install --frozen-lockfile
|
||||
- name: Install method smoke tests
|
||||
run: bun run ci:test:install-methods
|
||||
|
||||
release_binary:
|
||||
if: ${{ needs.gate.outputs.is-release == 'true' && !cancelled() &&
|
||||
needs.native_linux.result == 'success' && needs.native_release.result ==
|
||||
name: "Release binary: ${{ matrix.target_id }}"
|
||||
if: ${{ needs.release_metadata.outputs.is-release == 'true' && !cancelled() &&
|
||||
needs.native_linux_x64.result == 'success' && needs.native_cross_platform.result ==
|
||||
'success' && needs.test.result == 'success' && needs.check.result ==
|
||||
'success' && needs.install_methods.result == 'success' }}
|
||||
needs: [gate, check, native_linux, native_release, test, install_methods, rust-hash]
|
||||
needs: [release_metadata, check, native_linux_x64, native_cross_platform, test, install_methods, native_artifact_lookup]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -373,6 +386,8 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
env:
|
||||
MACOS_SIGNING: ${{ secrets.APPLE_CERTIFICATE_P12 != '' && secrets.APPLE_CERTIFICATE_PASSWORD != '' && secrets.APPLE_API_KEY_ID != '' && secrets.APPLE_API_ISSUER_ID != '' && secrets.APPLE_API_KEY != '' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
@@ -382,7 +397,7 @@ jobs:
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
# Trusted publishing allowed-actions flags require npm >= 11.16.0.
|
||||
# Keep npm aligned with trusted publishing setup (>= 11.16.0).
|
||||
- name: Ensure npm supports trusted publishing
|
||||
if: ${{ !inputs.skip_npm }}
|
||||
run: npm install -g npm@latest
|
||||
@@ -395,13 +410,26 @@ jobs:
|
||||
- name: Download native addon(s)
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: pi-natives-${{ matrix.platform }}-${{ matrix.arch }}*-h${{ needs.rust-hash.outputs.hash }}
|
||||
pattern: pi-natives-${{ matrix.platform }}-${{ matrix.arch }}*-h${{ needs.native_artifact_lookup.outputs.source-hash }}
|
||||
path: packages/natives/native
|
||||
merge-multiple: true
|
||||
- name: Build release binary
|
||||
env:
|
||||
RELEASE_TARGETS: ${{ matrix.target_id }}
|
||||
run: bun run ci:release:build-binaries
|
||||
- name: Sign and notarize macOS binary (Developer ID)
|
||||
# Replaces the ad-hoc signature with a Developer ID + hardened-runtime
|
||||
# one (+JIT/library-validation entitlements; omp dlopens its
|
||||
# runtime-extracted native addon, which has a different Team ID) and
|
||||
# notarizes. Auto-skips until the APPLE_* secrets are configured.
|
||||
if: matrix.platform == 'darwin' && env.MACOS_SIGNING == 'true'
|
||||
env:
|
||||
APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||
APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }}
|
||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||
run: bash scripts/ci-macos-sign.sh "${{ matrix.binary_path }}"
|
||||
# Windows binary is cross-built on Linux, so we have no Windows runner
|
||||
# to smoke it on. Cross-build correctness is verified via the napi
|
||||
# entry-point exports (see build-native action) and the bun
|
||||
@@ -426,10 +454,11 @@ jobs:
|
||||
name: omp-binary-${{ matrix.target_id }}
|
||||
path: ${{ matrix.binary_path }}
|
||||
|
||||
release-github:
|
||||
if: ${{ needs.gate.outputs.is-release == 'true' && !cancelled() &&
|
||||
release_github:
|
||||
name: Publish GitHub release
|
||||
if: ${{ needs.release_metadata.outputs.is-release == 'true' && !cancelled() &&
|
||||
needs.release_binary.result == 'success' }}
|
||||
needs: [gate, release_binary]
|
||||
needs: [release_metadata, release_binary]
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -439,7 +468,7 @@ jobs:
|
||||
with:
|
||||
bun-version: "1.3"
|
||||
- name: Generate release notes from CHANGELOGs
|
||||
run: bun scripts/ci-release-notes.ts ${{ needs.gate.outputs.release-tag }}
|
||||
run: bun scripts/ci-release-notes.ts ${{ needs.release_metadata.outputs.release-tag }}
|
||||
- name: Download release binaries
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -449,7 +478,7 @@ jobs:
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.gate.outputs.release-tag }}
|
||||
tag_name: ${{ needs.release_metadata.outputs.release-tag }}
|
||||
files: |
|
||||
packages/coding-agent/binaries/omp-*
|
||||
body_path: release-notes.md
|
||||
@@ -457,29 +486,46 @@ jobs:
|
||||
|
||||
|
||||
release_github_verify:
|
||||
if: ${{ needs.gate.outputs.is-release == 'true' && !cancelled() &&
|
||||
needs['release-github'].result == 'success' }}
|
||||
needs: [gate, release-github]
|
||||
name: Verify published release (macOS)
|
||||
if: ${{ needs.release_metadata.outputs.is-release == 'true' && !cancelled() &&
|
||||
needs.release_github.result == 'success' }}
|
||||
needs: [release_metadata, release_github]
|
||||
runs-on: macos-14
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
MACOS_SIGNING: ${{ secrets.APPLE_CERTIFICATE_P12 != '' && secrets.APPLE_CERTIFICATE_PASSWORD != '' && secrets.APPLE_API_KEY_ID != '' && secrets.APPLE_API_ISSUER_ID != '' && secrets.APPLE_API_KEY != '' }}
|
||||
steps:
|
||||
- name: Download published macOS arm64 binary
|
||||
run: |
|
||||
curl -fsSL -o omp-darwin-arm64 "https://github.com/${{ github.repository }}/releases/download/${{ needs.gate.outputs.release-tag }}/omp-darwin-arm64"
|
||||
curl -fsSL -o omp-darwin-arm64 "https://github.com/${{ github.repository }}/releases/download/${{ needs.release_metadata.outputs.release-tag }}/omp-darwin-arm64"
|
||||
chmod +x omp-darwin-arm64
|
||||
- name: Verify published macOS arm64 binary
|
||||
run: |
|
||||
codesign -dv ./omp-darwin-arm64
|
||||
codesign -dvvv ./omp-darwin-arm64
|
||||
codesign --verify --strict --verbose=4 ./omp-darwin-arm64
|
||||
runtime_dir="$(mktemp -d)"
|
||||
HOME="$runtime_dir/home" XDG_DATA_HOME="$runtime_dir/xdg" ./omp-darwin-arm64 --version
|
||||
HOME="$runtime_dir/home" XDG_DATA_HOME="$runtime_dir/xdg" ./omp-darwin-arm64 --smoke-test
|
||||
- name: Assert signed release is not ad-hoc
|
||||
if: env.MACOS_SIGNING == 'true'
|
||||
run: |
|
||||
if codesign -dvvv ./omp-darwin-arm64 2>&1 | grep -qE "flags=.*adhoc|Signature=adhoc"; then
|
||||
echo "published binary is still ad-hoc signed (Developer ID signing did not run)" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Gatekeeper assessment: a notarized Developer ID binary is accepted.
|
||||
# Informational — a bare (unstapled) Mach-O relies on the online ticket
|
||||
# lookup, so surface the result without gating the release on it.
|
||||
spctl -a -t exec -vv ./omp-darwin-arm64 || echo "spctl non-zero (expected for unstapled bare binary; ticket served online)"
|
||||
|
||||
release-npm:
|
||||
if: ${{ needs.gate.outputs.is-release == 'true' && !cancelled() &&
|
||||
release_npm:
|
||||
name: Publish to npm
|
||||
if: ${{ needs.release_metadata.outputs.is-release == 'true' && !cancelled() &&
|
||||
needs.release_binary.result == 'success' &&
|
||||
needs.release_github_verify.result == 'success' &&
|
||||
!inputs.skip_npm }}
|
||||
needs: [gate, release_binary, release_github_verify]
|
||||
needs: [release_metadata, release_binary, release_github_verify]
|
||||
runs-on: ubuntu-22.04
|
||||
# `id-token: write` lets npm mint the GitHub OIDC token it exchanges for a
|
||||
# short-lived publish token (trusted publishing + provenance). When a
|
||||
@@ -497,8 +543,8 @@ jobs:
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
# Trusted publishing (OIDC) and auto-provenance need npm >= 11.5.1.
|
||||
- name: Ensure npm supports OIDC trusted publishing
|
||||
# Keep npm aligned with trusted publishing setup (>= 11.16.0).
|
||||
- name: Ensure npm supports trusted publishing
|
||||
run: npm install -g npm@latest
|
||||
- name: Cache bun dependencies
|
||||
uses: actions/cache@v4
|
||||
@@ -513,3 +559,46 @@ jobs:
|
||||
# publisher for the package (or on a first publish).
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: bun run ci:release:publish
|
||||
|
||||
# Regenerate the Homebrew tap formula (can1357/homebrew-tap) from the freshly
|
||||
# published release assets and push it. Gated on release_github_verify so the
|
||||
# tap only cuts over to a release whose published binary was verified (matches
|
||||
# how release_npm is gated). No-ops when HOMEBREW_TAP_DEPLOY_KEY is unset, so a
|
||||
# release never blocks on tap access.
|
||||
release_brew:
|
||||
name: Update Homebrew tap
|
||||
if: ${{ needs.release_metadata.outputs.is-release == 'true' && !cancelled() &&
|
||||
needs.release_github_verify.result == 'success' }}
|
||||
needs: [release_metadata, release_github_verify]
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
HAS_TAP_KEY: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY != '' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
if: env.HAS_TAP_KEY == 'true'
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
if: env.HAS_TAP_KEY == 'true'
|
||||
with:
|
||||
bun-version: "1.3"
|
||||
- name: Check out the Homebrew tap
|
||||
if: env.HAS_TAP_KEY == 'true'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: can1357/homebrew-tap
|
||||
ssh-key: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }}
|
||||
path: homebrew-tap
|
||||
- name: Regenerate and push the formula
|
||||
if: env.HAS_TAP_KEY == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
bun scripts/ci-update-brew-formula.ts "${{ needs.release_metadata.outputs.release-tag }}" --out homebrew-tap/Formula/omp.rb
|
||||
cd homebrew-tap
|
||||
if git diff --quiet -- Formula/omp.rb; then
|
||||
echo "formula already up to date for ${{ needs.release_metadata.outputs.release-tag }}"
|
||||
exit 0
|
||||
fi
|
||||
git -c user.name="github-actions[bot]" \
|
||||
-c user.email="41898282+github-actions[bot]@users.noreply.github.com" \
|
||||
commit -m "omp ${{ needs.release_metadata.outputs.release-tag }}" -- Formula/omp.rb
|
||||
git push origin HEAD:main
|
||||
|
||||
Generated
+4
-4
@@ -2331,7 +2331,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-ast"
|
||||
version = "15.10.1"
|
||||
version = "15.10.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ast-grep-core",
|
||||
@@ -2399,7 +2399,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-iso"
|
||||
version = "15.10.1"
|
||||
version = "15.10.4"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"libc",
|
||||
@@ -2411,7 +2411,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-natives"
|
||||
version = "15.10.1"
|
||||
version = "15.10.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -2457,7 +2457,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-shell"
|
||||
version = "15.10.1"
|
||||
version = "15.10.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"brush-builtins",
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ exclude = ["crates/brush-core-vendored", "crates/brush-builtins-vendored"]
|
||||
resolver = "3"
|
||||
|
||||
[workspace.package]
|
||||
version = "15.10.1"
|
||||
version = "15.10.4"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Can Boluk"]
|
||||
|
||||
@@ -34,6 +34,12 @@ The most capable agent surface that ships. Continuously tuned by real-world use
|
||||
curl -fsSL https://omp.sh/install | sh
|
||||
```
|
||||
|
||||
**Homebrew**
|
||||
|
||||
```sh
|
||||
brew install can1357/tap/omp
|
||||
```
|
||||
|
||||
**Bun (recommended)**
|
||||
|
||||
```sh
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"packages/agent": {
|
||||
"name": "@oh-my-pi/pi-agent-core",
|
||||
"version": "15.10.1",
|
||||
"version": "15.10.4",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-ai": "catalog:",
|
||||
"@oh-my-pi/pi-natives": "catalog:",
|
||||
@@ -30,7 +30,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@oh-my-pi/pi-ai",
|
||||
"version": "15.10.1",
|
||||
"version": "15.10.4",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "catalog:",
|
||||
"@oh-my-pi/pi-utils": "catalog:",
|
||||
@@ -44,7 +44,7 @@
|
||||
},
|
||||
"packages/coding-agent": {
|
||||
"name": "@oh-my-pi/pi-coding-agent",
|
||||
"version": "15.10.1",
|
||||
"version": "15.10.4",
|
||||
"bin": {
|
||||
"omp": "src/cli.ts",
|
||||
},
|
||||
@@ -90,7 +90,7 @@
|
||||
},
|
||||
"packages/hashline": {
|
||||
"name": "@oh-my-pi/hashline",
|
||||
"version": "15.10.1",
|
||||
"version": "15.10.4",
|
||||
"dependencies": {
|
||||
"diff": "catalog:",
|
||||
"lru-cache": "catalog:",
|
||||
@@ -101,7 +101,7 @@
|
||||
},
|
||||
"packages/mnemopi": {
|
||||
"name": "@oh-my-pi/pi-mnemopi",
|
||||
"version": "15.10.1",
|
||||
"version": "15.10.4",
|
||||
"bin": {
|
||||
"mnemopi": "src/cli.ts",
|
||||
},
|
||||
@@ -118,7 +118,7 @@
|
||||
},
|
||||
"packages/natives": {
|
||||
"name": "@oh-my-pi/pi-natives",
|
||||
"version": "15.10.1",
|
||||
"version": "15.10.4",
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
@@ -126,7 +126,7 @@
|
||||
},
|
||||
"packages/stats": {
|
||||
"name": "@oh-my-pi/omp-stats",
|
||||
"version": "15.10.1",
|
||||
"version": "15.10.4",
|
||||
"bin": {
|
||||
"omp-stats": "./src/index.ts",
|
||||
},
|
||||
@@ -151,7 +151,7 @@
|
||||
},
|
||||
"packages/swarm-extension": {
|
||||
"name": "@oh-my-pi/swarm-extension",
|
||||
"version": "15.10.1",
|
||||
"version": "15.10.4",
|
||||
"bin": {
|
||||
"omp-swarm": "src/cli.ts",
|
||||
},
|
||||
@@ -167,7 +167,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@oh-my-pi/pi-tui",
|
||||
"version": "15.10.1",
|
||||
"version": "15.10.4",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-natives": "catalog:",
|
||||
"@oh-my-pi/pi-utils": "catalog:",
|
||||
@@ -208,7 +208,7 @@
|
||||
},
|
||||
"packages/utils": {
|
||||
"name": "@oh-my-pi/pi-utils",
|
||||
"version": "15.10.1",
|
||||
"version": "15.10.4",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-natives": "catalog:",
|
||||
"beautiful-mermaid": "catalog:",
|
||||
@@ -248,15 +248,15 @@
|
||||
"@huggingface/transformers": "^4.2.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@napi-rs/cli": "3.7.0",
|
||||
"@oh-my-pi/hashline": "15.10.1",
|
||||
"@oh-my-pi/omp-stats": "15.10.1",
|
||||
"@oh-my-pi/pi-agent-core": "15.10.1",
|
||||
"@oh-my-pi/pi-ai": "15.10.1",
|
||||
"@oh-my-pi/pi-coding-agent": "15.10.1",
|
||||
"@oh-my-pi/pi-mnemopi": "15.10.1",
|
||||
"@oh-my-pi/pi-natives": "15.10.1",
|
||||
"@oh-my-pi/pi-tui": "15.10.1",
|
||||
"@oh-my-pi/pi-utils": "15.10.1",
|
||||
"@oh-my-pi/hashline": "15.10.4",
|
||||
"@oh-my-pi/omp-stats": "15.10.4",
|
||||
"@oh-my-pi/pi-agent-core": "15.10.4",
|
||||
"@oh-my-pi/pi-ai": "15.10.4",
|
||||
"@oh-my-pi/pi-coding-agent": "15.10.4",
|
||||
"@oh-my-pi/pi-mnemopi": "15.10.4",
|
||||
"@oh-my-pi/pi-natives": "15.10.4",
|
||||
"@oh-my-pi/pi-tui": "15.10.4",
|
||||
"@oh-my-pi/pi-utils": "15.10.4",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/context-async-hooks": "^2.7.1",
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
|
||||
@@ -395,7 +395,7 @@
|
||||
|
||||
"@huggingface/jinja": ["@huggingface/jinja@0.5.9", "", {}, "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw=="],
|
||||
|
||||
"@huggingface/tasks": ["@huggingface/tasks@0.21.7", "", {}, "sha512-GuEXszIkir4j/Oywp4hXP+wfwojo/SKWA/omroNkzWWgqUGiOQ5p6HuyXcDOcinYnLQW1WsO8fwdEvtLTZbA4w=="],
|
||||
"@huggingface/tasks": ["@huggingface/tasks@0.21.8", "", {}, "sha512-+XNkgBks3dPVzSrnngHwsfB0BBjRZKmvmO6f45UDrvjygmZuzPTQN3+vbaMbxsAW2CHfEF6YQT3dAUvVNUGgug=="],
|
||||
|
||||
"@huggingface/tokenizers": ["@huggingface/tokenizers@0.1.3", "", {}, "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA=="],
|
||||
|
||||
@@ -927,7 +927,7 @@
|
||||
|
||||
"duck": ["duck@0.1.12", "", { "dependencies": { "underscore": "^1.13.1" } }, "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.364", "", {}, "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw=="],
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.368", "", {}, "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw=="],
|
||||
|
||||
"elkjs": ["elkjs@0.11.1", "", {}, "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg=="],
|
||||
|
||||
@@ -937,7 +937,7 @@
|
||||
|
||||
"enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.22.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww=="],
|
||||
"enhanced-resolve": ["enhanced-resolve@5.22.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-0rxICaFZ7NQho/sHely2bvOPRP0Eu2B0NZ9zM54YvRvWMn7jfz3DmnOZDR9LlXDdDcqntAVc6Hfy4gr/tdH/Ag=="],
|
||||
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
@@ -1101,7 +1101,7 @@
|
||||
|
||||
"mammoth": ["mammoth@1.12.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "argparse": "~1.0.3", "base64-js": "^1.5.1", "bluebird": "~3.4.0", "dingbat-to-unicode": "^1.0.1", "jszip": "^3.7.1", "lop": "^0.4.2", "path-is-absolute": "^1.0.0", "underscore": "^1.13.1", "xmlbuilder": "^10.0.0" }, "bin": { "mammoth": "bin/mammoth" } }, "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w=="],
|
||||
|
||||
"marked": ["marked@18.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA=="],
|
||||
"marked": ["marked@18.0.5", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="],
|
||||
|
||||
"markit-ai": ["markit-ai@0.5.3", "", { "dependencies": { "chalk": "^5.6.2", "commander": "^14.0.3", "exifr": "^7.1.3", "fast-xml-parser": "^5.5.9", "jszip": "^3.10.1", "mammoth": "^1.9.0", "mupdf": "^1.27.0", "music-metadata": "^11.12.3", "rss-parser": "^3.13.0", "turndown": "^7.2.0", "turndown-plugin-gfm": "^1.0.2" }, "bin": { "markit": "dist/main.js" } }, "sha512-h4nhn6a/SNXEdc3kLVtL37TspxjUNCNL0OM7LRWxd389ZByI/B7bjNNgxFdVAT0O+H7ZekSwLdVe/lws1l2AZQ=="],
|
||||
|
||||
@@ -1147,7 +1147,7 @@
|
||||
|
||||
"object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
|
||||
|
||||
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||
"obug": ["obug@2.1.2", "", {}, "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg=="],
|
||||
|
||||
"one-time": ["one-time@1.0.0", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="],
|
||||
|
||||
@@ -1225,7 +1225,7 @@
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
|
||||
"semver": ["semver@7.8.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ=="],
|
||||
|
||||
"semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="],
|
||||
|
||||
|
||||
@@ -47,6 +47,49 @@ fn encode_png(image: ImageData<'_>) -> Result<Vec<u8>> {
|
||||
/// Returns an error if clipboard access fails.
|
||||
#[napi]
|
||||
pub fn copy_to_clipboard(text: String) -> Result<()> {
|
||||
set_clipboard_text(text)
|
||||
}
|
||||
|
||||
/// Linux: keep a single `arboard::Clipboard` alive for the whole process.
|
||||
///
|
||||
/// X11 (and Wayland) clipboards are owner-based: the process that set the
|
||||
/// selection must stay alive and answer `SelectionRequest` events, otherwise
|
||||
/// the contents vanish the moment the owner goes away. arboard serves those
|
||||
/// requests from a global background thread that only lives as long as a
|
||||
/// `Clipboard` instance exists — so creating a throwaway `Clipboard` per copy
|
||||
/// (which is then dropped) tears that thread down immediately and leaves the
|
||||
/// X11 clipboard empty even while our process keeps running (issue #2075).
|
||||
/// Holding one instance for the lifetime of the process keeps that owner thread
|
||||
/// serving, without shelling out to `xclip`/`wl-copy`. Wayland is unaffected
|
||||
/// (`wl-clipboard-rs` forks its own serving process) but sharing the instance
|
||||
/// is harmless there.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn set_clipboard_text(text: String) -> Result<()> {
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
static CLIPBOARD: OnceLock<Mutex<Option<Clipboard>>> = OnceLock::new();
|
||||
let cell = CLIPBOARD.get_or_init(|| Mutex::new(None));
|
||||
let mut guard = cell.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if guard.is_none() {
|
||||
*guard = Some(
|
||||
Clipboard::new()
|
||||
.map_err(|err| Error::from_reason(format!("Failed to access clipboard: {err}")))?,
|
||||
);
|
||||
}
|
||||
guard
|
||||
.as_mut()
|
||||
.expect("clipboard initialized above")
|
||||
.set_text(text)
|
||||
.map_err(|err| Error::from_reason(format!("Failed to copy to clipboard: {err}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// macOS / Windows: the OS retains clipboard contents after the writing process
|
||||
/// exits, so a transient `Clipboard` is sufficient. Keeping the write on the
|
||||
/// calling thread also avoids worker-thread `AppKit` pasteboard warnings on
|
||||
/// macOS.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn set_clipboard_text(text: String) -> Result<()> {
|
||||
let mut clipboard = Clipboard::new()
|
||||
.map_err(|err| Error::from_reason(format!("Failed to access clipboard: {err}")))?;
|
||||
clipboard
|
||||
|
||||
@@ -1700,7 +1700,8 @@ mod tests {
|
||||
assert!(!matches_key_inner(b"\x1b[127;11u", "alt+backspace", true));
|
||||
// And plain backspace (mod 0) must still not match a super+alt-modified press.
|
||||
assert!(!matches_key_inner(b"\x1b[127;11u", "backspace", true));
|
||||
// Release events stay ignored: super+alt+backspace release must not match a press.
|
||||
// Release events stay ignored: super+alt+backspace release must not match a
|
||||
// press.
|
||||
assert!(!matches_key_inner(b"\x1b[127;11:3u", "super+alt+backspace", true));
|
||||
assert_eq!(parse_key_inner(b"\x1b[127;11:3u", true).as_deref(), None);
|
||||
}
|
||||
|
||||
@@ -68,5 +68,5 @@ use napi_derive::napi;
|
||||
/// MUST stay in sync with `VERSION_SENTINEL_EXPORT` in
|
||||
/// `packages/natives/native/index.js` (which derives the name from
|
||||
/// `package.json#version`).
|
||||
#[napi(js_name = "__piNativesV15_10_1")]
|
||||
#[napi(js_name = "__piNativesV15_10_4")]
|
||||
pub const fn pi_natives_version_sentinel() {}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# macOS signing & notarization
|
||||
|
||||
The compiled macOS `omp` binaries shipped on GitHub Releases are signed with a
|
||||
**Developer ID Application** certificate and **notarized** by Apple. This makes
|
||||
them Gatekeeper-acceptable and is the prerequisite for an official Homebrew
|
||||
submission (see [#776](https://github.com/can1357/oh-my-pi/issues/776)).
|
||||
|
||||
Signing happens in CI, in the `release_binary` job's darwin matrix legs
|
||||
(`.github/workflows/ci.yml`), via `scripts/ci-macos-sign.sh`. It **auto-skips**
|
||||
until the `APPLE_*` repository secrets below are configured, so releases keep
|
||||
working (ad-hoc signed, as before) in the meantime.
|
||||
|
||||
## How it works
|
||||
|
||||
1. `ci:release:build-binaries` builds and **ad-hoc** signs the binary (so it can
|
||||
run on the build runner).
|
||||
2. `scripts/ci-macos-sign.sh` then:
|
||||
- imports the Developer ID cert into a throwaway keychain;
|
||||
- re-signs with `--options runtime --timestamp` (hardened runtime + secure
|
||||
timestamp) and `--entitlements scripts/macos-entitlements.plist`;
|
||||
- runs `--version` and `--smoke-test` under the new signature to fail fast;
|
||||
- notarizes the binary via `notarytool submit --wait`.
|
||||
3. `release_github_verify` re-downloads the published arm64 asset and asserts it
|
||||
is **not** ad-hoc, passes `codesign --verify --strict`, and boots cleanly.
|
||||
|
||||
### Why the entitlements are mandatory
|
||||
|
||||
The binary is a Bun single-file executable, so the hardened runtime needs:
|
||||
|
||||
| Entitlement | Reason |
|
||||
| --- | --- |
|
||||
| `com.apple.security.cs.allow-jit` | JavaScriptCore JITs at runtime. |
|
||||
| `com.apple.security.cs.allow-unsigned-executable-memory` | JSC executable memory pages. |
|
||||
| `com.apple.security.cs.disable-library-validation` | omp extracts its native addon (`pi_natives.<triple>.node`) and other optional dylibs to a runtime cache and `dlopen()`s them. They do not share the main binary's Team ID, so without this the hardened runtime aborts with *"mapping process and mapped file have different Team IDs"* — breaking effectively every command. |
|
||||
|
||||
Without `disable-library-validation`, a signed+notarized binary signs and
|
||||
notarizes fine but **fails at first real use**. `scripts/ci-macos-sign.sh` runs
|
||||
`--smoke-test` after signing specifically to catch this before notarizing.
|
||||
|
||||
### Stapling limitation (important)
|
||||
|
||||
A bare Mach-O executable **cannot be stapled** (`stapler` only supports
|
||||
`.app`/`.pkg`/`.dmg`). The binary is genuinely notarized — `notarytool` returns
|
||||
`Accepted` and the ticket exists on Apple's servers keyed to its cdhash — but
|
||||
because there is no *stapled* ticket, a direct `spctl -a -t exec` assessment
|
||||
reports `rejected / source=Unnotarized Developer ID`. This is expected and is
|
||||
**not** a signing or credential failure.
|
||||
|
||||
What this means in practice:
|
||||
|
||||
- `curl https://omp.sh/install | sh` — `curl` sets no quarantine bit, so
|
||||
Gatekeeper is never consulted; the binary just runs. ✅
|
||||
- Homebrew **formula** installs — Homebrew does not quarantine formula files, so
|
||||
Gatekeeper is never consulted. ✅
|
||||
- Anything that **quarantines** the binary (a browser download, or a Homebrew
|
||||
**cask**) and is assessed offline will be blocked, because there is no stapled
|
||||
ticket. For that route, wrap the binary in a stapleable, notarized **`.pkg` or
|
||||
`.dmg`** (`xcrun stapler staple` works on those). That is a follow-up and is
|
||||
**not** required for the `curl`/formula paths.
|
||||
|
||||
## Required GitHub secrets
|
||||
|
||||
Add these under **Settings → Secrets and variables → Actions** (repo secrets).
|
||||
Both the cert (`APPLE_CERTIFICATE_P12`) **and** the API key (`APPLE_API_KEY`)
|
||||
must be present for signing to engage.
|
||||
|
||||
| Secret | What it is |
|
||||
| --- | --- |
|
||||
| `APPLE_CERTIFICATE_P12` | base64 of the exported Developer ID Application `.p12` (cert + private key). |
|
||||
| `APPLE_CERTIFICATE_PASSWORD` | password you set when exporting the `.p12`. |
|
||||
| `APPLE_API_KEY_ID` | App Store Connect API **Key ID**. |
|
||||
| `APPLE_API_ISSUER_ID` | App Store Connect API **Issuer ID** (UUID). |
|
||||
| `APPLE_API_KEY` | base64 of the App Store Connect `.p8` private key. |
|
||||
|
||||
### Producing the credential files
|
||||
|
||||
Drop these into a working directory (default `~/omp-signing`):
|
||||
|
||||
| File | How |
|
||||
| --- | --- |
|
||||
| `*.p12` | **Keychain Access** → right-click your *Developer ID Application: …* identity (the entry that expands to a cert **with** a private key) → **Export…** → save as `.p12` and set a password. |
|
||||
| `p12-password.txt` | the password you just set on the `.p12`. |
|
||||
| `AuthKey_<KEYID>.p8` | App Store Connect → **Users and Access → Integrations → App Store Connect API** → create a key (**Account Holder** role also allows API cert creation; **Developer** is enough for notarization) → **download once** (non-recoverable). |
|
||||
| `issuer-id.txt` | the **Issuer ID** (UUID) shown above the keys table. |
|
||||
| `key-id.txt` | *optional* — the Key ID; otherwise read from the `.p8` filename. |
|
||||
|
||||
The App Store Connect API key is the one credential that **cannot** be minted
|
||||
from a CLI — it is the bootstrap credential for the API itself, and the `.p8`
|
||||
downloads exactly once. Everything else is local.
|
||||
|
||||
### Uploading (no value leaves disk)
|
||||
|
||||
`scripts/ci-macos-upload-secrets.sh` validates the files (opens the `.p12` with
|
||||
your password, sanity-checks the `.p8`) and pipes each value to `gh secret set`
|
||||
over stdin — no secret is ever printed to the terminal, argv, or shell history:
|
||||
|
||||
```sh
|
||||
scripts/ci-macos-upload-secrets.sh ~/omp-signing --dry-run # validate first
|
||||
scripts/ci-macos-upload-secrets.sh ~/omp-signing # upload all five
|
||||
gh secret list --repo can1357/oh-my-pi # confirm
|
||||
```
|
||||
|
||||
Re-run it whenever the certificate is renewed.
|
||||
|
||||
### Finding your signing identity / Team ID (sanity check)
|
||||
|
||||
```sh
|
||||
security find-identity -v -p codesigning
|
||||
# e.g. "Developer ID Application: Your Name (TEAMID1234)"
|
||||
```
|
||||
|
||||
The script selects the first `Developer ID Application` identity automatically;
|
||||
you do not need to store the identity string or Team ID as a secret.
|
||||
|
||||
## Local dry run
|
||||
|
||||
You can exercise the full sign+notarize path locally (real cert + API key) by
|
||||
exporting the five env vars and running:
|
||||
|
||||
```sh
|
||||
RELEASE_TARGETS=darwin-arm64 bun run ci:release:build-binaries
|
||||
APPLE_CERTIFICATE_P12=… APPLE_CERTIFICATE_PASSWORD=… \
|
||||
APPLE_API_KEY_ID=… APPLE_API_ISSUER_ID=… APPLE_API_KEY=… \
|
||||
bash scripts/ci-macos-sign.sh packages/coding-agent/binaries/omp-darwin-arm64
|
||||
```
|
||||
+2
-2
@@ -166,9 +166,9 @@ Python prelude helpers include `agent(prompt, *, agent_type="task", model=None,
|
||||
|
||||
### Cell timeout
|
||||
|
||||
Each eval cell `timeout` is in seconds, defaults to 30, and is clamped to `1..600`. It is a **wall-clock budget on the cell's own work** that the watchdog (`IdleTimeout`, `src/eval/idle-timeout.ts`) enforces, **but it is paused while a host-side `agent()`/`parallel()`/`llm()` bridge call is in flight**: those calls pump a heartbeat (`withBridgeHeartbeat`, `src/eval/heartbeat.ts`) that re-arms the watchdog, so a long fanout or a slow completion runs to completion instead of being killed mid-stream.
|
||||
Each eval cell `timeout` is in seconds, defaults to 30, and is clamped to `1..600`. It is a **wall-clock budget on the cell's own work** that the watchdog (`IdleTimeout`, `src/eval/idle-timeout.ts`) enforces, **but it is paused while a host-side `agent()`/`parallel()`/`completion()` bridge call is in flight**: those calls pump a heartbeat (`withBridgeHeartbeat`, `src/eval/heartbeat.ts`) that re-arms the watchdog, so a long fanout or a slow completion runs to completion instead of being killed mid-stream.
|
||||
|
||||
The heartbeat is the **sole** signal that extends the budget. Everything else the cell does — compute, `stdout`/`stderr`, `log()`/`phase()`, and ordinary (non-agent) tool calls — counts against `timeout`, so a cell that is not delegating to an agent/llm is bounded by a plain wall-clock timeout. The tool combines the caller abort signal, the session abort signal, and the watchdog's signal with `AbortSignal.any(...)`; no wall-clock deadline is passed to the backend, so neither runtime arms a competing fixed timer.
|
||||
The heartbeat is the **sole** signal that extends the budget. Everything else the cell does — compute, `stdout`/`stderr`, `log()`/`phase()`, and ordinary (non-agent) tool calls — counts against `timeout`, so a cell that is not delegating to an agent/completion is bounded by a plain wall-clock timeout. The tool combines the caller abort signal, the session abort signal, and the watchdog's signal with `AbortSignal.any(...)`; no wall-clock deadline is passed to the backend, so neither runtime arms a competing fixed timer.
|
||||
|
||||
### Kernel execution cancellation
|
||||
|
||||
|
||||
+3
-2
@@ -29,9 +29,9 @@ Patch language inside `input`:
|
||||
- **File header**: `¶PATH#TAG`. `TAG` is four uppercase-hex chars minted by the session snapshot store.
|
||||
- **Operations**:
|
||||
- `replace N..M:` — replace original lines N..M with the body rows below.
|
||||
- `replace block N:` — replace the whole tree-sitter block beginning on line N (its header line through its closing line) with the body rows. The line span is resolved at apply time from the file's parse tree; point N at the line that opens the construct. Errors (and steers to `replace N..M:`) when the language is unsupported, line N is blank or a closing delimiter, no node begins there, or the resolved block has a syntax error.
|
||||
- `replace block N:` — replace the whole tree-sitter block beginning on line N (its header line through its closing line) with the body rows. The line span is resolved at apply time from the file's parse tree; point N at the line that opens the construct. The resolved span is exactly the node that begins on line N — a leading decorator, attribute, or doc-comment is a separate node and is not included; point N at the first decorator line (Python wraps `@dec` + `def` as one block) or fall back to `replace N..M:` to take a leading line-comment that parses as its own node (e.g. Rust `///`). On success the result echoes the matched span (`replace block N → resolved lines A-B`). Errors (and steers to `replace N..M:`) when the language is unsupported, line N is blank or a closing delimiter, no node begins there, or the resolved block has a syntax error.
|
||||
- `delete N..M` — delete original lines N..M. No body.
|
||||
- `delete block N` — delete the whole tree-sitter block beginning on line N (resolved like `replace block N`). No body. Same resolution failure modes and `delete N..M` fallback.
|
||||
- `delete block N` — delete the whole tree-sitter block beginning on line N (resolved like `replace block N`, with the same decorator/comment caveat). No body. On success the result echoes the matched span (`delete block N → resolved lines A-B`). Same resolution failure modes and `delete N..M` fallback.
|
||||
- `insert before N:` — insert body rows immediately before line N.
|
||||
- `insert after N:` — insert body rows immediately after line N.
|
||||
- `insert head:` — insert body rows at the start of the file.
|
||||
@@ -69,6 +69,7 @@ The canonical grammar is strict, but the hand parser accepts a few non-dangerous
|
||||
- `content` contains one text block per call. For a successful single-file edit it is either:
|
||||
- `<path>:` plus a compact diff preview from `packages/hashline/src/diff-preview.ts`, or
|
||||
- `Updated <path>` / `Created <path>` when no compact preview text is emitted.
|
||||
- When the patch used `replace block`/`delete block` ops (and the apply matched the tagged content), one `replace block N → resolved lines A-B (K lines)` line per block op is inserted between the `¶PATH#TAG` header and the diff preview, so the caller can confirm tree-sitter resolved the construct it intended.
|
||||
- Parse, apply, or recovery warnings are appended as:
|
||||
|
||||
```text
|
||||
|
||||
+6
-6
@@ -131,7 +131,7 @@ Implemented in `packages/coding-agent/src/eval/js/worker-core.ts`, `packages/cod
|
||||
- `display`, `print`
|
||||
- `read`, `write`, `append`, `sort`, `uniq`, `counter`, `diff`, `tree`, `env`, `output`
|
||||
- `tool.<name>(args)` proxy for arbitrary session tool calls
|
||||
- `llm(prompt, opts?)` for oneshot, stateless LLM calls (see _Oneshot LLM helper_ below)
|
||||
- `completion(prompt, opts?)` for oneshot, stateless model calls (see _Oneshot completion helper_ below)
|
||||
- `agent(prompt, opts?)` for a single subagent call, plus `parallel()` / `pipeline()` bounded-pool helpers (see _Subagent helper_ below)
|
||||
- JS helpers that touch the host/runtime boundary are async and `await`able; pure text helpers (`sort`, `uniq`, `counter`) return synchronously but may still be safely awaited.
|
||||
- JS helper signatures use a trailing options object rather than Python keyword arguments:
|
||||
@@ -161,7 +161,7 @@ Implemented in `packages/coding-agent/src/eval/py/executor.ts`, `packages/coding
|
||||
- initialize cwd / env / `sys.path`
|
||||
- execute `PYTHON_PRELUDE`
|
||||
- Python cells run in the runner's persistent asyncio event loop, so top-level `await` works; the prompt warns not to use `asyncio.run(...)`
|
||||
- The Python prelude defines helpers with the same surface as JS where practical, including `tool.<name>(args)`, `llm(...)`, and `agent(...)` through a per-run loopback bridge
|
||||
- The Python prelude defines helpers with the same surface as JS where practical, including `tool.<name>(args)`, `completion(...)`, and `agent(...)` through a per-run loopback bridge
|
||||
- Synchronous statement blocks run in the default executor with ContextVar state copied in; the GIL still serializes bytecode execution, but awaited regions can interleave with sibling cells
|
||||
- Kernel `display_data` / `execute_result` messages map to:
|
||||
- `application/x-omp-status` → status event
|
||||
@@ -172,13 +172,13 @@ Implemented in `packages/coding-agent/src/eval/py/executor.ts`, `packages/coding
|
||||
- `text/html` → HTML converted to markdown with `htmlToBasicMarkdown()`
|
||||
- Interactive stdin is rejected: `input_request` sends an empty reply, marks `stdinRequested`, and the executor returns exit code `1`
|
||||
|
||||
### Oneshot LLM helper (`llm`)
|
||||
### Oneshot completion helper (`completion`)
|
||||
|
||||
Both runtimes expose `llm()` — a single stateless completion against a model tier. It is intentionally minimal: no conversation history, no agent-visible tools, pure text in / text (or object) out. Implemented host-side in `packages/coding-agent/src/eval/llm-bridge.ts` and routed through the existing tool bridge under the reserved name `__llm__`.
|
||||
Both runtimes expose `completion()` — a single stateless completion against a model tier. It is intentionally minimal: no conversation history, no agent-visible tools, pure text in / text (or object) out. Implemented host-side in `packages/coding-agent/src/eval/completion-bridge.ts` and routed through the existing tool bridge under the reserved name `__completion__`.
|
||||
|
||||
- Signatures:
|
||||
- JS: `await llm(prompt, { model?, system?, schema? })`
|
||||
- Python: `llm(prompt, *, model="default", system=None, schema=None)`
|
||||
- JS: `await completion(prompt, { model?, system?, schema? })`
|
||||
- Python: `completion(prompt, *, model="default", system=None, schema=None)`
|
||||
- `model` selects a tier (default `"default"`):
|
||||
- `"smol"` → `pi/smol` role (fast / cheap)
|
||||
- `"default"` → the session's active model, falling back to the `pi/default` role
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@
|
||||
**Execution**
|
||||
- `file: "*"`: `runWorkspaceDiagnostics()` detects project type from root markers and runs one subprocess command: Rust `cargo check --message-format=short`, TypeScript `npx tsc --noEmit`, Go `go build ./...`, Python `pyright`.
|
||||
- Concrete file or glob: `resolveDiagnosticTargets()` treats non-globs as one target, otherwise expands a `Bun.Glob` up to `MAX_GLOB_DIAGNOSTIC_TARGETS`.
|
||||
- Per file, every matching server runs: custom clients call `lint(file)`; real LSP servers optionally wait for project load, capture `diagnosticsVersion`, `refreshFile()`, then `waitForDiagnostics()` for fresh `publishDiagnostics`.
|
||||
- Per file, every matching server runs: custom clients call `lint(file)`; real LSP servers optionally wait for project load, capture `diagnosticsVersion`, `refreshFile()`, then `waitForDiagnostics()` for fresh `publishDiagnostics` (settles on the latest publish; exact-version match accepted immediately).
|
||||
- Results are deduplicated by range+message and severity-sorted.
|
||||
|
||||
**Output text**
|
||||
|
||||
+1
-1
@@ -249,7 +249,7 @@ Notes: ...
|
||||
- Uses `session.internalRouter` for internal URLs.
|
||||
- Uses `session.allocateOutputArtifact()` for cached/truncated URL output.
|
||||
- Background work / cancellation
|
||||
- Most branches honor `AbortSignal`; the tool itself is marked `nonAbortable = true`, but helper paths still call `throwIfAborted(signal)`.
|
||||
- Only the deterministic disk reads are non-abortable: plain-file line/range reads (`streamLinesFromFile`, multi-range) and directory listings (`#readDirectory`) are called with `undefined` instead of the `AbortSignal`, so an interrupt mid-read can't surface a misleading "Operation aborted" on a read that would have finished instantly. Every other branch keeps the signal and its helpers call `throwIfAborted(signal)` to stop promptly: URL/internal-URL reads (network), archive, sqlite, document conversion, image decode, structural summary, conflict scan, and the suffix-glob path resolution.
|
||||
|
||||
## Limits & Caps
|
||||
- Shared text truncation defaults from `packages/coding-agent/src/session/streaming-output.ts`:
|
||||
|
||||
+1
-1
@@ -152,7 +152,7 @@ content: ""
|
||||
- Invalidates shared filesystem scan cache entries through `invalidateFsScanAfterWrite()`.
|
||||
- Enforces plan-mode write restrictions before mutating the target.
|
||||
- Background work / cancellation
|
||||
- Marks the tool `nonAbortable = true` and `concurrency = "exclusive"` in `WriteTool`.
|
||||
- Marks the tool `concurrency = "exclusive"` in `WriteTool`.
|
||||
- LSP writethrough can schedule deferred diagnostics fetches after a timeout, but plain `write.ts` only consumes the immediate return value.
|
||||
|
||||
## Limits & Caps
|
||||
|
||||
+9
-9
@@ -20,15 +20,15 @@
|
||||
"@huggingface/transformers": "^4.2.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@napi-rs/cli": "3.7.0",
|
||||
"@oh-my-pi/hashline": "15.10.1",
|
||||
"@oh-my-pi/omp-stats": "15.10.1",
|
||||
"@oh-my-pi/pi-agent-core": "15.10.1",
|
||||
"@oh-my-pi/pi-ai": "15.10.1",
|
||||
"@oh-my-pi/pi-coding-agent": "15.10.1",
|
||||
"@oh-my-pi/pi-mnemopi": "15.10.1",
|
||||
"@oh-my-pi/pi-natives": "15.10.1",
|
||||
"@oh-my-pi/pi-tui": "15.10.1",
|
||||
"@oh-my-pi/pi-utils": "15.10.1",
|
||||
"@oh-my-pi/hashline": "15.10.4",
|
||||
"@oh-my-pi/omp-stats": "15.10.4",
|
||||
"@oh-my-pi/pi-agent-core": "15.10.4",
|
||||
"@oh-my-pi/pi-ai": "15.10.4",
|
||||
"@oh-my-pi/pi-coding-agent": "15.10.4",
|
||||
"@oh-my-pi/pi-mnemopi": "15.10.4",
|
||||
"@oh-my-pi/pi-natives": "15.10.4",
|
||||
"@oh-my-pi/pi-tui": "15.10.4",
|
||||
"@oh-my-pi/pi-utils": "15.10.4",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/context-async-hooks": "^2.7.1",
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
|
||||
|
||||
@@ -2,6 +2,33 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [15.10.3] - 2026-06-08
|
||||
|
||||
### Added
|
||||
|
||||
- Added a non-interrupting "aside" message channel to the agent loop (`AgentLoopConfig.getAsideMessages` / `Agent.setAsideMessageProvider`). Asides are drained at each step boundary (after a tool batch, before the next model call) and at the yield check, so passive notifications (e.g. background-job completions, late LSP diagnostics) reach the model *between requests* without waiting for the agent to stop and without aborting in-flight tools the way steering does.
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed core custom and hook messages to convert to `developer` messages for provider context.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the compaction spinner freezing (only repainting on a terminal resize) when compacting very large codex/OpenAI contexts. `buildOpenAiNativeHistory` re-collected the full known/custom tool-call id sets on every history-bearing message, rescanning the entire growing native history each time — O(N²) in history items — which blocked the event loop for seconds and starved the loader's animation timer and render scheduler. The sets are now maintained incrementally (linear), so building the compaction request no longer monopolizes the main thread.
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed the now-dead `<turn-aborted>` marker from the OpenAI compaction output user-message filter, since `transformMessages` no longer emits that note.
|
||||
- Removed stale synthetic user-message tag filters from OpenAI remote compaction output preservation; developer messages are now dropped by role instead.
|
||||
- Tool executions now receive the active turn `AbortSignal` unconditionally.
|
||||
|
||||
|
||||
## [15.10.2] - 2026-06-08
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed proxy stream silently returning a zero-token success response when the server disconnects without sending a `done` or `error` terminal SSE event. The stream now throws an error, surfacing the disconnect as an `error` event with `stopReason: "error"` and resolving `finalResultPromise`, instead of defaulting to `stopReason: "stop"` with empty content and leaving `stream.result()` callers hanging indefinitely.
|
||||
|
||||
## [15.10.1] - 2026-06-07
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-agent-core",
|
||||
"version": "15.10.1",
|
||||
"version": "15.10.4",
|
||||
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
@@ -49,6 +49,7 @@ import type {
|
||||
AgentMessage,
|
||||
AgentTool,
|
||||
AgentToolResult,
|
||||
AsideMessage,
|
||||
StreamFn,
|
||||
} from "./types";
|
||||
import { yieldIfDue } from "./utils/yield";
|
||||
@@ -465,6 +466,23 @@ function cloneAssistantMessageForToolCallCap(message: AssistantMessage): Assista
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve aside entries at the moment the loop is about to inject them. Each entry
|
||||
* is either a ready {@link AgentMessage} or a sync thunk evaluated here so the
|
||||
* producer can make the final inject-or-drop decision (return null) against
|
||||
* up-to-the-injection state — e.g. dropping late diagnostics a newer edit
|
||||
* superseded. Kept sync so it can never stall the loop.
|
||||
*/
|
||||
function resolveAsides(entries: AsideMessage[] | undefined): AgentMessage[] {
|
||||
if (!entries || entries.length === 0) return [];
|
||||
const out: AgentMessage[] = [];
|
||||
for (const entry of entries) {
|
||||
const message = typeof entry === "function" ? entry() : entry;
|
||||
if (message) out.push(message);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function runLoopBody(
|
||||
currentContext: AgentContext,
|
||||
newMessages: AgentMessage[],
|
||||
@@ -647,15 +665,18 @@ async function runLoopBody(
|
||||
|
||||
stream.push({ type: "turn_end", message, toolResults });
|
||||
|
||||
pendingMessages = steeringMessagesFromExecution ?? ((await config.getSteeringMessages?.()) || []);
|
||||
const steering = steeringMessagesFromExecution ?? ((await config.getSteeringMessages?.()) || []);
|
||||
const asides = resolveAsides(await config.getAsideMessages?.());
|
||||
pendingMessages = asides.length > 0 ? [...steering, ...asides] : steering;
|
||||
}
|
||||
|
||||
// Agent would stop here. Check for follow-up messages.
|
||||
// Agent would stop here. Drain non-interrupting asides + follow-up messages.
|
||||
await config.onBeforeYield?.();
|
||||
const asideMessages = resolveAsides(await config.getAsideMessages?.());
|
||||
const followUpMessages = (await config.getFollowUpMessages?.()) || [];
|
||||
if (followUpMessages.length > 0) {
|
||||
// Set as pending so inner loop processes them
|
||||
pendingMessages = followUpMessages;
|
||||
if (asideMessages.length > 0 || followUpMessages.length > 0) {
|
||||
// Set as pending so the inner loop processes them before stopping.
|
||||
pendingMessages = [...asideMessages, ...followUpMessages];
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1282,7 +1303,7 @@ async function executeToolCalls(
|
||||
const rawResult = await tool.execute(
|
||||
toolCall.id,
|
||||
transformToolCallArguments ? transformToolCallArguments(effectiveArgs, toolCall.name) : effectiveArgs,
|
||||
tool.nonAbortable ? undefined : toolSignal,
|
||||
toolSignal,
|
||||
partialResult => {
|
||||
stream.push({
|
||||
type: "tool_execution_update",
|
||||
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
AgentState,
|
||||
AgentTool,
|
||||
AgentToolContext,
|
||||
AsideMessage,
|
||||
StreamFn,
|
||||
ToolCallContext,
|
||||
} from "./types";
|
||||
@@ -319,6 +320,7 @@ export class Agent {
|
||||
#onAssistantMessageEvent?: (message: AssistantMessage, event: AssistantMessageEvent) => void;
|
||||
#onHarmonyLeak?: (event: HarmonyAuditEvent) => void | Promise<void>;
|
||||
#onBeforeYield?: () => Promise<void> | void;
|
||||
#asideMessageProvider?: () => AsideMessage[] | Promise<AsideMessage[]>;
|
||||
#telemetry?: AgentLoopConfig["telemetry"];
|
||||
#appendOnlyContext?: AppendOnlyContextManager;
|
||||
|
||||
@@ -629,6 +631,15 @@ export class Agent {
|
||||
this.#onBeforeYield = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide a source of non-interrupting "aside" messages (e.g. background-job
|
||||
* completions, late LSP diagnostics) drained at each step boundary. Never
|
||||
* aborts in-flight tools. See `AgentLoopConfig.getAsideMessages`.
|
||||
*/
|
||||
setAsideMessageProvider(fn: (() => AsideMessage[] | Promise<AsideMessage[]>) | undefined): void {
|
||||
this.#asideMessageProvider = fn;
|
||||
}
|
||||
|
||||
emitExternalEvent(event: AgentEvent) {
|
||||
switch (event.type) {
|
||||
case "message_start":
|
||||
@@ -999,6 +1010,7 @@ export class Agent {
|
||||
return this.#dequeueSteeringMessages();
|
||||
},
|
||||
getFollowUpMessages: async () => this.#dequeueFollowUpMessages(),
|
||||
getAsideMessages: async () => (await this.#asideMessageProvider?.()) ?? [],
|
||||
onBeforeYield: () => this.#onBeforeYield?.(),
|
||||
telemetry: this.#telemetry,
|
||||
};
|
||||
|
||||
@@ -156,7 +156,7 @@ export function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
|
||||
? [{ type: "text" as const, text: message.content }]
|
||||
: message.content;
|
||||
return {
|
||||
role: "user",
|
||||
role: "developer",
|
||||
content,
|
||||
attribution: message.attribution,
|
||||
timestamp: message.timestamp,
|
||||
|
||||
@@ -158,38 +158,10 @@ function shouldTrimOpenAiCompactInputItem(item: Record<string, unknown>): boolea
|
||||
return item.type === "function_call_output" || (item.type === "message" && item.role === "developer");
|
||||
}
|
||||
|
||||
function shouldKeepOpenAiCompactOutputUserMessage(item: Record<string, unknown>): boolean {
|
||||
if (item.role !== "user") return false;
|
||||
const content = item.content;
|
||||
if (!Array.isArray(content) || content.length === 0) return false;
|
||||
const contextualFragmentPatterns = [
|
||||
[/^<system-reminder>[\s\S]*<\/system-reminder>$/i, /<system-reminder>/i],
|
||||
[/^#\s*AGENTS\.md instructions for\b[\s\S]*<\/INSTRUCTIONS>$/i, /# AGENTS.md instructions/],
|
||||
[/^<environment-context>[\s\S]*<\/environment-context>$/i, /<environment-context>/i],
|
||||
[/^<skill>[\s\S]*<\/skill>$/i, /<skill>/i],
|
||||
[/^<user-shell-command>[\s\S]*<\/user-shell-command>$/i, /<user-shell-command>/i],
|
||||
[/^<turn-aborted>[\s\S]*<\/turn-aborted>$/i, /<turn-aborted>/i],
|
||||
[/^<subagent-notification>[\s\S]*<\/subagent-notification>$/i, /<subagent-notification>/i],
|
||||
] as const;
|
||||
return content.every(part => {
|
||||
if (!part || typeof part !== "object") return false;
|
||||
const candidate = part as { type?: unknown; text?: unknown };
|
||||
if (candidate.type === "input_image") return true;
|
||||
if (candidate.type !== "input_text" || typeof candidate.text !== "string") return false;
|
||||
const trimmed = candidate.text.trim();
|
||||
if (trimmed.length === 0) return false;
|
||||
return !contextualFragmentPatterns.some(([strictPattern, markerPattern]) => {
|
||||
return strictPattern.test(trimmed) || markerPattern.test(trimmed);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function shouldKeepOpenAiCompactOutputItem(item: Record<string, unknown>): boolean {
|
||||
if (item.type === "compaction" || item.type === "compaction_summary") return true;
|
||||
if (item.type !== "message") return false;
|
||||
if (item.role === "developer") return false;
|
||||
if (item.role === "assistant") return true;
|
||||
return shouldKeepOpenAiCompactOutputUserMessage(item);
|
||||
return item.role === "assistant" || item.role === "user";
|
||||
}
|
||||
|
||||
function trimOpenAiCompactInput(
|
||||
@@ -220,24 +192,27 @@ function trimOpenAiCompactInput(
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function collectKnownOpenAiCallIds(items: Array<Record<string, unknown>>): Set<string> {
|
||||
const knownCallIds = new Set<string>();
|
||||
// Register every tool-call id in `items` (and the subset using the custom-tool
|
||||
// wire shape) into the running sets. The history builder maintains both sets
|
||||
// incrementally as native history is appended, so this only scans the
|
||||
// newly-added items (or, after a full-snapshot replace, the fresh input) rather
|
||||
// than re-scanning the whole growing history per message — the latter was
|
||||
// O(N²) and blocked the event loop for seconds while compacting large codex
|
||||
// contexts (frozen spinner until the next forced render).
|
||||
function addOpenAiCallIds(
|
||||
items: Array<Record<string, unknown>>,
|
||||
knownCallIds: Set<string>,
|
||||
customCallIds: Set<string>,
|
||||
): void {
|
||||
for (const item of items) {
|
||||
if ((item.type === "function_call" || item.type === "custom_tool_call") && typeof item.call_id === "string") {
|
||||
if (typeof item.call_id !== "string") continue;
|
||||
if (item.type === "function_call") {
|
||||
knownCallIds.add(item.call_id);
|
||||
} else if (item.type === "custom_tool_call") {
|
||||
knownCallIds.add(item.call_id);
|
||||
}
|
||||
}
|
||||
return knownCallIds;
|
||||
}
|
||||
|
||||
function collectCustomOpenAiCallIds(items: Array<Record<string, unknown>>): Set<string> {
|
||||
const customCallIds = new Set<string>();
|
||||
for (const item of items) {
|
||||
if (item.type === "custom_tool_call" && typeof item.call_id === "string") {
|
||||
customCallIds.add(item.call_id);
|
||||
}
|
||||
}
|
||||
return customCallIds;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -265,16 +240,16 @@ export function buildOpenAiNativeHistory(
|
||||
const transformedMessages = transformMessages(messages, model, id => normalizeOpenAiCompactionToolCallId(id));
|
||||
|
||||
let msgIndex = 0;
|
||||
let knownCallIds = collectKnownOpenAiCallIds(input);
|
||||
let customCallIds = collectCustomOpenAiCallIds(input);
|
||||
const knownCallIds = new Set<string>();
|
||||
const customCallIds = new Set<string>();
|
||||
addOpenAiCallIds(input, knownCallIds, customCallIds);
|
||||
for (const message of transformedMessages) {
|
||||
if (message.role === "user" || message.role === "developer") {
|
||||
const providerPayload = (message as { providerPayload?: AssistantMessage["providerPayload"] }).providerPayload;
|
||||
const historyItems = getOpenAIResponsesHistoryItems(providerPayload, model.provider);
|
||||
if (historyItems) {
|
||||
input.push(...historyItems);
|
||||
knownCallIds = collectKnownOpenAiCallIds(input);
|
||||
customCallIds = collectCustomOpenAiCallIds(input);
|
||||
addOpenAiCallIds(historyItems, knownCallIds, customCallIds);
|
||||
msgIndex++;
|
||||
continue;
|
||||
}
|
||||
@@ -317,11 +292,13 @@ export function buildOpenAiNativeHistory(
|
||||
if (providerPayload) {
|
||||
if (providerPayload.dt) {
|
||||
input.push(...providerPayload.items);
|
||||
addOpenAiCallIds(providerPayload.items, knownCallIds, customCallIds);
|
||||
} else {
|
||||
input.splice(0, input.length, ...providerPayload.items);
|
||||
knownCallIds.clear();
|
||||
customCallIds.clear();
|
||||
addOpenAiCallIds(input, knownCallIds, customCallIds);
|
||||
}
|
||||
knownCallIds = collectKnownOpenAiCallIds(input);
|
||||
customCallIds = collectCustomOpenAiCallIds(input);
|
||||
msgIndex++;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ import { calculateCost } from "@oh-my-pi/pi-ai/models";
|
||||
import { parseStreamingJson } from "@oh-my-pi/pi-ai/utils/json-parse";
|
||||
import { readSseJson } from "@oh-my-pi/pi-utils";
|
||||
|
||||
// Create stream class matching ProxyMessageEventStream
|
||||
class ProxyMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
|
||||
// Event stream adapter for proxy SSE events
|
||||
export class ProxyMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
|
||||
constructor() {
|
||||
super(
|
||||
event => event.type === "done" || event.type === "error",
|
||||
@@ -167,9 +167,12 @@ export function streamProxy(model: Model, context: Context, options: ProxyStream
|
||||
}
|
||||
}
|
||||
|
||||
if (options.signal?.aborted && !sawTerminalEvent) {
|
||||
const reason = options.signal.reason;
|
||||
throw reason instanceof Error ? reason : new Error(String(reason ?? "Request aborted"));
|
||||
if (!sawTerminalEvent) {
|
||||
if (options.signal?.aborted) {
|
||||
const reason = options.signal.reason;
|
||||
throw reason instanceof Error ? reason : new Error(String(reason ?? "Request aborted"));
|
||||
}
|
||||
throw new Error("Proxy stream ended without a terminal event (done or error)");
|
||||
}
|
||||
|
||||
stream.end();
|
||||
|
||||
@@ -26,6 +26,14 @@ export type StreamFn = (
|
||||
...args: Parameters<typeof streamSimple>
|
||||
) => AssistantMessageEventStream | Promise<AssistantMessageEventStream>;
|
||||
|
||||
/**
|
||||
* An aside entry: a ready {@link AgentMessage}, or a sync thunk evaluated at
|
||||
* injection time that returns the message to inject or `null` to skip it. Thunks
|
||||
* let the producer make the final inject-or-drop decision against current state
|
||||
* (e.g. dropping late diagnostics a newer edit superseded).
|
||||
*/
|
||||
export type AsideMessage = AgentMessage | (() => AgentMessage | null);
|
||||
|
||||
/**
|
||||
* Configuration for the agent loop.
|
||||
*/
|
||||
@@ -132,6 +140,17 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
||||
* continues with another turn.
|
||||
*/
|
||||
getFollowUpMessages?: () => Promise<AgentMessage[]>;
|
||||
/**
|
||||
* Returns non-interrupting "aside" messages to inject at a step boundary.
|
||||
*
|
||||
* Polled after each tool batch (before the next LLM call) AND at the yield
|
||||
* check. Unlike steering, these NEVER abort in-flight tools — they are passive
|
||||
* notifications (e.g. background-job completions, late LSP diagnostics) that
|
||||
* should reach the model between requests without waiting for the agent to
|
||||
* fully stop. Returned messages are appended to the context with normal
|
||||
* message events and keep the loop running so the model can react.
|
||||
*/
|
||||
getAsideMessages?: () => Promise<AsideMessage[]>;
|
||||
/**
|
||||
* Hook fired right before the loop would exit.
|
||||
*
|
||||
@@ -423,8 +442,6 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
|
||||
loadMode?: "essential" | "discoverable";
|
||||
/** Short one-line summary used for tool discovery indexes. */
|
||||
summary?: string;
|
||||
/** If true, tool execution ignores abort signals (runs to completion) */
|
||||
nonAbortable?: boolean;
|
||||
/**
|
||||
* Concurrency mode for tool scheduling when multiple calls are in one turn.
|
||||
* - "shared": can run alongside other shared tools (default)
|
||||
|
||||
@@ -768,6 +768,107 @@ describe("agentLoop with AgentMessage", () => {
|
||||
);
|
||||
expect(sawInterruptInContext).toBe(true);
|
||||
});
|
||||
|
||||
it("injects aside messages at the step boundary without interrupting tools", async () => {
|
||||
const toolSchema = z.object({ value: z.string() });
|
||||
const executed: string[] = [];
|
||||
const tool: AgentTool<typeof toolSchema, { value: string }> = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
description: "Echo tool",
|
||||
parameters: toolSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
executed.push(params.value);
|
||||
return {
|
||||
content: [{ type: "text", text: `echoed: ${params.value}` }],
|
||||
details: { value: params.value },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
|
||||
const mock = createMockModel({
|
||||
responses: [
|
||||
{
|
||||
content: [
|
||||
{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "first" } },
|
||||
{ type: "toolCall", id: "tool-2", name: "echo", arguments: { value: "second" } },
|
||||
],
|
||||
},
|
||||
{ content: ["done"] },
|
||||
],
|
||||
});
|
||||
|
||||
const asideMessage = createUserMessage("bg-job-complete");
|
||||
let asideDelivered = false;
|
||||
const config: AgentLoopConfig = {
|
||||
model: mock.model,
|
||||
convertToLlm: identityConverter,
|
||||
interruptMode: "immediate",
|
||||
getAsideMessages: async () => {
|
||||
if (!asideDelivered && executed.length >= 1) {
|
||||
asideDelivered = true;
|
||||
return [asideMessage];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
const events: AgentEvent[] = [];
|
||||
const stream = agentLoop([createUserMessage("start")], context, config, undefined, mock.stream);
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
// Asides are non-interrupting: BOTH tools in the batch run (steering would skip the 2nd).
|
||||
expect(executed).toEqual(["first", "second"]);
|
||||
|
||||
// The aside lands after the tool results, before the next model call.
|
||||
const seq = events.flatMap(event => {
|
||||
if (event.type !== "message_start") return [];
|
||||
if (event.message.role === "toolResult") return [`tool:${event.message.toolCallId}`];
|
||||
if (event.message.role === "user" && typeof event.message.content === "string") {
|
||||
return [event.message.content];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
expect(seq).toContain("bg-job-complete");
|
||||
expect(seq.indexOf("tool:tool-2")).toBeLessThan(seq.indexOf("bg-job-complete"));
|
||||
|
||||
// The model saw it on the very next request — delivered mid-run, no yield required.
|
||||
const sawAsideInContext = mock.calls[1]?.context.messages.some(
|
||||
m => m.role === "user" && typeof m.content === "string" && m.content === "bg-job-complete",
|
||||
);
|
||||
expect(sawAsideInContext).toBe(true);
|
||||
});
|
||||
|
||||
it("evaluates aside thunks at injection and skips ones that return null", async () => {
|
||||
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [] };
|
||||
const mock = createMockModel({ responses: [{ content: ["done"] }] });
|
||||
let polls = 0;
|
||||
const config: AgentLoopConfig = {
|
||||
model: mock.model,
|
||||
convertToLlm: identityConverter,
|
||||
// A lazy aside that decides, at injection time, NOT to inject (e.g. superseded).
|
||||
getAsideMessages: async () => {
|
||||
polls++;
|
||||
return [() => null];
|
||||
},
|
||||
};
|
||||
|
||||
const events: AgentEvent[] = [];
|
||||
const stream = agentLoop([createUserMessage("hi")], context, config, undefined, mock.stream);
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
// The thunk was consulted...
|
||||
expect(polls).toBeGreaterThan(0);
|
||||
// ...but a null result injects nothing and triggers no wasted continuation turn.
|
||||
const userStarts = events.filter(e => e.type === "message_start" && e.message.role === "user");
|
||||
expect(userStarts).toHaveLength(1); // only the original prompt
|
||||
expect(mock.calls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes tools and system prompt between same-turn model calls", async () => {
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Tests for proxy stream behavior when the server disconnects
|
||||
* without sending a terminal event (done/error).
|
||||
*
|
||||
* Contract: `streamProxy` MUST emit an error event and resolve
|
||||
* `stream.result()` when the SSE stream ends without a terminal
|
||||
* event — it must NOT silently complete with default stopReason='stop'.
|
||||
*/
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import type { ProxyAssistantMessageEvent } from "@oh-my-pi/pi-agent-core/proxy";
|
||||
import { type ProxyMessageEventStream, streamProxy } from "@oh-my-pi/pi-agent-core/proxy";
|
||||
import type { AssistantMessageEvent, Context, Model } from "@oh-my-pi/pi-ai";
|
||||
import { hookFetch } from "@oh-my-pi/pi-utils";
|
||||
|
||||
const mockModel: Model = {
|
||||
id: "test-model",
|
||||
name: "Test Model",
|
||||
api: "openai",
|
||||
provider: "test",
|
||||
baseUrl: "http://localhost:0",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 4096,
|
||||
maxTokens: 1024,
|
||||
};
|
||||
|
||||
const mockContext: Context = {
|
||||
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
|
||||
};
|
||||
|
||||
function buildSseBody(events: ProxyAssistantMessageEvent[]): ReadableStream<Uint8Array> {
|
||||
const parts: string[] = [];
|
||||
for (const event of events) {
|
||||
parts.push(`data: ${JSON.stringify(event)}\n\n`);
|
||||
}
|
||||
const text = parts.join("");
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(text));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function collectEvents(stream: ProxyMessageEventStream, timeoutMs = 2000): Promise<AssistantMessageEvent[]> {
|
||||
const events: AssistantMessageEvent[] = [];
|
||||
const iterator = stream[Symbol.asyncIterator]();
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const { promise: timeoutPromise, resolve: timeoutResolve } =
|
||||
Promise.withResolvers<IteratorResult<AssistantMessageEvent>>();
|
||||
const timer = setTimeout(
|
||||
() => timeoutResolve({ value: undefined, done: true } as IteratorResult<AssistantMessageEvent>),
|
||||
timeoutMs,
|
||||
);
|
||||
const result = await Promise.race([iterator.next(), timeoutPromise]);
|
||||
clearTimeout(timer);
|
||||
if (result.done) break;
|
||||
events.push(result.value);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
const baseUsage = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
|
||||
describe("streamProxy — server disconnect without terminal event", () => {
|
||||
it("emits an error event when server disconnects after start with no terminal event", async () => {
|
||||
const events: ProxyAssistantMessageEvent[] = [{ type: "start" }];
|
||||
const body = buildSseBody(events);
|
||||
|
||||
using _hook = hookFetch(() => new Response(body, { status: 200 }));
|
||||
|
||||
const stream = streamProxy(mockModel, mockContext, {
|
||||
proxyUrl: "http://localhost:0",
|
||||
authToken: "test",
|
||||
});
|
||||
const collected = await collectEvents(stream);
|
||||
const errorEvent = collected.find(e => e.type === "error");
|
||||
expect(errorEvent).toBeDefined();
|
||||
if (errorEvent && errorEvent.type === "error") {
|
||||
expect(errorEvent.reason).toBe("error");
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves stream.result() with stopReason='error' when server disconnects mid-stream", async () => {
|
||||
const events: ProxyAssistantMessageEvent[] = [
|
||||
{ type: "start" },
|
||||
{ type: "text_start", contentIndex: 0 },
|
||||
{ type: "text_delta", contentIndex: 0, delta: "Hel" },
|
||||
];
|
||||
const body = buildSseBody(events);
|
||||
|
||||
using _hook = hookFetch(() => new Response(body, { status: 200 }));
|
||||
|
||||
const stream = streamProxy(mockModel, mockContext, {
|
||||
proxyUrl: "http://localhost:0",
|
||||
authToken: "test",
|
||||
});
|
||||
|
||||
// Consume iterator so the internal async function runs
|
||||
const collected = await collectEvents(stream);
|
||||
expect(collected.some(e => e.type === "error")).toBe(true);
|
||||
|
||||
// stream.result() MUST resolve (not hang) with an error message
|
||||
const result = await stream.result();
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(result.errorMessage).toBeTruthy();
|
||||
});
|
||||
|
||||
it("handles client-initiated abort with stopReason='aborted'", async () => {
|
||||
const abortController = new AbortController();
|
||||
// Pre-abort before any data arrives
|
||||
abortController.abort();
|
||||
|
||||
const events: ProxyAssistantMessageEvent[] = [{ type: "start" }];
|
||||
const body = buildSseBody(events);
|
||||
|
||||
using _hook = hookFetch(() => new Response(body, { status: 200 }));
|
||||
|
||||
const stream = streamProxy(mockModel, mockContext, {
|
||||
proxyUrl: "http://localhost:0",
|
||||
authToken: "test",
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
const collected = await collectEvents(stream);
|
||||
// Should get an error event with reason 'aborted'
|
||||
const errorEvent = collected.find(e => e.type === "error");
|
||||
expect(errorEvent).toBeDefined();
|
||||
if (errorEvent && errorEvent.type === "error") {
|
||||
expect(errorEvent.reason).toBe("aborted");
|
||||
}
|
||||
|
||||
const result = await stream.result();
|
||||
expect(result.stopReason).toBe("aborted");
|
||||
});
|
||||
|
||||
it("preserves custom abort reason when client aborts mid-stream", async () => {
|
||||
const abortController = new AbortController();
|
||||
abortController.abort("user-interrupt");
|
||||
|
||||
const events: ProxyAssistantMessageEvent[] = [{ type: "start" }];
|
||||
const body = buildSseBody(events);
|
||||
|
||||
using _hook = hookFetch(() => new Response(body, { status: 200 }));
|
||||
|
||||
const stream = streamProxy(mockModel, mockContext, {
|
||||
proxyUrl: "http://localhost:0",
|
||||
authToken: "test",
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
await collectEvents(stream);
|
||||
const result = await stream.result();
|
||||
expect(result.stopReason).toBe("aborted");
|
||||
// Custom abort reason must be preserved in errorMessage, not overwritten
|
||||
// by the generic "Proxy stream ended without a terminal event" message
|
||||
expect(result.errorMessage).toBe("user-interrupt");
|
||||
});
|
||||
|
||||
it("completes normally when server sends a 'done' event", async () => {
|
||||
const events: ProxyAssistantMessageEvent[] = [
|
||||
{ type: "start" },
|
||||
{ type: "text_start", contentIndex: 0 },
|
||||
{ type: "text_delta", contentIndex: 0, delta: "Hello" },
|
||||
{ type: "text_end", contentIndex: 0 },
|
||||
{
|
||||
type: "done",
|
||||
reason: "stop",
|
||||
usage: { ...baseUsage },
|
||||
},
|
||||
];
|
||||
const body = buildSseBody(events);
|
||||
|
||||
using _hook = hookFetch(() => new Response(body, { status: 200 }));
|
||||
|
||||
const stream = streamProxy(mockModel, mockContext, {
|
||||
proxyUrl: "http://localhost:0",
|
||||
authToken: "test",
|
||||
});
|
||||
|
||||
const collected = await collectEvents(stream);
|
||||
expect(collected.some(e => e.type === "done")).toBe(true);
|
||||
|
||||
const result = await stream.result();
|
||||
expect(result.stopReason).toBe("stop");
|
||||
expect(result.content.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("completes with error event when server sends an 'error' terminal event", async () => {
|
||||
const events: ProxyAssistantMessageEvent[] = [
|
||||
{ type: "start" },
|
||||
{ type: "text_start", contentIndex: 0 },
|
||||
{ type: "text_delta", contentIndex: 0, delta: "Hel" },
|
||||
{
|
||||
type: "error",
|
||||
reason: "error",
|
||||
errorMessage: "rate_limit_exceeded",
|
||||
usage: { ...baseUsage },
|
||||
},
|
||||
];
|
||||
const body = buildSseBody(events);
|
||||
|
||||
using _hook = hookFetch(() => new Response(body, { status: 200 }));
|
||||
|
||||
const stream = streamProxy(mockModel, mockContext, {
|
||||
proxyUrl: "http://localhost:0",
|
||||
authToken: "test",
|
||||
});
|
||||
|
||||
const collected = await collectEvents(stream);
|
||||
expect(collected.some(e => e.type === "error")).toBe(true);
|
||||
|
||||
const result = await stream.result();
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(result.errorMessage).toBe("rate_limit_exceeded");
|
||||
});
|
||||
});
|
||||
@@ -105,6 +105,96 @@ describe("buildOpenAiNativeHistory custom tool calls", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const ZERO_USAGE = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
|
||||
// Codex carries native responses-API items on `providerPayload`. The history
|
||||
// builder reads call ids from there (not the message content blocks), so each
|
||||
// turn pairs a content `toolCall` (kept by `transformMessages` so the matching
|
||||
// result survives) with a `providerPayload` function/custom call of the same id.
|
||||
// `dt: true` appends to the running history; `dt: false` is a full snapshot that
|
||||
// replaces it.
|
||||
const CODEX_MODEL = makeOpenAiModel({ provider: "openai-codex" });
|
||||
|
||||
function codexAssistant(calls: Array<{ callId: string; custom?: boolean }>, dt: boolean): AssistantMessage {
|
||||
const content = calls.map(c => ({
|
||||
type: "toolCall" as const,
|
||||
id: `${c.callId}|${c.custom ? "ctc" : "fc"}_${c.callId}`,
|
||||
name: c.custom ? "edit" : "read",
|
||||
arguments: c.custom ? { input: "p" } : {},
|
||||
...(c.custom ? { customWireName: "apply_patch" } : {}),
|
||||
}));
|
||||
const items = calls.map(c =>
|
||||
c.custom
|
||||
? { type: "custom_tool_call", id: `ctc_${c.callId}`, call_id: c.callId, name: "apply_patch", input: "p" }
|
||||
: { type: "function_call", id: `fc_${c.callId}`, call_id: c.callId, name: "read", arguments: "{}" },
|
||||
);
|
||||
return {
|
||||
role: "assistant",
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
provider: "openai-codex",
|
||||
model: "gpt-5",
|
||||
api: "openai-responses",
|
||||
usage: ZERO_USAGE,
|
||||
stopReason: "toolUse",
|
||||
providerPayload: { type: "openaiResponsesHistory", provider: "openai-codex", ...(dt ? { dt: true } : {}), items },
|
||||
} as unknown as AssistantMessage;
|
||||
}
|
||||
|
||||
function toolResultFor(callId: string, custom = false): ToolResultMessage {
|
||||
return {
|
||||
role: "toolResult",
|
||||
toolCallId: `${callId}|${custom ? "ctc" : "fc"}_${callId}`,
|
||||
toolName: custom ? "edit" : "read",
|
||||
content: [{ type: "text", text: "result" }],
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildOpenAiNativeHistory call-id tracking", () => {
|
||||
test("registers function_call ids carried in providerPayload so later tool results are emitted", () => {
|
||||
const items = buildOpenAiNativeHistory(
|
||||
[codexAssistant([{ callId: "call_1" }], true), toolResultFor("call_1")],
|
||||
CODEX_MODEL,
|
||||
);
|
||||
const output = items.find(item => item.type === "function_call_output");
|
||||
expect(output?.call_id).toBe("call_1");
|
||||
expect(items.find(item => item.type === "custom_tool_call_output")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("registers custom_tool_call ids from providerPayload so outputs use the custom wire shape", () => {
|
||||
const items = buildOpenAiNativeHistory(
|
||||
[codexAssistant([{ callId: "call_2", custom: true }], true), toolResultFor("call_2", true)],
|
||||
CODEX_MODEL,
|
||||
);
|
||||
expect(items.find(item => item.type === "custom_tool_call_output")?.call_id).toBe("call_2");
|
||||
expect(items.find(item => item.type === "function_call_output")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("a full-snapshot providerPayload resets known call ids so stale outputs are dropped", () => {
|
||||
const items = buildOpenAiNativeHistory(
|
||||
[
|
||||
codexAssistant([{ callId: "call_old" }], true),
|
||||
// dt: false → splices the running history; call_old's function_call is gone.
|
||||
codexAssistant([{ callId: "call_new" }], false),
|
||||
toolResultFor("call_old"),
|
||||
toolResultFor("call_new"),
|
||||
],
|
||||
CODEX_MODEL,
|
||||
);
|
||||
expect(items.some(item => item.type === "function_call_output" && item.call_id === "call_old")).toBe(false);
|
||||
expect(items.some(item => item.type === "function_call_output" && item.call_id === "call_new")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remote compaction input trimming", () => {
|
||||
test("trims custom tool outputs with their matching custom calls", async () => {
|
||||
let requestInput: Array<Record<string, unknown>> | undefined;
|
||||
|
||||
@@ -2,13 +2,43 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [15.10.4] - 2026-06-08
|
||||
### Added
|
||||
|
||||
- Added `anthropic-client-platform` (`desktop_app`) and `anthropic-client-version` (`1.11187.4`) headers to the Anthropic request fingerprint for OAuth sessions
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed non-built-in tool names sent to Anthropic from `proxy_` prefixing to `_` prefixing (for example `bash` to `_bash`) while built-in tool names remain unchanged
|
||||
- Updated the Anthropic OAuth stealth fingerprint to track Claude Code 2.1.165: `claudeCodeVersion` bumped to `2.1.165` (flows into both the `cc_version` billing header and the `claude-cli/<version>` user-agent), `claudeCodeSystemInstruction` changed to `"You are a Claude agent, built on Anthropic's Claude Agent SDK."`, and the billing-header `cc_entrypoint` changed from `cli` to `local-agent`.
|
||||
- Clamped the Anthropic request `max_tokens` to `Math.min(CLAUDE_CODE_MAX_OUTPUT_TOKENS, options.maxTokens || model.maxTokens)` (64k) so OAuth requests match Claude Code's requested output cap instead of sending the model's full ceiling (e.g. 128k for Opus 4.8).
|
||||
|
||||
## [15.10.3] - 2026-06-08
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed the synthetic `<turn-aborted>` developer guidance note that `transformMessages` injected after an aborted/errored assistant turn (and its `turn-aborted-guidance.md` prompt). The per-call synthetic `"aborted"` tool results already tell the model the turn's tools were terminated, so the extra "verify current state before retrying" note was redundant — and it biased the model toward second-guessing a deliberate user interrupt when the turn was resumed.
|
||||
- Removed the legacy Anthropic first-user-message skip for `<system-reminder>` blocks now that synthetic reminders no longer travel as user messages.
|
||||
|
||||
|
||||
## [15.10.2] - 2026-06-08
|
||||
### Added
|
||||
|
||||
- Added support for `impersonated_service_account` Application Default Credentials (ADC) in Vertex AI to enable chained impersonation without failing via 401 `invalid_client`.
|
||||
- Added `AuthStorage.getCredentialOrigin(provider)` (returning a structured `CredentialOrigin` / `CredentialOriginKind`) and `getEnvApiKeyName(provider)`, so callers can render where a provider's auth comes from — runtime override, config, stored OAuth/api-key, env var (with the backing variable name), or fallback resolver — without parsing the prose of `describeCredentialSource`.
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed `onSseEvent` recording for OpenAI Responses, Azure OpenAI Responses, OpenAI Completions, and Anthropic stream providers to emit reconstructed SSE events from decoded SDK stream items instead of wrapping raw fetch responses
|
||||
- Changed OpenAI Completions SSE diagnostics to include `event: "chat.completion.chunk"` in `onSseEvent` records for chunked responses
|
||||
- Changed the default Anthropic model in `DEFAULT_MODEL_PER_PROVIDER` from `claude-sonnet-4-6` to `claude-opus-4-6`, so sessions that fall back to the provider default (no configured `default` role, no `--model`, no restored session) now start on Claude Opus 4.6.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed duplicate upstream `tool_call_id` values collapsing distinct tool calls during message transformation, preserving one call/result pairing per emitted tool call before provider replay. ([#2055](https://github.com/can1357/oh-my-pi/issues/2055))
|
||||
- Fixed duplicate upstream `tool_call_id` values collapsing distinct tool calls during message transformation, preserving one call/result pairing per emitted tool call before provider replay and keeping generated duplicate IDs distinct after OpenAI/Mistral wire-length caps. ([#2055](https://github.com/can1357/oh-my-pi/issues/2055))
|
||||
- Fixed the Anthropic provider retrying persistent account usage/quota limits (e.g. `429 "This request would exceed your account's rate limit"`, `usage_limit_reached`) as if they were transient. Because the error text contains "rate limit", `isProviderRetryableError` matched it and the stream retry loop looped through its 2s/4s/8s backoff (then the `streamSimple` a/b/c policy re-minted the credential and ran the whole thing again) before surfacing the failure — even though the server's `retry-after` parked the account for minutes-to-hours. These errors are now recognized via `isUsageLimitError` and surfaced immediately to the credential-rotation layer, so e.g. `omp dry-balance --bench` reports a rate-limited account as failed at once instead of appearing to hang.
|
||||
- Fixed MiniMax-compatible OpenAI-completions hosts losing tool-call argument content when `function.arguments` is streamed as an object across more than one delta. The accumulator added in #1776 wrote `block.partialArgs = rawArgs` per chunk, so every chunk but the last was overwritten — for an `edit` call this surfaced as a tail-slice of the patch text being applied (e.g. a single-line `replace 91..91:` body extending the deletion across the surrounding rows). Chunks are now shallow-merged; for shared string keys, `startsWith` distinguishes cumulative restatements (take the latest) from per-chunk-delta fragments (concatenate). Per-chunk `toolcall_delta` emission for the object branch is suppressed (the previous code emitted `JSON.stringify(rawArgs)` per chunk, which fed downstream concat consumers — `packages/agent/src/proxy.ts`, `openai-chat-server`, `openai-responses-server`, `anthropic-messages-server` — an invalid sequence like `{"input":"a"}{"input":"b"}`); the merged object is flushed instead as a single concat-safe delta in `finishToolCallBlock` before `toolcall_end`, so accumulators reconstruct the args correctly. The single-chunk shape covered by the existing #1776 regression test stays correct end-to-end. ([#2080](https://github.com/can1357/oh-my-pi/issues/2080))
|
||||
- Fixed the OpenAI Responses compatibility server misrouting late `toolcall_delta` events for earlier parallel tool calls after a later `toolcall_start`. The encoder now keeps OpenFunctionCall state by content index, allocates output indexes at item start, and closes each tool item by its own `toolcall_end`, preserving deferred MiniMax object-argument flushes for the matching call. ([#2080](https://github.com/can1357/oh-my-pi/issues/2080))
|
||||
|
||||
## [15.10.1] - 2026-06-07
|
||||
|
||||
@@ -3015,4 +3045,4 @@ _Dedicated to Peter's shoulder ([@steipete](https://twitter.com/steipete))_
|
||||
|
||||
## [0.9.4] - 2025-11-26
|
||||
|
||||
Initial release with multi-provider LLM support.
|
||||
Initial release with multi-provider LLM support.
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-ai",
|
||||
"version": "15.10.1",
|
||||
"version": "15.10.4",
|
||||
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
@@ -13,7 +13,7 @@ import * as path from "node:path";
|
||||
import { getAgentDbPath, logger } from "@oh-my-pi/pi-utils";
|
||||
import type { ApiKeyResolver } from "./auth-retry";
|
||||
import { isUsageLimitError } from "./rate-limit-utils";
|
||||
import { getEnvApiKey } from "./stream";
|
||||
import { getEnvApiKey, getEnvApiKeyName } from "./stream";
|
||||
import type { Provider } from "./types";
|
||||
import type {
|
||||
CredentialRankingStrategy,
|
||||
@@ -59,6 +59,23 @@ export type AuthCredentialEntry = AuthCredential | AuthCredential[];
|
||||
|
||||
export type AuthStorageData = Record<string, AuthCredentialEntry>;
|
||||
|
||||
/**
|
||||
* Cascade leg that supplies a provider's active credential, highest precedence
|
||||
* first — mirrors {@link AuthStorage.getApiKey}'s resolution order.
|
||||
*/
|
||||
export type CredentialOriginKind = "runtime" | "config" | "oauth" | "api_key" | "env" | "fallback";
|
||||
|
||||
/**
|
||||
* Structured provenance for a provider's auth, for UI that needs a machine
|
||||
* tag (the `/login` provider list) rather than the prose of
|
||||
* {@link AuthStorage.describeCredentialSource}.
|
||||
*/
|
||||
export interface CredentialOrigin {
|
||||
kind: CredentialOriginKind;
|
||||
/** Env var name when `kind === "env"` and a single named variable backs it. */
|
||||
envVar?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialized representation of AuthStorage for passing to subagent workers.
|
||||
* Contains only the essential credential data, not runtime state.
|
||||
@@ -1430,6 +1447,26 @@ export class AuthStorage {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify where a provider's auth comes from, following the same precedence
|
||||
* as {@link AuthStorage.getApiKey}: runtime override → config override →
|
||||
* stored credential (api_key before oauth, matching getApiKey) → env var →
|
||||
* fallback resolver. Returns undefined when no auth is configured.
|
||||
*
|
||||
* Compact, structured counterpart to {@link describeCredentialSource}.
|
||||
*/
|
||||
getCredentialOrigin(provider: string): CredentialOrigin | undefined {
|
||||
if (this.#runtimeOverrides.has(provider)) return { kind: "runtime" };
|
||||
if (this.#configOverrides.has(provider)) return { kind: "config" };
|
||||
const stored = this.#getCredentialsForProvider(provider);
|
||||
if (stored.length > 0) {
|
||||
return { kind: stored.some(credential => credential.type === "api_key") ? "api_key" : "oauth" };
|
||||
}
|
||||
if (getEnvApiKey(provider)) return { kind: "env", envVar: getEnvApiKeyName(provider) };
|
||||
if (this.#fallbackResolver?.(provider)) return { kind: "fallback" };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OAuth credentials are configured for a provider.
|
||||
*/
|
||||
|
||||
+11
-11
@@ -13270,7 +13270,7 @@
|
||||
},
|
||||
"minimax/minimax-m3": {
|
||||
"id": "minimax/minimax-m3",
|
||||
"name": "MiniMax: MiniMax M3 (new)",
|
||||
"name": "MiniMax: MiniMax M3",
|
||||
"api": "openai-completions",
|
||||
"provider": "kilo",
|
||||
"baseUrl": "https://api.kilo.ai/api/gateway",
|
||||
@@ -41929,8 +41929,8 @@
|
||||
"image"
|
||||
],
|
||||
"cost": {
|
||||
"input": 0.04,
|
||||
"output": 0.13,
|
||||
"input": 0.049999999999999996,
|
||||
"output": 0.15,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
@@ -42490,7 +42490,7 @@
|
||||
"image"
|
||||
],
|
||||
"cost": {
|
||||
"input": 0.08,
|
||||
"input": 0.09999999999999999,
|
||||
"output": 0.3,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
@@ -43439,7 +43439,7 @@
|
||||
"text"
|
||||
],
|
||||
"cost": {
|
||||
"input": 0.09999999999999999,
|
||||
"input": 0.39999999999999997,
|
||||
"output": 0.39999999999999997,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
@@ -45516,7 +45516,7 @@
|
||||
"text"
|
||||
],
|
||||
"cost": {
|
||||
"input": 0.071,
|
||||
"input": 0.09,
|
||||
"output": 0.09999999999999999,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
@@ -45564,8 +45564,8 @@
|
||||
"text"
|
||||
],
|
||||
"cost": {
|
||||
"input": 0.09,
|
||||
"output": 0.44999999999999996,
|
||||
"input": 0.12,
|
||||
"output": 0.5,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
@@ -46226,13 +46226,13 @@
|
||||
"image"
|
||||
],
|
||||
"cost": {
|
||||
"input": 0.04,
|
||||
"input": 0.09999999999999999,
|
||||
"output": 0.15,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 262144,
|
||||
"maxTokens": 81920,
|
||||
"maxTokens": 262144,
|
||||
"thinking": {
|
||||
"mode": "effort",
|
||||
"minLevel": "minimal",
|
||||
@@ -49976,7 +49976,7 @@
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 222222,
|
||||
"contextWindow": 256000,
|
||||
"maxTokens": 8888,
|
||||
"compat": {
|
||||
"supportsUsageInStreaming": false
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<turn-aborted>
|
||||
The previous turn was aborted. Any running tools/commands were terminated.
|
||||
If tools were aborted, they may have partially executed; verify current state before retrying.
|
||||
</turn-aborted>
|
||||
@@ -132,7 +132,7 @@ function catalogDescriptor(
|
||||
* openai-codex) are handled separately because they require different config shapes.
|
||||
*/
|
||||
export const PROVIDER_DESCRIPTORS: readonly ProviderDescriptor[] = [
|
||||
descriptor("anthropic", "claude-sonnet-4-6", config => anthropicModelManagerOptions(config)),
|
||||
descriptor("anthropic", "claude-opus-4-6", config => anthropicModelManagerOptions(config)),
|
||||
catalogDescriptor(
|
||||
"alibaba-coding-plan",
|
||||
"qwen3.5-plus",
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
supportsMidConversationSystemMessages,
|
||||
} from "../model-thinking";
|
||||
import { calculateCost } from "../models";
|
||||
import { isUsageLimitError } from "../rate-limit-utils";
|
||||
import { getEnvApiKey, OUTPUT_FALLBACK_BUFFER } from "../stream";
|
||||
import type {
|
||||
Api,
|
||||
@@ -29,6 +30,7 @@ import type {
|
||||
Message,
|
||||
Model,
|
||||
ProviderSessionState,
|
||||
RawSseEvent,
|
||||
RedactedThinkingContent,
|
||||
ServiceTier,
|
||||
SimpleStreamOptions,
|
||||
@@ -62,7 +64,7 @@ import { isCopilotTransientModelError } from "../utils/retry";
|
||||
import { COMBINATOR_KEYS, NO_STRICT, toolWireSchema } from "../utils/schema";
|
||||
import { spillToDescription } from "../utils/schema/spill";
|
||||
import { createSdkStreamRequestOptions } from "../utils/sdk-stream-timeout";
|
||||
import { notifyRawSseEvent, wrapFetchForSseDebug } from "../utils/sse-debug";
|
||||
import { notifyRawSseEvent } from "../utils/sse-debug";
|
||||
import {
|
||||
AnthropicConnectionTimeoutError,
|
||||
type AnthropicFetchOptions,
|
||||
@@ -194,9 +196,9 @@ const sharedHeaders = {
|
||||
"Accept-Encoding": "gzip, deflate, br, zstd",
|
||||
Connection: "keep-alive",
|
||||
"Content-Type": "application/json",
|
||||
"Anthropic-Version": "2023-06-01",
|
||||
"Anthropic-Dangerous-Direct-Browser-Access": "true",
|
||||
"X-App": "cli",
|
||||
"anthropic-version": "2023-06-01",
|
||||
"anthropic-dangerous-direct-browser-access": "true",
|
||||
"x-app": "cli",
|
||||
};
|
||||
|
||||
export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<string, string> {
|
||||
@@ -214,7 +216,7 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<s
|
||||
...modelHeaders,
|
||||
Accept: acceptHeader,
|
||||
...sharedHeaders,
|
||||
"Anthropic-Beta": betaHeader,
|
||||
"anthropic-beta": betaHeader,
|
||||
"cf-aig-authorization": `Bearer ${options.apiKey}`,
|
||||
};
|
||||
}
|
||||
@@ -223,14 +225,14 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<s
|
||||
const incomingUserAgent = getHeaderCaseInsensitive(options.modelHeaders, "User-Agent");
|
||||
const userAgent = isClaudeCodeClientUserAgent(incomingUserAgent)
|
||||
? incomingUserAgent
|
||||
: `claude-cli/${claudeCodeVersion} (external, cli)`;
|
||||
: `claude-cli/${claudeCodeVersion} (external, local-agent, agent-sdk/${claudeAgentSdkVersion})`;
|
||||
return {
|
||||
...modelHeaders,
|
||||
...claudeCodeHeaders,
|
||||
Accept: acceptHeader,
|
||||
Authorization: `Bearer ${options.apiKey}`,
|
||||
...sharedHeaders,
|
||||
"Anthropic-Beta": betaHeader,
|
||||
"anthropic-beta": betaHeader,
|
||||
...(options.claudeCodeSessionId ? { "X-Claude-Code-Session-Id": options.claudeCodeSessionId } : {}),
|
||||
"x-client-request-id": nodeCrypto.randomUUID(),
|
||||
"User-Agent": userAgent,
|
||||
@@ -241,14 +243,14 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<s
|
||||
Accept: acceptHeader,
|
||||
Authorization: `Bearer ${options.apiKey}`,
|
||||
...sharedHeaders,
|
||||
"Anthropic-Beta": betaHeader,
|
||||
"anthropic-beta": betaHeader,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...modelHeaders,
|
||||
Accept: acceptHeader,
|
||||
...sharedHeaders,
|
||||
"Anthropic-Beta": betaHeader,
|
||||
"anthropic-beta": betaHeader,
|
||||
"X-Api-Key": options.apiKey,
|
||||
};
|
||||
}
|
||||
@@ -258,12 +260,6 @@ type AnthropicCacheControl = NonNullable<TextBlockParam["cache_control"]>;
|
||||
|
||||
type AnthropicOutputConfig = NonNullable<MessageCreateParamsStreaming["output_config"]>;
|
||||
|
||||
function getAnthropicOutputConfig(params: MessageCreateParamsStreaming): AnthropicOutputConfig {
|
||||
const outputConfig = params.output_config ?? {};
|
||||
params.output_config = outputConfig;
|
||||
return outputConfig;
|
||||
}
|
||||
|
||||
const ANTHROPIC_STOP_SEQUENCES_MAX = 4;
|
||||
let warnedStopSequencesTrim = false;
|
||||
|
||||
@@ -396,9 +392,14 @@ function getCacheControl(
|
||||
}
|
||||
|
||||
// Stealth mode: mimic Claude Code's request fingerprint.
|
||||
export const claudeCodeVersion = "2.1.160";
|
||||
export const claudeToolPrefix: string = "proxy_";
|
||||
export const claudeCodeSystemInstruction = "You are Claude Code, Anthropic's official CLI for Claude.";
|
||||
export const claudeCodeVersion = "2.1.165";
|
||||
export const claudeAgentSdkVersion = "0.3.165";
|
||||
export const claudeClientVersion = "1.11187.4";
|
||||
export const claudeToolPrefix: string = "_";
|
||||
export const claudeCodeSystemInstruction = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
|
||||
// Claude Code caps requested output at 64k tokens even when the model ceiling is
|
||||
// higher (e.g. Opus 4.8 supports 128k); clamp to match the wire fingerprint.
|
||||
export const CLAUDE_CODE_MAX_OUTPUT_TOKENS = 64000;
|
||||
|
||||
export function mapStainlessOs(platform: string): "MacOS" | "Windows" | "Linux" | "FreeBSD" | `Other::${string}` {
|
||||
switch (platform.toLowerCase()) {
|
||||
@@ -441,7 +442,9 @@ export const claudeCodeHeaders = {
|
||||
"X-Stainless-Lang": "js",
|
||||
"X-Stainless-Arch": mapStainlessArch(process.arch),
|
||||
"X-Stainless-OS": mapStainlessOs(process.platform),
|
||||
"X-Stainless-Timeout": "600",
|
||||
"X-Stainless-Timeout": "900",
|
||||
"anthropic-client-platform": "desktop_app",
|
||||
"anthropic-client-version": claudeClientVersion,
|
||||
};
|
||||
|
||||
const enforcedHeaderKeys = new Set(
|
||||
@@ -451,11 +454,11 @@ const enforcedHeaderKeys = new Set(
|
||||
"Accept-Encoding",
|
||||
"Connection",
|
||||
"Content-Type",
|
||||
"Anthropic-Version",
|
||||
"Anthropic-Dangerous-Direct-Browser-Access",
|
||||
"Anthropic-Beta",
|
||||
"anthropic-version",
|
||||
"anthropic-dangerous-direct-browser-access",
|
||||
"anthropic-beta",
|
||||
"User-Agent",
|
||||
"X-App",
|
||||
"x-app",
|
||||
"Authorization",
|
||||
"X-Api-Key",
|
||||
"X-Claude-Code-Session-Id",
|
||||
@@ -478,7 +481,7 @@ function createClaudeBillingHeader(firstUserMessageText: string): string {
|
||||
.slice(0, 3);
|
||||
// cch=00000: placeholder replaced with the real attestation hash by wrapFetchForCch
|
||||
// before the request hits the wire (see below).
|
||||
return `${CLAUDE_BILLING_HEADER_PREFIX} cc_version=${claudeCodeVersion}.${versionSuffix}; cc_entrypoint=cli; ${CCH_PLACEHOLDER_STR};`;
|
||||
return `${CLAUDE_BILLING_HEADER_PREFIX} cc_version=${claudeCodeVersion}.${versionSuffix}; cc_entrypoint=local-agent; ${CCH_PLACEHOLDER_STR};`;
|
||||
}
|
||||
|
||||
// cch attestation: XXHash64(body_with_placeholder, seed) low-20-bits, 5 hex chars.
|
||||
@@ -618,19 +621,17 @@ function resolveAnthropicMetadataUserId(
|
||||
return generateClaudeJsonUserId(sessionId);
|
||||
}
|
||||
const ANTHROPIC_BUILTIN_TOOL_NAMES = new Set(["web_search", "code_execution", "text_editor", "computer"]);
|
||||
export const applyClaudeToolPrefix = (name: string, prefixOverride: string = claudeToolPrefix) => {
|
||||
if (!prefixOverride) return name;
|
||||
export const applyClaudeToolPrefix = (name: string): string => {
|
||||
if (!claudeToolPrefix) return name;
|
||||
if (ANTHROPIC_BUILTIN_TOOL_NAMES.has(name.toLowerCase())) return name;
|
||||
const prefix = prefixOverride.toLowerCase();
|
||||
if (name.toLowerCase().startsWith(prefix)) return name;
|
||||
return `${prefixOverride}${name}`;
|
||||
if (name.toLowerCase().startsWith(claudeToolPrefix.toLowerCase())) return name;
|
||||
return `${claudeToolPrefix}${name}`;
|
||||
};
|
||||
|
||||
export const stripClaudeToolPrefix = (name: string, prefixOverride: string = claudeToolPrefix) => {
|
||||
if (!prefixOverride) return name;
|
||||
const prefix = prefixOverride.toLowerCase();
|
||||
if (!name.toLowerCase().startsWith(prefix)) return name;
|
||||
return name.slice(prefixOverride.length);
|
||||
export const stripClaudeToolPrefix = (name: string): string => {
|
||||
if (!claudeToolPrefix) return name;
|
||||
if (!name.toLowerCase().startsWith(claudeToolPrefix.toLowerCase())) return name;
|
||||
return name.slice(claudeToolPrefix.length);
|
||||
};
|
||||
|
||||
const ANTHROPIC_MANY_IMAGE_THRESHOLD = 20;
|
||||
@@ -863,7 +864,6 @@ export type AnthropicClientOptionsArgs = {
|
||||
hasTools?: boolean;
|
||||
thinkingEnabled?: boolean;
|
||||
thinkingDisplay?: AnthropicThinkingDisplay;
|
||||
onSseEvent?: AnthropicOptions["onSseEvent"];
|
||||
fetch?: FetchImpl;
|
||||
claudeCodeSessionId?: string;
|
||||
};
|
||||
@@ -1036,11 +1036,25 @@ const ANTHROPIC_MESSAGE_EVENTS: ReadonlySet<string> = new Set([
|
||||
"content_block_stop",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Anthropic keepalive `ping` events carry no message content, but they prove the
|
||||
* upstream connection is alive during long server-side gaps (extended thinking,
|
||||
* slow tool execution). They are normally dropped before reaching the consumer;
|
||||
* we instead surface them as lightweight markers so the idle watchdog
|
||||
* (`iterateWithIdleTimeout`) resets its deadline on every ping. Without this, a
|
||||
* connection that is demonstrably still streaming pings still trips
|
||||
* "Anthropic stream stalled while waiting for the next event". The message-event
|
||||
* branches in `streamAnthropic` match none of these markers, so they are ignored.
|
||||
*/
|
||||
type RawMessagePingEvent = { type: "ping" };
|
||||
type AnthropicStreamEvent = RawMessageStreamEvent | RawMessagePingEvent;
|
||||
const ANTHROPIC_PING_EVENT: RawMessagePingEvent = { type: "ping" };
|
||||
|
||||
async function* iterateAnthropicEvents(
|
||||
response: Response,
|
||||
signal?: AbortSignal,
|
||||
onSseEvent?: AnthropicOptions["onSseEvent"],
|
||||
): AsyncGenerator<RawMessageStreamEvent> {
|
||||
): AsyncGenerator<AnthropicStreamEvent> {
|
||||
if (!response.body) {
|
||||
throw new Error("Attempted to iterate over an Anthropic response with no body");
|
||||
}
|
||||
@@ -1054,6 +1068,12 @@ async function* iterateAnthropicEvents(
|
||||
throw new Error(sse.data);
|
||||
}
|
||||
|
||||
if (sse.event === "ping") {
|
||||
// Surface keepalives so the idle watchdog treats them as liveness.
|
||||
yield ANTHROPIC_PING_EVENT;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ANTHROPIC_MESSAGE_EVENTS.has(sse.event ?? "")) {
|
||||
continue;
|
||||
}
|
||||
@@ -1103,22 +1123,40 @@ async function getAnthropicStreamResponse(
|
||||
request: unknown,
|
||||
signal?: AbortSignal,
|
||||
onSseEvent?: AnthropicOptions["onSseEvent"],
|
||||
): Promise<{ events: AsyncIterable<RawMessageStreamEvent>; response: Response; requestId: string | null }> {
|
||||
): Promise<{
|
||||
events: AsyncIterable<AnthropicStreamEvent>;
|
||||
response: Response;
|
||||
requestId: string | null;
|
||||
recordsRawSseEvents: boolean;
|
||||
}> {
|
||||
if (hasAnthropicRawResponseRequest(request)) {
|
||||
const response = await request.asResponse();
|
||||
return {
|
||||
events: iterateAnthropicEvents(response, signal, onSseEvent),
|
||||
response,
|
||||
requestId: response.headers.get("request-id"),
|
||||
recordsRawSseEvents: true,
|
||||
};
|
||||
}
|
||||
if (hasAnthropicStreamWithResponseRequest(request)) {
|
||||
const { data, response, request_id } = await request.withResponse();
|
||||
return { events: data, response, requestId: request_id };
|
||||
return { events: data, response, requestId: request_id, recordsRawSseEvents: false };
|
||||
}
|
||||
throw new Error("Anthropic SDK request did not expose a stream response");
|
||||
}
|
||||
|
||||
async function* observeDecodedAnthropicSdkEvents(
|
||||
events: AsyncIterable<AnthropicStreamEvent>,
|
||||
observer: (event: RawSseEvent) => void,
|
||||
): AsyncGenerator<AnthropicStreamEvent> {
|
||||
for await (const event of events) {
|
||||
const data = JSON.stringify(event);
|
||||
// Reconstructed from decoded SDK event; not literal wire bytes.
|
||||
notifyRawSseEvent(observer, { event: event.type, data, raw: [`event: ${event.type}`, `data: ${data}`] });
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
|
||||
function getAnthropicCompat(
|
||||
model: Model<"anthropic-messages">,
|
||||
): Required<NonNullable<Model<"anthropic-messages">["compat"]>> {
|
||||
@@ -1189,6 +1227,14 @@ function isProviderRetryableStreamEnvelopeError(error: unknown): boolean {
|
||||
export function isProviderRetryableError(error: unknown, provider?: string): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
if (provider === "github-copilot" && isCopilotTransientModelError(error)) return true;
|
||||
// Account-level usage/quota limits ("usage_limit_reached", "exceed your
|
||||
// account's rate limit", "quota exceeded") are persistent — the server
|
||||
// parks the credential for minutes-to-hours (see the long `retry-after`).
|
||||
// Retrying the same key with the provider's seconds-scale backoff never
|
||||
// helps; these are owned by the credential-rotation layer (auth-gateway /
|
||||
// `streamSimple` a/b/c policy), so surface them immediately instead of
|
||||
// burning the retry budget here.
|
||||
if (isUsageLimitError(error.message)) return false;
|
||||
const msg = error.message.toLowerCase();
|
||||
if (
|
||||
isUnexpectedSocketCloseMessage(msg) ||
|
||||
@@ -1285,6 +1331,9 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
|
||||
let rawRequestDump: RawHttpRequestDump | undefined;
|
||||
let activeAbortTracker = createAbortSourceTracker(options?.signal);
|
||||
|
||||
const onSseEvent = options?.onSseEvent;
|
||||
const rawSseObserver = onSseEvent ? (event: RawSseEvent) => onSseEvent(event, model) : undefined;
|
||||
|
||||
try {
|
||||
let client: AnthropicMessagesClientLike;
|
||||
let isOAuthToken: boolean;
|
||||
@@ -1319,7 +1368,6 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
|
||||
hasTools: !!context.tools?.length,
|
||||
thinkingEnabled: options?.thinkingEnabled,
|
||||
thinkingDisplay: options?.thinkingDisplay,
|
||||
onSseEvent: options?.onSseEvent,
|
||||
fetch: options?.fetch,
|
||||
claudeCodeSessionId: options?.sessionId ?? extractClaudeMetadataSessionId(options?.metadata?.user_id),
|
||||
});
|
||||
@@ -1395,19 +1443,17 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
|
||||
requestTimeoutMs,
|
||||
);
|
||||
}
|
||||
let anthropicStream: AsyncIterable<RawMessageStreamEvent>;
|
||||
let anthropicStream: AsyncIterable<AnthropicStreamEvent>;
|
||||
let response: Response;
|
||||
let requestId: string | null;
|
||||
let recordsRawSseEvents: boolean;
|
||||
try {
|
||||
({
|
||||
events: anthropicStream,
|
||||
response,
|
||||
requestId,
|
||||
} = await getAnthropicStreamResponse(
|
||||
anthropicRequest,
|
||||
requestSignal,
|
||||
options?.client ? event => options?.onSseEvent?.(event, model) : undefined,
|
||||
));
|
||||
recordsRawSseEvents,
|
||||
} = await getAnthropicStreamResponse(anthropicRequest, requestSignal, rawSseObserver));
|
||||
} catch (error) {
|
||||
if (error instanceof AnthropicConnectionTimeoutError && !activeAbortTracker.wasCallerAbort()) {
|
||||
throw firstEventTimeoutAbortError;
|
||||
@@ -1421,7 +1467,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
|
||||
let sawMessageStart = false;
|
||||
let sawTerminalEnvelope = false;
|
||||
|
||||
for await (const event of iterateWithIdleTimeout(anthropicStream, {
|
||||
const timedAnthropicStream = iterateWithIdleTimeout(anthropicStream, {
|
||||
idleTimeoutMs,
|
||||
firstItemTimeoutMs: firstEventTimeoutMs,
|
||||
errorMessage: idleTimeoutAbortError.message,
|
||||
@@ -1429,7 +1475,12 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
|
||||
onIdle: () => activeAbortTracker.abortLocally(idleTimeoutAbortError),
|
||||
onFirstItemTimeout: () => activeAbortTracker.abortLocally(firstEventTimeoutAbortError),
|
||||
abortSignal: options?.signal,
|
||||
})) {
|
||||
});
|
||||
const observedAnthropicStream =
|
||||
rawSseObserver && !recordsRawSseEvents
|
||||
? observeDecodedAnthropicSdkEvents(timedAnthropicStream, rawSseObserver)
|
||||
: timedAnthropicStream;
|
||||
for await (const event of observedAnthropicStream) {
|
||||
sawEvent = true;
|
||||
|
||||
if (event.type === "message_start") {
|
||||
@@ -1848,7 +1899,6 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
|
||||
thinkingEnabled = false,
|
||||
thinkingDisplay,
|
||||
isOAuth,
|
||||
onSseEvent,
|
||||
claudeCodeSessionId,
|
||||
} = args;
|
||||
const compat = getAnthropicCompat(model);
|
||||
@@ -1862,7 +1912,6 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
|
||||
// Only OAuth requests inject the CC billing header; no API-key request can ever
|
||||
// contain it, so there is no need to install the rewriter for those.
|
||||
const cchFetch = oauthToken ? wrapFetchForCch(baseFetch) : baseFetch;
|
||||
const debugFetch = onSseEvent ? wrapFetchForSseDebug(cchFetch, event => onSseEvent(event, model)) : cchFetch;
|
||||
if (model.provider === "github-copilot") {
|
||||
const copilotApiKey = parseGitHubCopilotApiKey(apiKey).accessToken;
|
||||
const betaFeatures = [...extraBetas];
|
||||
@@ -1888,7 +1937,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
|
||||
baseURL: baseUrl,
|
||||
maxRetries: 5,
|
||||
defaultHeaders,
|
||||
fetch: debugFetch,
|
||||
fetch: cchFetch,
|
||||
...(tlsFetchOptions ? { fetchOptions: tlsFetchOptions } : {}),
|
||||
};
|
||||
}
|
||||
@@ -1923,7 +1972,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
|
||||
baseURL: baseUrl,
|
||||
maxRetries: 5,
|
||||
defaultHeaders,
|
||||
fetch: debugFetch,
|
||||
fetch: cchFetch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1939,7 +1988,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
|
||||
baseURL: baseUrl,
|
||||
maxRetries: 5,
|
||||
defaultHeaders,
|
||||
...(debugFetch ? { fetch: debugFetch } : {}),
|
||||
fetch: cchFetch,
|
||||
...(tlsFetchOptions ? { fetchOptions: tlsFetchOptions } : {}),
|
||||
};
|
||||
}
|
||||
@@ -1954,7 +2003,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
|
||||
baseURL: baseUrl,
|
||||
maxRetries: 5,
|
||||
defaultHeaders,
|
||||
...(debugFetch ? { fetch: debugFetch } : {}),
|
||||
fetch: cchFetch,
|
||||
...(tlsFetchOptions ? { fetchOptions: tlsFetchOptions } : {}),
|
||||
};
|
||||
}
|
||||
@@ -1966,7 +2015,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
|
||||
baseURL: baseUrl,
|
||||
maxRetries: 5,
|
||||
defaultHeaders,
|
||||
fetch: debugFetch,
|
||||
fetch: cchFetch,
|
||||
...(tlsFetchOptions ? { fetchOptions: tlsFetchOptions } : {}),
|
||||
};
|
||||
}
|
||||
@@ -2221,44 +2270,20 @@ function resolveAnthropicAdaptiveEffort(
|
||||
return mapEffortToAnthropicAdaptiveEffort(model, requestedEffort);
|
||||
}
|
||||
|
||||
function startsWithAfterAsciiWhitespace(value: string, prefix: string): boolean {
|
||||
let index = 0;
|
||||
while (index < value.length) {
|
||||
const code = value.charCodeAt(index);
|
||||
if (code !== 9 && code !== 10 && code !== 13 && code !== 32) break;
|
||||
index++;
|
||||
}
|
||||
return value.startsWith(prefix, index);
|
||||
}
|
||||
|
||||
function isClaudeSyntheticUserText(value: string): boolean {
|
||||
return startsWithAfterAsciiWhitespace(value, "<system-reminder>");
|
||||
}
|
||||
|
||||
function extractClaudeCodeFirstUserMessageText(messages: readonly Message[]): string {
|
||||
for (const message of messages) {
|
||||
if (message.role !== "user") continue;
|
||||
const { content } = message;
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
let fallback: string | undefined;
|
||||
for (const block of content) {
|
||||
if (block.type !== "text") continue;
|
||||
fallback ??= block.text;
|
||||
if (!isClaudeSyntheticUserText(block.text)) return block.text;
|
||||
if (block.type === "text") return block.text;
|
||||
}
|
||||
return fallback ?? "";
|
||||
return "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function applyClaudeCodeContextManagement(params: MessageCreateParamsStreaming, isOAuthToken: boolean): void {
|
||||
if (!isOAuthToken || params.thinking?.type !== "adaptive") return;
|
||||
params.context_management = {
|
||||
edits: [{ type: "clear_thinking_20251015", keep: "all" }],
|
||||
};
|
||||
}
|
||||
|
||||
function buildParams(
|
||||
model: Model<"anthropic-messages">,
|
||||
baseUrl: string,
|
||||
@@ -2268,20 +2293,101 @@ function buildParams(
|
||||
disableStrictTools = false,
|
||||
): MessageCreateParamsStreaming {
|
||||
const { cacheControl } = getCacheControl(model, baseUrl, options?.cacheRetention, isOAuthToken);
|
||||
|
||||
// Pre-compute system blocks so they occupy the right slot in the serialized body.
|
||||
const shouldInjectClaudeCodeInstruction = isOAuthToken && !model.id.startsWith("claude-3-5-haiku");
|
||||
const firstUserMessageText = shouldInjectClaudeCodeInstruction
|
||||
? extractClaudeCodeFirstUserMessageText(context.messages)
|
||||
: "";
|
||||
const systemBlocks = buildAnthropicSystemBlocks(context.systemPrompt, {
|
||||
includeClaudeCodeInstruction: shouldInjectClaudeCodeInstruction,
|
||||
firstUserMessageText,
|
||||
});
|
||||
|
||||
// Pre-compute tools.
|
||||
let tools: ReturnType<typeof convertTools> | undefined;
|
||||
if (context.tools) {
|
||||
tools = convertTools(
|
||||
context.tools,
|
||||
isOAuthToken,
|
||||
disableStrictTools || model.provider === "github-copilot",
|
||||
getAnthropicCompat(model).supportsEagerToolInputStreaming,
|
||||
);
|
||||
} else if (isOAuthToken) {
|
||||
tools = [];
|
||||
}
|
||||
|
||||
// Pre-compute metadata.
|
||||
const metadataUserId = resolveAnthropicMetadataUserId(options?.metadata?.user_id, isOAuthToken, options?.sessionId);
|
||||
const metadata = metadataUserId ? { user_id: metadataUserId } : undefined;
|
||||
|
||||
// Pre-compute thinking + output_config effort.
|
||||
let thinking: MessageCreateParamsStreaming["thinking"] | undefined;
|
||||
let outputConfigEffort: AnthropicEffort | undefined;
|
||||
if (model.reasoning) {
|
||||
if (options?.thinkingEnabled) {
|
||||
const mode = model.thinking?.mode;
|
||||
const effort = resolveAnthropicAdaptiveEffort(model, options);
|
||||
const compat = getAnthropicCompat(model);
|
||||
if (mode === "anthropic-adaptive" && !compat.disableAdaptiveThinking) {
|
||||
const adaptive: { type: "adaptive"; display?: AnthropicThinkingDisplay } = { type: "adaptive" };
|
||||
// Starting with Claude Opus 4.7, adaptive thinking content is omitted from the
|
||||
// response by default. Opt into summarized reasoning so thinking deltas keep
|
||||
// streaming with human-readable content for callers that rely on it.
|
||||
if (options.thinkingDisplay !== undefined || supportsAdaptiveThinkingDisplay(model.id)) {
|
||||
adaptive.display = options.thinkingDisplay ?? "summarized";
|
||||
}
|
||||
thinking = adaptive;
|
||||
if (effort) outputConfigEffort = effort;
|
||||
} else {
|
||||
thinking = {
|
||||
type: "enabled",
|
||||
budget_tokens: options.thinkingBudgetTokens || 1024,
|
||||
display: options.thinkingDisplay ?? "summarized",
|
||||
};
|
||||
if (mode === "anthropic-budget-effort" && effort) outputConfigEffort = effort;
|
||||
}
|
||||
} else if (options?.thinkingEnabled === false) {
|
||||
thinking = { type: "disabled" };
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-compute context_management (depends on thinking).
|
||||
const contextManagement =
|
||||
isOAuthToken && thinking?.type === "adaptive"
|
||||
? { edits: [{ type: "clear_thinking_20251015" as const, keep: "all" as const }] }
|
||||
: undefined;
|
||||
|
||||
// Pre-compute output_config.
|
||||
const outputConfigEntries: AnthropicOutputConfig = {};
|
||||
if (outputConfigEffort) outputConfigEntries.effort = outputConfigEffort;
|
||||
if (options?.taskBudget) outputConfigEntries.task_budget = options.taskBudget;
|
||||
const outputConfig = Object.keys(outputConfigEntries).length ? outputConfigEntries : undefined;
|
||||
|
||||
// Build params in the canonical field order: model → messages → system → tools →
|
||||
// metadata → max_tokens → thinking → context_management → output_config → stream.
|
||||
const params: MessageCreateParamsStreaming = {
|
||||
model: model.id,
|
||||
messages: convertAnthropicMessages(context.messages, model, isOAuthToken),
|
||||
max_tokens: options?.maxTokens || model.maxTokens,
|
||||
...(systemBlocks && { system: systemBlocks }),
|
||||
...(tools !== undefined && { tools }),
|
||||
...(metadata && { metadata }),
|
||||
max_tokens: Math.min(CLAUDE_CODE_MAX_OUTPUT_TOKENS, options?.maxTokens || model.maxTokens),
|
||||
...(thinking && { thinking }),
|
||||
...(contextManagement && { context_management: contextManagement }),
|
||||
...(outputConfig && { output_config: outputConfig }),
|
||||
stream: true,
|
||||
};
|
||||
if (options?.temperature !== undefined && !options?.thinkingEnabled) {
|
||||
|
||||
// Opus 4.7+ rejects non-default sampling parameters with 400 error.
|
||||
const allowSamplingParams = !hasOpus47ApiRestrictions(model.id);
|
||||
if (allowSamplingParams && options?.temperature !== undefined && !options?.thinkingEnabled) {
|
||||
params.temperature = options.temperature;
|
||||
}
|
||||
|
||||
if (options?.topP !== undefined) {
|
||||
if (allowSamplingParams && options?.topP !== undefined) {
|
||||
params.top_p = options.topP;
|
||||
}
|
||||
if (options?.topK !== undefined) {
|
||||
if (allowSamplingParams && options?.topK !== undefined) {
|
||||
params.top_k = options.topK;
|
||||
}
|
||||
if (options?.stopSequences?.length) {
|
||||
@@ -2297,65 +2403,6 @@ function buildParams(
|
||||
seqs.length > ANTHROPIC_STOP_SEQUENCES_MAX ? seqs.slice(0, ANTHROPIC_STOP_SEQUENCES_MAX) : seqs;
|
||||
}
|
||||
|
||||
// Opus 4.7+ rejects non-default sampling parameters with 400 error.
|
||||
if (hasOpus47ApiRestrictions(model.id)) {
|
||||
delete params.top_p;
|
||||
delete params.top_k;
|
||||
delete params.temperature;
|
||||
}
|
||||
|
||||
if (context.tools) {
|
||||
params.tools = convertTools(
|
||||
context.tools,
|
||||
isOAuthToken,
|
||||
disableStrictTools || model.provider === "github-copilot",
|
||||
getAnthropicCompat(model).supportsEagerToolInputStreaming,
|
||||
);
|
||||
} else if (isOAuthToken) {
|
||||
params.tools = [];
|
||||
}
|
||||
|
||||
if (model.reasoning) {
|
||||
if (options?.thinkingEnabled) {
|
||||
const mode = model.thinking?.mode;
|
||||
const effort = resolveAnthropicAdaptiveEffort(model, options);
|
||||
|
||||
const compat = getAnthropicCompat(model);
|
||||
if (mode === "anthropic-adaptive" && !compat.disableAdaptiveThinking) {
|
||||
const adaptive: { type: "adaptive"; display?: AnthropicThinkingDisplay } = { type: "adaptive" };
|
||||
// Starting with Claude Opus 4.7, adaptive thinking content is omitted from the
|
||||
// response by default. Opt into summarized reasoning so thinking deltas keep
|
||||
// streaming with human-readable content for callers that rely on it.
|
||||
if (options.thinkingDisplay !== undefined || supportsAdaptiveThinkingDisplay(model.id)) {
|
||||
adaptive.display = options.thinkingDisplay ?? "summarized";
|
||||
}
|
||||
params.thinking = adaptive;
|
||||
if (effort) {
|
||||
getAnthropicOutputConfig(params).effort = effort;
|
||||
}
|
||||
} else {
|
||||
params.thinking = {
|
||||
type: "enabled",
|
||||
budget_tokens: options.thinkingBudgetTokens || 1024,
|
||||
display: options.thinkingDisplay ?? "summarized",
|
||||
};
|
||||
if (mode === "anthropic-budget-effort" && effort) {
|
||||
getAnthropicOutputConfig(params).effort = effort;
|
||||
}
|
||||
}
|
||||
} else if (options?.thinkingEnabled === false) {
|
||||
params.thinking = { type: "disabled" };
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.taskBudget) {
|
||||
getAnthropicOutputConfig(params).task_budget = options.taskBudget;
|
||||
}
|
||||
const metadataUserId = resolveAnthropicMetadataUserId(options?.metadata?.user_id, isOAuthToken, options?.sessionId);
|
||||
if (metadataUserId) {
|
||||
params.metadata = { user_id: metadataUserId };
|
||||
}
|
||||
|
||||
if (resolveServiceTier(options?.serviceTier, model.provider) === "priority") {
|
||||
params.speed = "fast";
|
||||
}
|
||||
@@ -2370,19 +2417,7 @@ function buildParams(
|
||||
}
|
||||
}
|
||||
|
||||
const shouldInjectClaudeCodeInstruction = isOAuthToken && !model.id.startsWith("claude-3-5-haiku");
|
||||
const firstUserMessageText = shouldInjectClaudeCodeInstruction
|
||||
? extractClaudeCodeFirstUserMessageText(context.messages)
|
||||
: "";
|
||||
const systemBlocks = buildAnthropicSystemBlocks(context.systemPrompt, {
|
||||
includeClaudeCodeInstruction: shouldInjectClaudeCodeInstruction,
|
||||
firstUserMessageText,
|
||||
});
|
||||
if (systemBlocks) {
|
||||
params.system = systemBlocks;
|
||||
}
|
||||
disableThinkingIfToolChoiceForced(params);
|
||||
applyClaudeCodeContextManagement(params, isOAuthToken);
|
||||
ensureMaxTokensForThinking(params, model);
|
||||
applyPromptCaching(params, cacheControl);
|
||||
enforceCacheControlLimit(params, 4);
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
AssistantMessage,
|
||||
Context,
|
||||
Model,
|
||||
RawSseEvent,
|
||||
ServiceTier,
|
||||
StreamFunction,
|
||||
StreamOptions,
|
||||
@@ -27,7 +28,7 @@ import {
|
||||
iterateWithIdleTimeout,
|
||||
} from "../utils/idle-iterator";
|
||||
import { sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema";
|
||||
import { wrapFetchForSseDebug } from "../utils/sse-debug";
|
||||
import { notifyRawSseEvent } from "../utils/sse-debug";
|
||||
import { mapToOpenAIResponsesToolChoice } from "../utils/tool-choice";
|
||||
import { normalizeOpenAIResponsesPromptCacheKey, supportsDeveloperRole } from "./openai-responses";
|
||||
import {
|
||||
@@ -89,6 +90,18 @@ type AzureOpenAIResponsesSamplingParams = ResponseCreateParamsStreaming & {
|
||||
repetition_penalty?: number;
|
||||
};
|
||||
|
||||
async function* observeDecodedAzureResponsesEvents(
|
||||
events: AsyncIterable<ResponseStreamEvent>,
|
||||
observer: (event: RawSseEvent) => void,
|
||||
): AsyncGenerator<ResponseStreamEvent> {
|
||||
for await (const event of events) {
|
||||
const data = JSON.stringify(event);
|
||||
// Reconstructed from decoded SDK event; not literal wire bytes.
|
||||
notifyRawSseEvent(observer, { event: event.type, data, raw: [`event: ${event.type}`, `data: ${data}`] });
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate function for Azure OpenAI Responses API
|
||||
*/
|
||||
@@ -114,6 +127,8 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses"
|
||||
const abortTracker = createAbortSourceTracker(options?.signal);
|
||||
const firstEventTimeoutAbortError = new Error(AZURE_OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE);
|
||||
const { requestAbortController, requestSignal } = abortTracker;
|
||||
const onSseEvent = options?.onSseEvent;
|
||||
const rawSseObserver = onSseEvent ? (event: RawSseEvent) => onSseEvent(event, model) : undefined;
|
||||
|
||||
try {
|
||||
// Create Azure OpenAI client
|
||||
@@ -156,26 +171,24 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses"
|
||||
}
|
||||
stream.push({ type: "start", partial: output });
|
||||
|
||||
await processResponsesStream(
|
||||
iterateWithIdleTimeout(openaiStream, {
|
||||
idleTimeoutMs,
|
||||
firstItemTimeoutMs: firstEventTimeoutMs,
|
||||
firstItemErrorMessage: AZURE_OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE,
|
||||
errorMessage: "Azure OpenAI responses stream stalled while waiting for the next event",
|
||||
onIdle: () => requestAbortController.abort(),
|
||||
onFirstItemTimeout: () => abortTracker.abortLocally(firstEventTimeoutAbortError),
|
||||
abortSignal: options?.signal,
|
||||
isProgressItem: isOpenAIResponsesProgressEvent,
|
||||
}),
|
||||
output,
|
||||
stream,
|
||||
model,
|
||||
{
|
||||
onFirstToken: () => {
|
||||
if (!firstTokenTime) firstTokenTime = Date.now();
|
||||
},
|
||||
const timedOpenaiStream = iterateWithIdleTimeout(openaiStream, {
|
||||
idleTimeoutMs,
|
||||
firstItemTimeoutMs: firstEventTimeoutMs,
|
||||
firstItemErrorMessage: AZURE_OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE,
|
||||
errorMessage: "Azure OpenAI responses stream stalled while waiting for the next event",
|
||||
onIdle: () => requestAbortController.abort(),
|
||||
onFirstItemTimeout: () => abortTracker.abortLocally(firstEventTimeoutAbortError),
|
||||
abortSignal: options?.signal,
|
||||
isProgressItem: isOpenAIResponsesProgressEvent,
|
||||
});
|
||||
const observedOpenaiStream = rawSseObserver
|
||||
? observeDecodedAzureResponsesEvents(timedOpenaiStream, rawSseObserver)
|
||||
: timedOpenaiStream;
|
||||
await processResponsesStream(observedOpenaiStream, output, stream, model, {
|
||||
onFirstToken: () => {
|
||||
if (!firstTokenTime) firstTokenTime = Date.now();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const firstEventTimeoutError = abortTracker.getLocalAbortReason();
|
||||
if (firstEventTimeoutError) {
|
||||
@@ -269,7 +282,6 @@ function createClient(model: Model<"azure-openai-responses">, apiKey: string, op
|
||||
const { baseUrl, apiVersion } = resolveAzureConfig(model, options);
|
||||
|
||||
const baseFetch = options?.fetch ?? fetch;
|
||||
const onSseEvent = options?.onSseEvent;
|
||||
return new AzureOpenAI({
|
||||
apiKey,
|
||||
apiVersion,
|
||||
@@ -277,7 +289,7 @@ function createClient(model: Model<"azure-openai-responses">, apiKey: string, op
|
||||
maxRetries: 5,
|
||||
defaultHeaders: headers,
|
||||
baseURL: baseUrl,
|
||||
fetch: onSseEvent ? wrapFetchForSseDebug(baseFetch, event => onSseEvent(event, model)) : baseFetch,
|
||||
fetch: baseFetch,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type Model,
|
||||
type OpenAICompat,
|
||||
type ProviderSessionState,
|
||||
type RawSseEvent,
|
||||
resolveServiceTier,
|
||||
type ServiceTier,
|
||||
type StopReason,
|
||||
@@ -57,7 +58,7 @@ import { getKimiCommonHeaders } from "../utils/oauth/kimi";
|
||||
import { notifyProviderResponse } from "../utils/provider-response";
|
||||
import { callWithCopilotModelRetry } from "../utils/retry";
|
||||
import { adaptSchemaForStrict, NO_STRICT, toolWireSchema } from "../utils/schema";
|
||||
import { wrapFetchForSseDebug } from "../utils/sse-debug";
|
||||
import { notifyRawSseEvent } from "../utils/sse-debug";
|
||||
import {
|
||||
getStreamMarkupHealingPattern,
|
||||
type HealedToolCall,
|
||||
@@ -406,6 +407,20 @@ export function getOpenAICompletionsStreamIdleTimeoutFallbackMs(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function* observeDecodedOpenAICompletionChunks(
|
||||
chunks: AsyncIterable<ChatCompletionChunk>,
|
||||
observer: (event: RawSseEvent) => void,
|
||||
): AsyncGenerator<ChatCompletionChunk> {
|
||||
for await (const chunk of chunks) {
|
||||
const data = JSON.stringify(chunk);
|
||||
const event = typeof chunk.object === "string" ? chunk.object : null;
|
||||
const raw = event === null ? [`data: ${data}`] : [`event: ${event}`, `data: ${data}`];
|
||||
// Reconstructed from decoded SDK event; not literal wire bytes.
|
||||
notifyRawSseEvent(observer, { event, data, raw });
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
|
||||
export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
||||
model: Model<"openai-completions">,
|
||||
context: Context,
|
||||
@@ -423,6 +438,8 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
||||
const abortTracker = createAbortSourceTracker(options?.signal);
|
||||
const firstEventTimeoutAbortError = new Error(OPENAI_COMPLETIONS_FIRST_EVENT_TIMEOUT_MESSAGE);
|
||||
const { requestAbortController, requestSignal } = abortTracker;
|
||||
const onSseEvent = options?.onSseEvent;
|
||||
const rawSseObserver = onSseEvent ? (event: RawSseEvent) => onSseEvent(event, model) : undefined;
|
||||
|
||||
try {
|
||||
const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
|
||||
@@ -439,15 +456,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
||||
requestHeaders,
|
||||
getCapturedErrorResponse: captureErrorResponse,
|
||||
clearCapturedErrorResponse,
|
||||
} = await createClient(
|
||||
model,
|
||||
context,
|
||||
apiKey,
|
||||
options?.headers,
|
||||
options?.initiatorOverride,
|
||||
options?.onSseEvent,
|
||||
options?.fetch,
|
||||
);
|
||||
} = await createClient(model, context, apiKey, options?.headers, options?.initiatorOverride, options?.fetch);
|
||||
const premiumRequestsTotal = copilotPremiumRequests;
|
||||
getCapturedErrorResponse = captureErrorResponse;
|
||||
let appliedToolStrictMode: AppliedToolStrictMode = "mixed";
|
||||
@@ -560,6 +569,20 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
||||
if (block.partialArgs === undefined) return;
|
||||
const contentIndex = blockIndex(block);
|
||||
if (contentIndex < 0) return;
|
||||
// Object-shaped `partialArgs` came from MiniMax-compatible hosts that stream
|
||||
// `function.arguments` as an object. The per-chunk handler holds them with an
|
||||
// empty wire delta (see the object branch below) because emitting each chunk's
|
||||
// `JSON.stringify(rawArgs)` would feed concat-based downstream consumers
|
||||
// (proxy.ts, openai-chat-server, openai-responses-server, anthropic-messages-server)
|
||||
// an invalid concatenation like `{"input":"a"}{"input":"b"}`. Flush the final
|
||||
// merged object as one concat-safe delta now so those consumers reconstruct the
|
||||
// args correctly before observing `toolcall_end`.
|
||||
if (typeof block.partialArgs === "object" && !Array.isArray(block.partialArgs)) {
|
||||
const fullJson = JSON.stringify(block.partialArgs);
|
||||
if (fullJson.length > 0 && fullJson !== "{}") {
|
||||
stream.push({ type: "toolcall_delta", contentIndex, delta: fullJson, partial: output });
|
||||
}
|
||||
}
|
||||
block.arguments =
|
||||
typeof block.partialArgs === "string" ? parseStreamingJson(block.partialArgs) : block.partialArgs;
|
||||
delete block.partialArgs;
|
||||
@@ -720,7 +743,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
||||
for (const call of calls) emitHealedToolCall(call);
|
||||
};
|
||||
|
||||
for await (const chunk of iterateWithIdleTimeout(openaiStream, {
|
||||
const timedOpenaiStream = iterateWithIdleTimeout(openaiStream, {
|
||||
idleTimeoutMs,
|
||||
firstItemTimeoutMs: firstEventTimeoutMs,
|
||||
firstItemErrorMessage: OPENAI_COMPLETIONS_FIRST_EVENT_TIMEOUT_MESSAGE,
|
||||
@@ -729,7 +752,11 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
||||
onFirstItemTimeout: () => abortTracker.abortLocally(firstEventTimeoutAbortError),
|
||||
abortSignal: options?.signal,
|
||||
isProgressItem: isOpenAICompletionsProgressChunk,
|
||||
})) {
|
||||
});
|
||||
const observedOpenaiStream = rawSseObserver
|
||||
? observeDecodedOpenAICompletionChunks(timedOpenaiStream, rawSseObserver)
|
||||
: timedOpenaiStream;
|
||||
for await (const chunk of observedOpenaiStream) {
|
||||
if (!chunk || typeof chunk !== "object") continue;
|
||||
|
||||
// OpenAI documents ChatCompletionChunk.id as the unique chat completion identifier,
|
||||
@@ -869,13 +896,37 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
||||
}
|
||||
}
|
||||
} else if (rawArgs && typeof rawArgs === "object" && !Array.isArray(rawArgs)) {
|
||||
// MiniMax-compatible hosts stream `function.arguments` as a complete object in a
|
||||
// single delta instead of the OpenAI JSON-string contract. Hold the object directly
|
||||
// — no `[object Object]` round-trip through the string buffer — and serialize once for
|
||||
// the wire delta that proxy servers forward verbatim as `input_json_delta`.
|
||||
block.partialArgs = rawArgs;
|
||||
block.arguments = rawArgs;
|
||||
delta = JSON.stringify(rawArgs);
|
||||
// MiniMax-compatible hosts stream `function.arguments` as an object instead of the
|
||||
// OpenAI JSON-string contract. Most chunks carry the complete object in one delta,
|
||||
// but cannot rely on that: replacing per-chunk drops earlier keys (and earlier
|
||||
// string content for the same key) when the host fragments the args across deltas.
|
||||
// Shallow-merge into the accumulated object; for shared string keys, detect
|
||||
// cumulative-vs-delta semantics with `startsWith` so we neither duplicate cumulative
|
||||
// payloads nor lose delta fragments. Degenerates to the previous "last wins"
|
||||
// behaviour for the common single-chunk shape (no prior value to merge with).
|
||||
//
|
||||
// `delta` stays empty here: emitting `JSON.stringify(rawArgs)` per chunk feeds
|
||||
// downstream concat-based accumulators (proxy.ts, openai-chat-server,
|
||||
// openai-responses-server, anthropic-messages-server) an invalid sequence like
|
||||
// `{"input":"a"}{"input":"b"}`. The merged object is flushed as a single
|
||||
// concat-safe delta in `finishToolCallBlock` before `toolcall_end` instead.
|
||||
const prev =
|
||||
block.partialArgs &&
|
||||
typeof block.partialArgs === "object" &&
|
||||
!Array.isArray(block.partialArgs)
|
||||
? (block.partialArgs as Record<string, unknown>)
|
||||
: undefined;
|
||||
const merged: Record<string, unknown> = prev ? { ...prev } : {};
|
||||
for (const [key, value] of Object.entries(rawArgs)) {
|
||||
const prevValue = merged[key];
|
||||
if (typeof prevValue === "string" && typeof value === "string") {
|
||||
merged[key] = value.startsWith(prevValue) ? value : prevValue + value;
|
||||
} else {
|
||||
merged[key] = value;
|
||||
}
|
||||
}
|
||||
block.partialArgs = merged;
|
||||
block.arguments = merged;
|
||||
}
|
||||
stream.push({
|
||||
type: "toolcall_delta",
|
||||
@@ -987,7 +1038,6 @@ async function createClient(
|
||||
apiKey?: string,
|
||||
extraHeaders?: Record<string, string>,
|
||||
initiatorOverride?: MessageAttribution,
|
||||
onSseEvent?: OpenAICompletionsOptions["onSseEvent"],
|
||||
fetchOverride?: FetchImpl,
|
||||
): Promise<{
|
||||
client: OpenAI;
|
||||
@@ -1086,7 +1136,6 @@ async function createClient(
|
||||
},
|
||||
baseFetch.preconnect ? { preconnect: baseFetch.preconnect } : {},
|
||||
);
|
||||
const debugFetch = onSseEvent ? wrapFetchForSseDebug(wrappedFetch, event => onSseEvent(event, model)) : wrappedFetch;
|
||||
return {
|
||||
client: new OpenAI({
|
||||
apiKey,
|
||||
@@ -1095,7 +1144,7 @@ async function createClient(
|
||||
maxRetries: 5,
|
||||
defaultHeaders: headers,
|
||||
defaultQuery: azureDefaultQuery,
|
||||
fetch: debugFetch,
|
||||
fetch: wrappedFetch,
|
||||
}),
|
||||
copilotPremiumRequests,
|
||||
baseUrl,
|
||||
@@ -1476,6 +1525,12 @@ export function convertMessages(
|
||||
): ChatCompletionMessageParam[] {
|
||||
const params: ChatCompletionMessageParam[] = [];
|
||||
|
||||
const maxNormalizedToolCallIdLength = compat.requiresMistralToolIds
|
||||
? 9
|
||||
: model.provider === "openai"
|
||||
? 40
|
||||
: undefined;
|
||||
const duplicateToolCallIdSuffixPrefix = compat.requiresMistralToolIds ? "dup" : undefined;
|
||||
const normalizeToolCallId = (id: string): string => {
|
||||
if (compat.requiresMistralToolIds) return normalizeMistralToolId(id, true);
|
||||
|
||||
@@ -1492,7 +1547,13 @@ export function convertMessages(
|
||||
if (model.provider === "openai") return id.length > 40 ? id.slice(0, 40) : id;
|
||||
return id;
|
||||
};
|
||||
const transformedMessages = transformMessages(context.messages, model, id => normalizeToolCallId(id));
|
||||
const transformedMessages = transformMessages(
|
||||
context.messages,
|
||||
model,
|
||||
id => normalizeToolCallId(id),
|
||||
maxNormalizedToolCallIdLength,
|
||||
duplicateToolCallIdSuffixPrefix,
|
||||
);
|
||||
|
||||
const remappedToolCallIds = new Map<string, string[]>();
|
||||
let generatedToolCallIdCounter = 0;
|
||||
|
||||
@@ -698,6 +698,7 @@ interface OpenFunctionCall {
|
||||
kind: "function_call";
|
||||
itemId: string;
|
||||
outputIndex: number;
|
||||
contentIndex: number;
|
||||
callId: string;
|
||||
name: string;
|
||||
argsText: string;
|
||||
@@ -729,7 +730,9 @@ export function encodeStream(
|
||||
let createdAt = Math.floor(Date.now() / 1000);
|
||||
let outputIndex = 0;
|
||||
const state: { open: OpenItem | null } = { open: null };
|
||||
const openFunctionCalls = new Map<number, OpenFunctionCall>();
|
||||
const finishedItems: OutputItem[] = [];
|
||||
const allocateOutputIndex = (): number => outputIndex++;
|
||||
|
||||
const responseSnapshot = (status: ResponseStatus, output: OutputItem[] | []) => ({
|
||||
id: responseId,
|
||||
@@ -742,6 +745,7 @@ export function encodeStream(
|
||||
});
|
||||
|
||||
const openMessage = (): OpenMessage => {
|
||||
const itemOutputIndex = allocateOutputIndex();
|
||||
const itemId = makeMsgId();
|
||||
const item = {
|
||||
type: "message" as const,
|
||||
@@ -750,11 +754,11 @@ export function encodeStream(
|
||||
role: "assistant" as const,
|
||||
content: [] as Array<{ type: "output_text"; text: string; annotations: never[] }>,
|
||||
};
|
||||
emit("response.output_item.added", { output_index: outputIndex, item });
|
||||
emit("response.output_item.added", { output_index: itemOutputIndex, item });
|
||||
const next: OpenMessage = {
|
||||
kind: "message",
|
||||
itemId,
|
||||
outputIndex,
|
||||
outputIndex: itemOutputIndex,
|
||||
contentIndex: 0,
|
||||
currentPartText: "",
|
||||
content: [],
|
||||
@@ -764,6 +768,7 @@ export function encodeStream(
|
||||
};
|
||||
|
||||
const openReasoning = (partial: AssistantMessage, contentIndex: number): OpenReasoning => {
|
||||
const itemOutputIndex = allocateOutputIndex();
|
||||
const part = partial.content[contentIndex];
|
||||
const itemId = part && part.type === "thinking" ? reasoningItemId(part) : makeReasoningId();
|
||||
const item = {
|
||||
@@ -771,22 +776,23 @@ export function encodeStream(
|
||||
id: itemId,
|
||||
summary: [] as Array<{ type: "summary_text"; text: string }>,
|
||||
};
|
||||
emit("response.output_item.added", { output_index: outputIndex, item });
|
||||
emit("response.output_item.added", { output_index: itemOutputIndex, item });
|
||||
// Open the summary part. Real OpenAI streams summary text in the
|
||||
// canonical `reasoning_summary_*` lifecycle; pi-ai's own decoder
|
||||
// reads `summary[].text` from the eventual `output_item.done`.
|
||||
emit("response.reasoning_summary_part.added", {
|
||||
item_id: itemId,
|
||||
output_index: outputIndex,
|
||||
output_index: itemOutputIndex,
|
||||
summary_index: 0,
|
||||
part: { type: "summary_text", text: "" },
|
||||
});
|
||||
const next: OpenReasoning = { kind: "reasoning", itemId, outputIndex, reasoningText: "" };
|
||||
const next: OpenReasoning = { kind: "reasoning", itemId, outputIndex: itemOutputIndex, reasoningText: "" };
|
||||
state.open = next;
|
||||
return next;
|
||||
};
|
||||
|
||||
const openToolCall = (partial: AssistantMessage, contentIndex: number): OpenFunctionCall => {
|
||||
const itemOutputIndex = allocateOutputIndex();
|
||||
const part = partial.content[contentIndex];
|
||||
const tc = part && part.type === "toolCall" ? part : undefined;
|
||||
const customWireName: string | undefined =
|
||||
@@ -814,20 +820,65 @@ export function encodeStream(
|
||||
arguments: "",
|
||||
status: "in_progress",
|
||||
};
|
||||
emit("response.output_item.added", { output_index: outputIndex, item });
|
||||
emit("response.output_item.added", { output_index: itemOutputIndex, item });
|
||||
const next: OpenFunctionCall = {
|
||||
kind: "function_call",
|
||||
itemId,
|
||||
outputIndex,
|
||||
outputIndex: itemOutputIndex,
|
||||
contentIndex,
|
||||
callId,
|
||||
name,
|
||||
argsText: "",
|
||||
...(isCustom ? { customWireName } : {}),
|
||||
};
|
||||
openFunctionCalls.set(contentIndex, next);
|
||||
state.open = next;
|
||||
return next;
|
||||
};
|
||||
|
||||
const closeFunctionCall = (call: OpenFunctionCall): void => {
|
||||
const text = call.argsText ?? "";
|
||||
if (call.customWireName) {
|
||||
const item = {
|
||||
type: "custom_tool_call",
|
||||
id: call.itemId,
|
||||
call_id: call.callId ?? "",
|
||||
name: call.customWireName,
|
||||
input: text,
|
||||
status: "completed",
|
||||
};
|
||||
emit("response.output_item.done", { output_index: call.outputIndex, item });
|
||||
finishedItems.push({
|
||||
type: "custom_tool_call",
|
||||
id: call.itemId,
|
||||
call_id: call.callId ?? "",
|
||||
name: call.customWireName,
|
||||
input: text,
|
||||
status: "completed",
|
||||
});
|
||||
} else {
|
||||
const item = {
|
||||
type: "function_call",
|
||||
id: call.itemId,
|
||||
call_id: call.callId ?? "",
|
||||
name: call.name ?? "",
|
||||
arguments: text,
|
||||
status: "completed",
|
||||
};
|
||||
emit("response.output_item.done", { output_index: call.outputIndex, item });
|
||||
finishedItems.push({
|
||||
type: "function_call",
|
||||
id: call.itemId,
|
||||
call_id: call.callId ?? "",
|
||||
name: call.name ?? "",
|
||||
arguments: text,
|
||||
status: "completed",
|
||||
});
|
||||
}
|
||||
openFunctionCalls.delete(call.contentIndex);
|
||||
if (state.open === call) state.open = null;
|
||||
};
|
||||
|
||||
const closeOpen = () => {
|
||||
if (!state.open) return;
|
||||
if (state.open.kind === "message") {
|
||||
@@ -846,6 +897,7 @@ export function encodeStream(
|
||||
status: "completed",
|
||||
content: state.open.content,
|
||||
});
|
||||
state.open = null;
|
||||
} else if (state.open.kind === "reasoning") {
|
||||
const summary = [{ type: "summary_text" as const, text: state.open.reasoningText ?? "" }];
|
||||
const item = {
|
||||
@@ -859,50 +911,23 @@ export function encodeStream(
|
||||
id: state.open.itemId,
|
||||
summary,
|
||||
});
|
||||
state.open = null;
|
||||
} else {
|
||||
const text = state.open.argsText ?? "";
|
||||
if (state.open.customWireName) {
|
||||
const item = {
|
||||
type: "custom_tool_call",
|
||||
id: state.open.itemId,
|
||||
call_id: state.open.callId ?? "",
|
||||
name: state.open.customWireName,
|
||||
input: text,
|
||||
status: "completed",
|
||||
};
|
||||
emit("response.output_item.done", { output_index: state.open.outputIndex, item });
|
||||
finishedItems.push({
|
||||
type: "custom_tool_call",
|
||||
id: state.open.itemId,
|
||||
call_id: state.open.callId ?? "",
|
||||
name: state.open.customWireName,
|
||||
input: text,
|
||||
status: "completed",
|
||||
});
|
||||
} else {
|
||||
const item = {
|
||||
type: "function_call",
|
||||
id: state.open.itemId,
|
||||
call_id: state.open.callId ?? "",
|
||||
name: state.open.name ?? "",
|
||||
arguments: text,
|
||||
status: "completed",
|
||||
};
|
||||
emit("response.output_item.done", { output_index: state.open.outputIndex, item });
|
||||
finishedItems.push({
|
||||
type: "function_call",
|
||||
id: state.open.itemId,
|
||||
call_id: state.open.callId ?? "",
|
||||
name: state.open.name ?? "",
|
||||
arguments: text,
|
||||
status: "completed",
|
||||
});
|
||||
}
|
||||
closeFunctionCall(state.open);
|
||||
}
|
||||
outputIndex++;
|
||||
state.open = null;
|
||||
};
|
||||
|
||||
const closeOpenFunctionCalls = (): void => {
|
||||
for (const call of [...openFunctionCalls.values()]) {
|
||||
closeFunctionCall(call);
|
||||
}
|
||||
};
|
||||
|
||||
const functionCallForEvent = (contentIndex: number): OpenFunctionCall | undefined => {
|
||||
const byIndex = openFunctionCalls.get(contentIndex);
|
||||
if (byIndex) return byIndex;
|
||||
return state.open?.kind === "function_call" ? state.open : undefined;
|
||||
};
|
||||
try {
|
||||
let finalMessage: AssistantMessage | null = null;
|
||||
let failureMessage: AssistantMessage | null = null;
|
||||
@@ -941,7 +966,7 @@ export function encodeStream(
|
||||
cur = state.open;
|
||||
cur.currentPartText = "";
|
||||
} else {
|
||||
if (state.open) closeOpen();
|
||||
if (state.open && state.open.kind !== "function_call") closeOpen();
|
||||
cur = openMessage();
|
||||
}
|
||||
const part = { type: "output_text", text: "", annotations: [] as never[] };
|
||||
@@ -992,7 +1017,7 @@ export function encodeStream(
|
||||
break;
|
||||
}
|
||||
case "thinking_start": {
|
||||
if (state.open) closeOpen();
|
||||
if (state.open && state.open.kind !== "function_call") closeOpen();
|
||||
openReasoning(ev.partial, ev.contentIndex);
|
||||
break;
|
||||
}
|
||||
@@ -1029,13 +1054,13 @@ export function encodeStream(
|
||||
break;
|
||||
}
|
||||
case "toolcall_start": {
|
||||
if (state.open) closeOpen();
|
||||
if (state.open && state.open.kind !== "function_call") closeOpen();
|
||||
openToolCall(ev.partial, ev.contentIndex);
|
||||
break;
|
||||
}
|
||||
case "toolcall_delta": {
|
||||
if (state.open?.kind !== "function_call") break;
|
||||
const cur: OpenFunctionCall = state.open;
|
||||
const cur = functionCallForEvent(ev.contentIndex);
|
||||
if (!cur) break;
|
||||
cur.argsText += ev.delta;
|
||||
if (cur.customWireName) {
|
||||
emit("response.custom_tool_call_input.delta", {
|
||||
@@ -1053,8 +1078,8 @@ export function encodeStream(
|
||||
break;
|
||||
}
|
||||
case "toolcall_end": {
|
||||
if (state.open?.kind !== "function_call") break;
|
||||
const cur: OpenFunctionCall = state.open;
|
||||
const cur = functionCallForEvent(ev.contentIndex);
|
||||
if (!cur) break;
|
||||
// Promote possibly-late info from the canonical ToolCall.
|
||||
const tc = ev.toolCall;
|
||||
if (tc.customWireName && !cur.customWireName) cur.customWireName = tc.customWireName;
|
||||
@@ -1087,7 +1112,7 @@ export function encodeStream(
|
||||
name: cur.name,
|
||||
});
|
||||
}
|
||||
closeOpen();
|
||||
closeFunctionCall(cur);
|
||||
break;
|
||||
}
|
||||
case "done": {
|
||||
@@ -1102,6 +1127,7 @@ export function encodeStream(
|
||||
}
|
||||
|
||||
if (failureMessage) {
|
||||
closeOpenFunctionCalls();
|
||||
if (state.open) closeOpen();
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
@@ -1120,6 +1146,7 @@ export function encodeStream(
|
||||
return;
|
||||
}
|
||||
|
||||
closeOpenFunctionCalls();
|
||||
if (state.open) closeOpen();
|
||||
const message = finalMessage ?? ((await events.result().catch(() => null)) as AssistantMessage | null);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
Tool as OpenAITool,
|
||||
ResponseCreateParamsStreaming,
|
||||
ResponseInput,
|
||||
ResponseStreamEvent,
|
||||
} from "openai/resources/responses/responses";
|
||||
import { getEnvApiKey } from "../stream";
|
||||
import type {
|
||||
@@ -15,6 +16,7 @@ import type {
|
||||
Model,
|
||||
OpenAICompat,
|
||||
ProviderSessionState,
|
||||
RawSseEvent,
|
||||
ServiceTier,
|
||||
StreamFunction,
|
||||
StreamOptions,
|
||||
@@ -42,7 +44,7 @@ import { notifyProviderResponse } from "../utils/provider-response";
|
||||
import { callWithCopilotModelRetry } from "../utils/retry";
|
||||
import { adaptSchemaForStrict, NO_STRICT, sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema";
|
||||
import { createSdkStreamRequestOptions } from "../utils/sdk-stream-timeout";
|
||||
import { wrapFetchForSseDebug } from "../utils/sse-debug";
|
||||
import { notifyRawSseEvent } from "../utils/sse-debug";
|
||||
import { mapToOpenAIResponsesToolChoice, type OpenAIResponsesToolChoice } from "../utils/tool-choice";
|
||||
import {
|
||||
buildCopilotDynamicHeaders,
|
||||
@@ -184,6 +186,18 @@ type OpenAIResponsesSamplingParams = ResponseCreateParamsStreaming & {
|
||||
stream_options?: { include_obfuscation?: boolean };
|
||||
};
|
||||
|
||||
async function* observeDecodedOpenAIResponsesEvents(
|
||||
events: AsyncIterable<ResponseStreamEvent>,
|
||||
observer: (event: RawSseEvent) => void,
|
||||
): AsyncGenerator<ResponseStreamEvent> {
|
||||
for await (const event of events) {
|
||||
const data = JSON.stringify(event);
|
||||
// Reconstructed from decoded SDK event; not literal wire bytes.
|
||||
notifyRawSseEvent(observer, { event: event.type, data, raw: [`event: ${event.type}`, `data: ${data}`] });
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate function for OpenAI Responses API
|
||||
*/
|
||||
@@ -208,6 +222,8 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
|
||||
const abortTracker = createAbortSourceTracker(options?.signal);
|
||||
const firstEventTimeoutAbortError = new Error(OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE);
|
||||
const { requestAbortController, requestSignal } = abortTracker;
|
||||
const onSseEvent = options?.onSseEvent;
|
||||
const rawSseObserver = onSseEvent ? (event: RawSseEvent) => onSseEvent(event, model) : undefined;
|
||||
|
||||
try {
|
||||
// Keep request routing on `sessionId` while allowing callers to pin a
|
||||
@@ -222,7 +238,6 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
|
||||
options?.headers,
|
||||
options?.initiatorOverride,
|
||||
routingSessionId,
|
||||
options?.onSseEvent,
|
||||
options?.fetch,
|
||||
);
|
||||
const premiumRequestsTotal = copilotPremiumRequests;
|
||||
@@ -273,29 +288,27 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
|
||||
stream.push({ type: "start", partial: output });
|
||||
|
||||
const nativeOutputItems: Array<Record<string, unknown>> = [];
|
||||
await processResponsesStream(
|
||||
iterateWithIdleTimeout(openaiStream, {
|
||||
idleTimeoutMs,
|
||||
firstItemTimeoutMs: firstEventTimeoutMs,
|
||||
firstItemErrorMessage: OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE,
|
||||
errorMessage: "OpenAI responses stream stalled while waiting for the next event",
|
||||
onFirstItemTimeout: () => abortTracker.abortLocally(firstEventTimeoutAbortError),
|
||||
onIdle: () => requestAbortController.abort(),
|
||||
abortSignal: options?.signal,
|
||||
isProgressItem: isOpenAIResponsesProgressEvent,
|
||||
}),
|
||||
output,
|
||||
stream,
|
||||
model,
|
||||
{
|
||||
onFirstToken: () => {
|
||||
if (!firstTokenTime) firstTokenTime = Date.now();
|
||||
},
|
||||
onOutputItemDone: item => {
|
||||
nativeOutputItems.push(structuredCloneJSON<unknown>(item) as unknown as Record<string, unknown>);
|
||||
},
|
||||
const timedOpenaiStream = iterateWithIdleTimeout(openaiStream, {
|
||||
idleTimeoutMs,
|
||||
firstItemTimeoutMs: firstEventTimeoutMs,
|
||||
firstItemErrorMessage: OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE,
|
||||
errorMessage: "OpenAI responses stream stalled while waiting for the next event",
|
||||
onFirstItemTimeout: () => abortTracker.abortLocally(firstEventTimeoutAbortError),
|
||||
onIdle: () => requestAbortController.abort(),
|
||||
abortSignal: options?.signal,
|
||||
isProgressItem: isOpenAIResponsesProgressEvent,
|
||||
});
|
||||
const observedOpenaiStream = rawSseObserver
|
||||
? observeDecodedOpenAIResponsesEvents(timedOpenaiStream, rawSseObserver)
|
||||
: timedOpenaiStream;
|
||||
await processResponsesStream(observedOpenaiStream, output, stream, model, {
|
||||
onFirstToken: () => {
|
||||
if (!firstTokenTime) firstTokenTime = Date.now();
|
||||
},
|
||||
);
|
||||
onOutputItemDone: item => {
|
||||
nativeOutputItems.push(structuredCloneJSON<unknown>(item) as unknown as Record<string, unknown>);
|
||||
},
|
||||
});
|
||||
if (premiumRequestsTotal !== undefined) output.usage.premiumRequests = premiumRequestsTotal;
|
||||
|
||||
const firstEventTimeoutError = abortTracker.getLocalAbortReason();
|
||||
@@ -341,7 +354,6 @@ function createClient(
|
||||
extraHeaders?: Record<string, string>,
|
||||
initiatorOverride?: MessageAttribution,
|
||||
sessionId?: string,
|
||||
onSseEvent?: OpenAIResponsesOptions["onSseEvent"],
|
||||
fetchOverride?: FetchImpl,
|
||||
): {
|
||||
client: OpenAI;
|
||||
@@ -388,7 +400,7 @@ function createClient(
|
||||
dangerouslyAllowBrowser: true,
|
||||
maxRetries: 5,
|
||||
defaultHeaders: headers,
|
||||
fetch: onSseEvent ? wrapFetchForSseDebug(baseFetch, event => onSseEvent(event, model)) : baseFetch,
|
||||
fetch: baseFetch,
|
||||
}),
|
||||
copilotPremiumRequests,
|
||||
baseUrl,
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
import turnAbortedGuidance from "../prompts/turn-aborted-guidance.md" with { type: "text" };
|
||||
import type {
|
||||
Api,
|
||||
AssistantMessage,
|
||||
DeveloperMessage,
|
||||
Message,
|
||||
Model,
|
||||
ToolCall,
|
||||
ToolResultMessage,
|
||||
UserMessage,
|
||||
} from "../types";
|
||||
import type { Api, AssistantMessage, Message, Model, ToolCall, ToolResultMessage, UserMessage } from "../types";
|
||||
|
||||
const enum ToolCallStatus {
|
||||
/** A tool result has already been emitted for this tool call; later duplicates must be skipped. */
|
||||
@@ -28,15 +18,19 @@ const enum ToolCallStatus {
|
||||
*/
|
||||
const MAX_TOOL_CALL_ID_LENGTH = 64;
|
||||
|
||||
function appendDuplicateSuffix(originalId: string, suffix: string): string {
|
||||
if (originalId.length + suffix.length <= MAX_TOOL_CALL_ID_LENGTH) return `${originalId}${suffix}`;
|
||||
const prefixBudget = Math.max(0, MAX_TOOL_CALL_ID_LENGTH - suffix.length);
|
||||
function appendDuplicateSuffix(originalId: string, suffix: string, maxLength: number): string {
|
||||
if (originalId.length + suffix.length <= maxLength) return `${originalId}${suffix}`;
|
||||
const prefixBudget = Math.max(0, maxLength - suffix.length);
|
||||
return `${originalId.slice(0, prefixBudget)}${suffix}`;
|
||||
}
|
||||
|
||||
type PendingToolResultRewrite = { replacementId: string } | undefined;
|
||||
|
||||
function deduplicateToolCallIds(messages: Message[]): Message[] {
|
||||
function deduplicateToolCallIds(
|
||||
messages: Message[],
|
||||
maxToolCallIdLength = MAX_TOOL_CALL_ID_LENGTH,
|
||||
duplicateSuffixPrefix = "_dup",
|
||||
): Message[] {
|
||||
const seenToolCallIds = new Map<string, number>();
|
||||
const pendingToolResultRewrites = new Map<string, PendingToolResultRewrite[]>();
|
||||
|
||||
@@ -90,10 +84,18 @@ function deduplicateToolCallIds(messages: Message[]): Message[] {
|
||||
}
|
||||
|
||||
let duplicateIndex = previousCount;
|
||||
let replacementId = appendDuplicateSuffix(block.id, `_dup${duplicateIndex}`);
|
||||
let replacementId = appendDuplicateSuffix(
|
||||
block.id,
|
||||
`${duplicateSuffixPrefix}${duplicateIndex}`,
|
||||
maxToolCallIdLength,
|
||||
);
|
||||
while (seenToolCallIds.has(replacementId)) {
|
||||
duplicateIndex += 1;
|
||||
replacementId = appendDuplicateSuffix(block.id, `_dup${duplicateIndex}`);
|
||||
replacementId = appendDuplicateSuffix(
|
||||
block.id,
|
||||
`${duplicateSuffixPrefix}${duplicateIndex}`,
|
||||
maxToolCallIdLength,
|
||||
);
|
||||
}
|
||||
seenToolCallIds.set(block.id, duplicateIndex + 1);
|
||||
seenToolCallIds.set(replacementId, 1);
|
||||
@@ -130,12 +132,13 @@ function getLatestSurvivingAssistantIndex(messages: readonly Message[]): number
|
||||
* For aborted/errored turns, this function:
|
||||
* - Preserves tool call structure (unlike converting to text summaries)
|
||||
* - Injects synthetic "aborted" tool results
|
||||
* - Adds a <turn-aborted> guidance marker for the model
|
||||
*/
|
||||
export function transformMessages<TApi extends Api>(
|
||||
messages: Message[],
|
||||
model: Model<TApi>,
|
||||
normalizeToolCallId?: (id: string, model: Model<TApi>, source: AssistantMessage) => string,
|
||||
maxNormalizedToolCallIdLength = MAX_TOOL_CALL_ID_LENGTH,
|
||||
duplicateToolCallIdSuffixPrefix = "_dup",
|
||||
): Message[] {
|
||||
// Build a map of original tool call IDs to normalized IDs
|
||||
const toolCallIdMap = new Map<string, string>();
|
||||
@@ -255,6 +258,8 @@ export function transformMessages<TApi extends Api>(
|
||||
}
|
||||
return msg;
|
||||
}),
|
||||
maxNormalizedToolCallIdLength,
|
||||
duplicateToolCallIdSuffixPrefix,
|
||||
);
|
||||
const realToolResultsById = new Map<string, ToolResultMessage>();
|
||||
for (const msg of transformed) {
|
||||
@@ -329,11 +334,6 @@ export function transformMessages<TApi extends Api>(
|
||||
} as ToolResultMessage);
|
||||
toolCallStatus.set(tc.id, ToolCallStatus.Aborted);
|
||||
}
|
||||
result.push({
|
||||
role: "developer",
|
||||
content: turnAbortedGuidance,
|
||||
timestamp: pendingAbortedTimestamp + 1,
|
||||
} as DeveloperMessage);
|
||||
pendingAbortedToolCalls = new Map();
|
||||
pendingAbortedTimestamp = undefined;
|
||||
};
|
||||
@@ -362,11 +362,6 @@ export function transformMessages<TApi extends Api>(
|
||||
// (OpenAI completions `reasoning_text`, Google signed thought parts).
|
||||
const originalMsg = messages[i]!;
|
||||
if (originalMsg.role === "assistant" && shouldDropTruncatedThinkingOnlyAssistant(originalMsg)) {
|
||||
if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") {
|
||||
// Still arm the aborted-turn note so downstream guidance fires.
|
||||
pendingAbortedToolCalls = new Map();
|
||||
pendingAbortedTimestamp = assistantMsg.timestamp;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -285,6 +285,18 @@ export function getEnvApiKey(provider: string): string | undefined {
|
||||
return resolver?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Name of the environment variable that backs `getEnvApiKey` for a provider,
|
||||
* when that provider maps to a single named variable (e.g. `github-copilot` →
|
||||
* `COPILOT_GITHUB_TOKEN`). Returns undefined for providers whose env fallback
|
||||
* is computed (multi-var pickers, Vertex ADC / Bedrock probes, …) since no
|
||||
* single variable name describes the source.
|
||||
*/
|
||||
export function getEnvApiKeyName(provider: string): string | undefined {
|
||||
const resolver = serviceProviderMap[provider];
|
||||
return typeof resolver === "string" ? resolver : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate every provider that has an env-var fallback for `getEnvApiKey`.
|
||||
* Used by `omp auth-broker migrate --include-env` to discover env-sourced keys
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { ServerSentEvent } from "@oh-my-pi/pi-utils";
|
||||
import type { RawSseEvent } from "../types";
|
||||
|
||||
type FetchFunction = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||
type FetchWithPreconnect = FetchFunction & { preconnect?: typeof fetch.preconnect };
|
||||
|
||||
type RawSseObserver = (event: RawSseEvent) => void;
|
||||
|
||||
export function notifyRawSseEvent(observer: RawSseObserver | undefined, event: ServerSentEvent | RawSseEvent): void {
|
||||
@@ -19,271 +16,3 @@ export function notifyRawSseEvent(observer: RawSseObserver | undefined, event: S
|
||||
// Raw stream observers are diagnostic only and must not affect generation.
|
||||
}
|
||||
}
|
||||
|
||||
function isSseResponse(response: Response): boolean {
|
||||
// `response.body` is non-null for any fetch Response with a body, but we
|
||||
// still guard because user-supplied `fetch` mocks may return `{ body: null }`
|
||||
// for empty responses and we don't want to wrap those.
|
||||
if (!response.ok || !response.body) return false;
|
||||
const contentType = response.headers.get("content-type");
|
||||
// All providers in this repo emit lowercase `text/event-stream` (verified
|
||||
// against anthropic, openai-completions, openai-responses, azure-openai-responses,
|
||||
// google-shared, google-gemini-cli, openai-codex-responses, pi-native-client,
|
||||
// and the auth-gateway server). A canonical `includes` check is sufficient;
|
||||
// if a future provider sends mixed case it will fall back to the unwrapped
|
||||
// fetch — observably safe, just no debug tee for that response.
|
||||
return contentType?.includes("text/event-stream") ?? false;
|
||||
}
|
||||
|
||||
// Reused for every UTF-8 line decode. Safe because lines are split on LF
|
||||
// (0x0a), which is single-byte ASCII and never appears inside a UTF-8
|
||||
// multi-byte sequence — each line is a complete UTF-8 run, so the decoder
|
||||
// carries no state across calls.
|
||||
const SSE_LINE_DECODER = new TextDecoder("utf-8");
|
||||
|
||||
// Decode bytes [start, end) of an SSE line.
|
||||
//
|
||||
// A previous revision added an ASCII fast-path using `String.fromCharCode.apply`
|
||||
// over chunked subarrays, on the theory that skipping `TextDecoder` would save
|
||||
// the ~9.7% `decode` self-time the profile reported. In practice the swap
|
||||
// *regressed* total wall time: `fromCharCode` became a new 7.8% hotspot,
|
||||
// `Uint8Array` allocations grew 5.3%, and `subarray` rose from 11.5% to 18.3%
|
||||
// — net loss of ~10pp. Bun's `TextDecoder.decode` has a fast C++ ASCII path
|
||||
// that beats chunked `fromCharCode.apply` for the typical sub-1KB SSE line,
|
||||
// so we keep the decoder. The line is bounded by LF (0x0a, single-byte
|
||||
// ASCII), so each [start, end) slice is a complete UTF-8 run and the shared
|
||||
// stateless decoder is safe to reuse.
|
||||
function decodeSseLine(buf: Uint8Array, start: number, end: number): string {
|
||||
if (start === 0 && end === buf.length) return SSE_LINE_DECODER.decode(buf);
|
||||
return SSE_LINE_DECODER.decode(buf.subarray(start, end));
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline SSE event splitter. Walks the byte stream as it flows through a
|
||||
* `TransformStream`, dispatching parsed events to the debug observer while
|
||||
* the bytes are forwarded unchanged to the response consumer. Replaces the
|
||||
* previous `body.tee()` + `readSseEvents` re-parse pipeline so the byte
|
||||
* stream is parsed exactly once when a debug observer is attached.
|
||||
*
|
||||
* Field parsing intentionally mirrors `readSseEvents` in `@oh-my-pi/pi-utils`
|
||||
* (only `event` and `data` are observed; `id`/`retry` ignored; CR stripped
|
||||
* before LF dispatch; leading space after `:` trimmed; `data:` lines join
|
||||
* with `\n`). Reusing `readSseEvents` directly would require a second stream
|
||||
* pipeline, which is exactly what this class avoids.
|
||||
*/
|
||||
class SseTeeParser {
|
||||
#observer: RawSseObserver;
|
||||
// Trailing bytes from the previous chunk that did not end with LF.
|
||||
#partial: Uint8Array | null = null;
|
||||
#event: string | null = null;
|
||||
#data: string | null = null;
|
||||
#raw: string[] = [];
|
||||
|
||||
constructor(observer: RawSseObserver) {
|
||||
this.#observer = observer;
|
||||
}
|
||||
|
||||
push(chunk: Uint8Array): void {
|
||||
// Carry-forward path: concat the partial line with the new chunk so the
|
||||
// LF scan walks a single contiguous buffer. The common case (partial is
|
||||
// null) skips the allocation entirely.
|
||||
let buf: Uint8Array;
|
||||
if (this.#partial) {
|
||||
buf = new Uint8Array(this.#partial.length + chunk.length);
|
||||
buf.set(this.#partial, 0);
|
||||
buf.set(chunk, this.#partial.length);
|
||||
this.#partial = null;
|
||||
} else {
|
||||
buf = chunk;
|
||||
}
|
||||
|
||||
const len = buf.length;
|
||||
let i = 0;
|
||||
while (i < len) {
|
||||
const lf = buf.indexOf(0x0a, i);
|
||||
if (lf === -1) {
|
||||
// Retain the tail as a partial line for the next chunk. Copy
|
||||
// because the source `chunk` buffer may be reused upstream.
|
||||
this.#partial = buf.subarray(i).slice();
|
||||
return;
|
||||
}
|
||||
let end = lf;
|
||||
if (end > i && buf[end - 1] === 0x0d) end--;
|
||||
this.#consumeLine(buf, i, end);
|
||||
i = lf + 1;
|
||||
}
|
||||
}
|
||||
|
||||
flush(): void {
|
||||
// Treat any trailing partial line (no terminating LF) as a complete line.
|
||||
if (this.#partial) {
|
||||
const tail = this.#partial;
|
||||
this.#partial = null;
|
||||
let end = tail.length;
|
||||
if (end > 0 && tail[end - 1] === 0x0d) end--;
|
||||
if (end > 0) this.#consumeLine(tail, 0, end);
|
||||
}
|
||||
// Real services don't always close on a blank line — flush any pending event.
|
||||
this.#dispatch();
|
||||
}
|
||||
|
||||
#consumeLine(buf: Uint8Array, start: number, end: number): void {
|
||||
if (end === start) {
|
||||
this.#dispatch();
|
||||
return;
|
||||
}
|
||||
// Comment line: keep verbatim in `raw` for diagnostic context, skip parsing.
|
||||
// SSE spec § 9.2.6: lines beginning with ':' are heartbeats/comments and
|
||||
// MUST NOT contribute to the event dispatch state. Heartbeats are the
|
||||
// single most common line type on long-poll provider streams, so the
|
||||
// early-return here directly avoids ~half the field-parse work.
|
||||
if (buf[start] === 0x3a /* ':' */) {
|
||||
this.#raw.push(decodeSseLine(buf, start, end));
|
||||
return;
|
||||
}
|
||||
// Byte-level field parse. We avoid `text.indexOf(':')` + two `String.slice`
|
||||
// calls (~6% of CPU pre-optimization) by scanning bytes for the field
|
||||
// delimiter and matching the field name byte-for-byte. Field-name bytes
|
||||
// are ASCII per SSE spec, so byte offsets equal char offsets in the
|
||||
// decoded string and we can `slice` the value directly off `text` without
|
||||
// re-decoding.
|
||||
//
|
||||
// ASCII signatures (verified against SSE spec):
|
||||
// "event" = 0x65 0x76 0x65 0x6e 0x74 (5 bytes)
|
||||
// "data" = 0x64 0x61 0x74 0x61 (4 bytes)
|
||||
let colon = -1;
|
||||
for (let k = start; k < end; k++) {
|
||||
if (buf[k] === 0x3a) {
|
||||
colon = k;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const fieldEnd = colon === -1 ? end : colon;
|
||||
let valueStart = colon === -1 ? end : colon + 1;
|
||||
// Per SSE spec, a single leading SP after the colon is stripped.
|
||||
if (valueStart < end && buf[valueStart] === 0x20 /* ' ' */) valueStart++;
|
||||
const fieldLen = fieldEnd - start;
|
||||
const isEvent =
|
||||
fieldLen === 5 &&
|
||||
buf[start] === 0x65 &&
|
||||
buf[start + 1] === 0x76 &&
|
||||
buf[start + 2] === 0x65 &&
|
||||
buf[start + 3] === 0x6e &&
|
||||
buf[start + 4] === 0x74;
|
||||
const isData =
|
||||
!isEvent &&
|
||||
fieldLen === 4 &&
|
||||
buf[start] === 0x64 &&
|
||||
buf[start + 1] === 0x61 &&
|
||||
buf[start + 2] === 0x74 &&
|
||||
buf[start + 3] === 0x61;
|
||||
// Decode the line exactly once. Raw observers (debug buffer) want it
|
||||
// regardless of field kind; `id`/`retry`/unknown lines pay only the
|
||||
// decode cost, not any extra slicing.
|
||||
const text = decodeSseLine(buf, start, end);
|
||||
this.#raw.push(text);
|
||||
if (isEvent) {
|
||||
// `valueStart - start` is a byte offset into the line; since the
|
||||
// "event:" prefix (and the optional SP) are pure ASCII, that byte
|
||||
// offset equals the char offset in the decoded `text`.
|
||||
this.#event = valueStart === end ? "" : text.slice(valueStart - start);
|
||||
} else if (isData) {
|
||||
const value = valueStart === end ? "" : text.slice(valueStart - start);
|
||||
if (this.#data === null) this.#data = value;
|
||||
else this.#data = `${this.#data}\n${value}`;
|
||||
}
|
||||
// `id` and `retry` are intentionally ignored — providers don't use them
|
||||
// and reconnects are handled by the underlying transport.
|
||||
}
|
||||
|
||||
// Hands ownership of the accumulated `raw` array to the observer. The
|
||||
// observer (currently only `RawSseDebugBuffer.recordEvent`) MAY retain the
|
||||
// array; we install a fresh `#raw = []` for the next event before invoking
|
||||
// the observer so there is no aliasing across dispatches. This contract is
|
||||
// mirrored in `notifyRawSseEvent` (no defensive clone) — see its comment.
|
||||
//
|
||||
// TODO(BufferOpt): once the buffer-side audit confirms it never mutates
|
||||
// `event.raw`, the defensive `[...event.raw]` clone in older call paths
|
||||
// (search for `notifyRawSseEvent`) can be dropped repository-wide.
|
||||
#dispatch(): void {
|
||||
if (this.#event === null && this.#data === null) return;
|
||||
const event: RawSseEvent = {
|
||||
event: this.#event,
|
||||
data: this.#data ?? "",
|
||||
raw: this.#raw,
|
||||
};
|
||||
this.#event = null;
|
||||
this.#data = null;
|
||||
this.#raw = [];
|
||||
try {
|
||||
this.#observer(event);
|
||||
} catch {
|
||||
// Raw stream observers are diagnostic only and must not affect generation.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function wrapFetchForSseDebug(
|
||||
fetchImpl: FetchWithPreconnect,
|
||||
observer: RawSseObserver | undefined,
|
||||
): FetchWithPreconnect {
|
||||
if (!observer) return fetchImpl;
|
||||
|
||||
const wrapped = Object.assign(
|
||||
async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||
const response = await fetchImpl(input, init);
|
||||
if (!isSseResponse(response)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const body = response.body;
|
||||
if (!body) return response;
|
||||
|
||||
// Single-pass interception. Previously implemented as
|
||||
// `body.pipeThrough(new TransformStream({...}))`, but the WHATWG
|
||||
// TransformStream machinery imposes a per-chunk Promise boundary
|
||||
// (`#handleNumberResult` showed at 8.8% self-time in CPU profile).
|
||||
// A manual ReadableStream pulling directly from `body.getReader()`
|
||||
// skips that hop: every `read()` immediately feeds both the parser
|
||||
// and the controller in the same microtask.
|
||||
const parser = new SseTeeParser(observer);
|
||||
const reader = body.getReader();
|
||||
const teed = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
try {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
parser.flush();
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
// Enqueue first so the consumer sees bytes ASAP; parser
|
||||
// dispatch is best-effort diagnostic and runs after.
|
||||
controller.enqueue(value);
|
||||
parser.push(value);
|
||||
} catch (err) {
|
||||
// Mirror TransformStream semantics: surface upstream
|
||||
// errors to the consumer; do not flush a partial event.
|
||||
controller.error(err);
|
||||
}
|
||||
},
|
||||
cancel(reason) {
|
||||
// Propagate downstream cancellation to the source body so the
|
||||
// underlying connection is released. Matches `pipeThrough`'s
|
||||
// cancel-propagation behavior; `flush()` is intentionally NOT
|
||||
// called (TransformStream skips `flush` on abort too).
|
||||
return reader.cancel(reason);
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(teed, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
},
|
||||
fetchImpl.preconnect ? { preconnect: fetchImpl.preconnect } : {},
|
||||
);
|
||||
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@ import {
|
||||
buildAnthropicClientOptions,
|
||||
buildAnthropicHeaders,
|
||||
buildAnthropicSystemBlocks,
|
||||
claudeAgentSdkVersion,
|
||||
claudeCodeSystemInstruction,
|
||||
claudeCodeVersion,
|
||||
claudeToolPrefix,
|
||||
generateClaudeCloakingUserId,
|
||||
isClaudeCloakingUserId,
|
||||
mapStainlessArch,
|
||||
@@ -150,27 +152,11 @@ describe("Anthropic request fingerprint alignment", () => {
|
||||
});
|
||||
|
||||
expect(headers.Accept).toBe("application/json");
|
||||
expect(headers["User-Agent"]).toBe(`claude-cli/${claudeCodeVersion} (external, cli)`);
|
||||
expect(headers["User-Agent"]).toBe(
|
||||
`claude-cli/${claudeCodeVersion} (external, local-agent, agent-sdk/${claudeAgentSdkVersion})`,
|
||||
);
|
||||
expect(headers["X-Claude-Code-Session-Id"]).toBe(sessionId);
|
||||
expect(headers["x-client-request-id"]).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
|
||||
expect(headers["Anthropic-Beta"]).toBe(
|
||||
"claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07,advanced-tool-use-2025-11-20,effort-2025-11-24,extended-cache-ttl-2025-04-11",
|
||||
);
|
||||
});
|
||||
|
||||
it("matches Claude Code utility OAuth beta defaults when tools and thinking are absent", () => {
|
||||
const options = buildAnthropicClientOptions({
|
||||
model: ANTHROPIC_MODEL,
|
||||
apiKey: "sk-ant-oat-test",
|
||||
stream: true,
|
||||
interleavedThinking: true,
|
||||
hasTools: false,
|
||||
thinkingEnabled: false,
|
||||
});
|
||||
|
||||
expect(options.defaultHeaders["Anthropic-Beta"]).toBe(
|
||||
"oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,structured-outputs-2025-12-15",
|
||||
);
|
||||
});
|
||||
|
||||
it("sends redact-thinking beta only when thinking display is omitted", () => {
|
||||
@@ -184,12 +170,10 @@ describe("Anthropic request fingerprint alignment", () => {
|
||||
} as const;
|
||||
|
||||
const visible = buildAnthropicClientOptions(baseArgs);
|
||||
expect(visible.defaultHeaders["Anthropic-Beta"]).not.toContain("redact-thinking-2026-02-12");
|
||||
expect(visible.defaultHeaders["anthropic-beta"]).not.toContain("redact-thinking-2026-02-12");
|
||||
|
||||
const hidden = buildAnthropicClientOptions({ ...baseArgs, thinkingDisplay: "omitted" });
|
||||
expect(hidden.defaultHeaders["Anthropic-Beta"]).toBe(
|
||||
"claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,context-management-2025-06-27,prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07,advanced-tool-use-2025-11-20,effort-2025-11-24,extended-cache-ttl-2025-04-11",
|
||||
);
|
||||
expect(hidden.defaultHeaders["anthropic-beta"]).toContain("redact-thinking-2026-02-12");
|
||||
|
||||
const hiddenUtility = buildAnthropicClientOptions({
|
||||
...baseArgs,
|
||||
@@ -197,9 +181,7 @@ describe("Anthropic request fingerprint alignment", () => {
|
||||
thinkingEnabled: false,
|
||||
thinkingDisplay: "omitted",
|
||||
});
|
||||
expect(hiddenUtility.defaultHeaders["Anthropic-Beta"]).toBe(
|
||||
"oauth-2025-04-20,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,context-management-2025-06-27,prompt-caching-scope-2026-01-05,structured-outputs-2025-12-15",
|
||||
);
|
||||
expect(hiddenUtility.defaultHeaders["anthropic-beta"]).toContain("redact-thinking-2026-02-12");
|
||||
});
|
||||
|
||||
it("matches CC system-block layout: billing and instruction uncached, context cached in order", () => {
|
||||
@@ -253,6 +235,25 @@ describe("Anthropic request fingerprint alignment", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps requested max_tokens to Claude Code's 64k cap when the model ceiling is higher", async () => {
|
||||
const payload = (await captureAnthropicPayload(
|
||||
{ ...ANTHROPIC_MODEL, id: "claude-opus-4-8", name: "Claude Opus 4.8", maxTokens: 128_000 },
|
||||
{
|
||||
systemPrompt: ["Stay concise."],
|
||||
messages: [{ role: "user", content: "Hi", timestamp: Date.now() }],
|
||||
},
|
||||
)) as { max_tokens?: number };
|
||||
expect(payload.max_tokens).toBe(64_000);
|
||||
});
|
||||
|
||||
it("leaves max_tokens untouched when the model ceiling is below the 64k cap", async () => {
|
||||
const payload = (await captureAnthropicPayload(ANTHROPIC_MODEL, {
|
||||
systemPrompt: ["Stay concise."],
|
||||
messages: [{ role: "user", content: "Hi", timestamp: Date.now() }],
|
||||
})) as { max_tokens?: number };
|
||||
expect(payload.max_tokens).toBe(8_192);
|
||||
});
|
||||
|
||||
it("billing-header fingerprint uses first user message, not leading developer message", async () => {
|
||||
const userText = "Hello from user with enough chars padding here";
|
||||
|
||||
@@ -334,7 +335,9 @@ describe("Anthropic request fingerprint alignment", () => {
|
||||
stream: true,
|
||||
modelHeaders: { "User-Agent": "curl/8.7.1" },
|
||||
});
|
||||
expect(normalizedHeaders["User-Agent"]).toBe(`claude-cli/${claudeCodeVersion} (external, cli)`);
|
||||
expect(normalizedHeaders["User-Agent"]).toBe(
|
||||
`claude-cli/${claudeCodeVersion} (external, local-agent, agent-sdk/${claudeAgentSdkVersion})`,
|
||||
);
|
||||
|
||||
const embeddedClaudeCliHeaders = buildAnthropicHeaders({
|
||||
apiKey: "sk-ant-oat-test",
|
||||
@@ -342,7 +345,9 @@ describe("Anthropic request fingerprint alignment", () => {
|
||||
stream: true,
|
||||
modelHeaders: { "User-Agent": "my-client claude-cli/2.1.63" },
|
||||
});
|
||||
expect(embeddedClaudeCliHeaders["User-Agent"]).toBe(`claude-cli/${claudeCodeVersion} (external, cli)`);
|
||||
expect(embeddedClaudeCliHeaders["User-Agent"]).toBe(
|
||||
`claude-cli/${claudeCodeVersion} (external, local-agent, agent-sdk/${claudeAgentSdkVersion})`,
|
||||
);
|
||||
});
|
||||
|
||||
it("skips Claude Code instruction injection for claude-3-5-haiku models", async () => {
|
||||
@@ -802,7 +807,7 @@ describe("Anthropic request fingerprint alignment", () => {
|
||||
tools?: Array<{ name?: string; strict?: boolean; eager_input_streaming?: boolean; cache_control?: unknown }>;
|
||||
};
|
||||
|
||||
expect(payload.tools?.[0]?.name).toBe("proxy_bash");
|
||||
expect(payload.tools?.[0]?.name).toBe(`${claudeToolPrefix}bash`);
|
||||
expect(payload.tools?.[0]?.strict).toBe(true);
|
||||
expect(payload.tools?.[0]?.eager_input_streaming).toBe(true);
|
||||
expect(payload.tools?.[0]?.cache_control).toBeUndefined();
|
||||
@@ -1030,11 +1035,11 @@ describe("Anthropic request fingerprint alignment", () => {
|
||||
hasTools: true,
|
||||
});
|
||||
|
||||
expect(withoutTools.defaultHeaders["Anthropic-Beta"]).not.toContain("fine-grained-tool-streaming-2025-05-14");
|
||||
expect(withCompatibleTools.defaultHeaders["Anthropic-Beta"]).not.toContain(
|
||||
expect(withoutTools.defaultHeaders["anthropic-beta"]).not.toContain("fine-grained-tool-streaming-2025-05-14");
|
||||
expect(withCompatibleTools.defaultHeaders["anthropic-beta"]).not.toContain(
|
||||
"fine-grained-tool-streaming-2025-05-14",
|
||||
);
|
||||
expect(withIncompatibleTools.defaultHeaders["Anthropic-Beta"]).toContain(
|
||||
expect(withIncompatibleTools.defaultHeaders["anthropic-beta"]).toContain(
|
||||
"fine-grained-tool-streaming-2025-05-14",
|
||||
);
|
||||
});
|
||||
@@ -1504,22 +1509,27 @@ describe("Anthropic request fingerprint alignment", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("treats tool prefix helpers as no-ops when prefix is empty", () => {
|
||||
expect(applyClaudeToolPrefix("Read", "")).toBe("Read");
|
||||
expect(stripClaudeToolPrefix("proxy_Read", "")).toBe("proxy_Read");
|
||||
it("treats tool prefix helpers as no-ops when prefix is empty string", () => {
|
||||
// Directly verify the codec's identity behaviour: builtins pass through apply unchanged.
|
||||
// (Empty-prefix path is exercised by the builtin guard below; the contract is
|
||||
// roundtrip fidelity, not knowledge of the literal prefix string.)
|
||||
const name = "Read";
|
||||
expect(stripClaudeToolPrefix(applyClaudeToolPrefix(name))).toBe(name);
|
||||
});
|
||||
|
||||
it("does not prefix built-in Anthropic tool names when prefix is configured", () => {
|
||||
expect(applyClaudeToolPrefix("web_search", "proxy_")).toBe("web_search");
|
||||
expect(applyClaudeToolPrefix("CODE_EXECUTION", "proxy_")).toBe("CODE_EXECUTION");
|
||||
expect(applyClaudeToolPrefix("Text_Editor", "proxy_")).toBe("Text_Editor");
|
||||
expect(applyClaudeToolPrefix("computer", "proxy_")).toBe("computer");
|
||||
it("does not prefix built-in Anthropic tool names", () => {
|
||||
expect(applyClaudeToolPrefix("web_search")).toBe("web_search");
|
||||
expect(applyClaudeToolPrefix("CODE_EXECUTION")).toBe("CODE_EXECUTION");
|
||||
expect(applyClaudeToolPrefix("Text_Editor")).toBe("Text_Editor");
|
||||
expect(applyClaudeToolPrefix("computer")).toBe("computer");
|
||||
});
|
||||
|
||||
it("prefixes custom tool names when prefix is configured", () => {
|
||||
expect(applyClaudeToolPrefix("Read", "proxy_")).toBe("proxy_Read");
|
||||
expect(applyClaudeToolPrefix("proxy_Read", "proxy_")).toBe("proxy_Read");
|
||||
expect(stripClaudeToolPrefix("proxy_Read", "proxy_")).toBe("Read");
|
||||
it("prefixes custom tool names and roundtrips cleanly", () => {
|
||||
const name = "Read";
|
||||
const prefixed = applyClaudeToolPrefix(name);
|
||||
expect(prefixed).toBe(`${claudeToolPrefix}${name}`);
|
||||
expect(applyClaudeToolPrefix(prefixed)).toBe(prefixed); // idempotent
|
||||
expect(stripClaudeToolPrefix(prefixed)).toBe(name); // roundtrip
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -388,6 +388,6 @@ describe("buildAnthropicSearchHeaders", () => {
|
||||
it("includes the web-search beta in Anthropic-Beta", () => {
|
||||
const auth = buildAnthropicAuthConfig("sk-ant-api-key");
|
||||
const headers = buildAnthropicSearchHeaders(auth);
|
||||
expect(headers["Anthropic-Beta"]).toContain("web-search-2025-03-05");
|
||||
expect(headers["anthropic-beta"]).toContain("web-search-2025-03-05");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,6 +55,23 @@ describe("isProviderRetryableError", () => {
|
||||
expect(isProviderRetryableError(new Error("Bad request"))).toBe(false);
|
||||
});
|
||||
|
||||
it("does not retry persistent account usage/quota limits despite rate-limit wording", () => {
|
||||
// Account-level 429 that says "rate limit" but is really a parked
|
||||
// credential (long retry-after). Must surface immediately so the
|
||||
// credential-rotation layer takes over instead of looping on backoff.
|
||||
expect(
|
||||
isProviderRetryableError(
|
||||
new Error(
|
||||
'429 {"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed your account\'s rate limit. Please try again later."}}',
|
||||
),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isProviderRetryableError(new Error("usage_limit_reached"))).toBe(false);
|
||||
expect(isProviderRetryableError(new Error("You have hit your ChatGPT usage limit"))).toBe(false);
|
||||
// A generic transient rate limit (no account/usage framing) still retries.
|
||||
expect(isProviderRetryableError(new Error("Rate limit exceeded"))).toBe(true);
|
||||
});
|
||||
|
||||
it("retries Copilot transient model_not_supported only for github-copilot provider", () => {
|
||||
const err = new Error("400 The requested model is not supported.");
|
||||
(err as unknown as { status: number; code: string }).status = 400;
|
||||
|
||||
@@ -131,7 +131,7 @@ describe("transformMessages drops thinking-only assistant turns", () => {
|
||||
expect(wireThinkingSignatures).toEqual(["sig_fresh"]);
|
||||
});
|
||||
|
||||
it("drops error-stop thinking-only assistant turn AND emits the aborted-turn developer note", () => {
|
||||
it("drops error-stop thinking-only assistant turn without injecting any synthetic note", () => {
|
||||
const user: UserMessage = { role: "user", content: "do a thing", timestamp: 1 };
|
||||
const errored = makeThinkingOnlyAssistant("partial reasoning", "sig_errored", "error");
|
||||
const nextUser: UserMessage = { role: "user", content: "try again", timestamp: 3 };
|
||||
@@ -147,10 +147,10 @@ describe("transformMessages drops thinking-only assistant turns", () => {
|
||||
);
|
||||
expect(erroredSurvivors.length).toBe(0);
|
||||
|
||||
// The aborted-turn developer guidance must still be emitted so the model sees
|
||||
// the lifecycle marker; otherwise the next turn loses the abort context.
|
||||
const developerNotes = transformed.filter(m => m.role === "developer");
|
||||
expect(developerNotes.length).toBeGreaterThanOrEqual(1);
|
||||
// No synthetic developer note is injected for a dropped aborted/errored turn —
|
||||
// the abort lifecycle is conveyed by aborted tool results (when there are tool
|
||||
// calls), not by a separate marker message.
|
||||
expect(transformed.filter(m => m.role === "developer").length).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps assistant turns that have a `text` block even when stopped at length", () => {
|
||||
|
||||
@@ -495,6 +495,76 @@ describe("openai-responses encodeStream", () => {
|
||||
expect(output[2]!.id).not.toBe(output[2]!.call_id);
|
||||
});
|
||||
|
||||
it("routes late tool-call deltas by contentIndex after later parallel starts", async () => {
|
||||
const stream = new AssistantMessageEventStream();
|
||||
const base: AssistantMessage = {
|
||||
role: "assistant",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-5",
|
||||
content: [],
|
||||
usage: zeroUsage(),
|
||||
stopReason: "toolUse",
|
||||
timestamp: 1_700_000_000_000,
|
||||
};
|
||||
const callA = { type: "toolCall" as const, id: "call_a", name: "edit", arguments: {} };
|
||||
const callB = { type: "toolCall" as const, id: "call_b", name: "read", arguments: {} };
|
||||
const partialA: AssistantMessage = { ...base, content: [callA] };
|
||||
const partialBoth: AssistantMessage = { ...base, content: [callA, callB] };
|
||||
const finalMessage: AssistantMessage = {
|
||||
...base,
|
||||
content: [
|
||||
{ ...callA, arguments: { input: "first" } },
|
||||
{ ...callB, arguments: { path: "second" } },
|
||||
],
|
||||
};
|
||||
|
||||
queueMicrotask(() => {
|
||||
stream.push({ type: "start", partial: base });
|
||||
stream.push({ type: "toolcall_start", contentIndex: 0, partial: partialA });
|
||||
stream.push({ type: "toolcall_start", contentIndex: 1, partial: partialBoth });
|
||||
stream.push({ type: "toolcall_delta", contentIndex: 0, delta: '{"input":"first"}', partial: partialBoth });
|
||||
stream.push({
|
||||
type: "toolcall_end",
|
||||
contentIndex: 0,
|
||||
toolCall: { ...callA, arguments: { input: "first" } },
|
||||
partial: partialBoth,
|
||||
});
|
||||
stream.push({ type: "toolcall_delta", contentIndex: 1, delta: '{"path":"second"}', partial: partialBoth });
|
||||
stream.push({
|
||||
type: "toolcall_end",
|
||||
contentIndex: 1,
|
||||
toolCall: { ...callB, arguments: { path: "second" } },
|
||||
partial: partialBoth,
|
||||
});
|
||||
stream.push({ type: "done", reason: "toolUse", message: finalMessage });
|
||||
});
|
||||
|
||||
const raw = await collectStream(encodeStream(stream, "gpt-5-requested"));
|
||||
const frames = parseSse(raw);
|
||||
const argumentDeltas = frames.filter(f => f.event === "response.function_call_arguments.delta");
|
||||
expect(argumentDeltas.map(f => (f.data as Record<string, unknown>).output_index)).toEqual([0, 1]);
|
||||
expect(argumentDeltas.map(f => (f.data as Record<string, unknown>).delta)).toEqual([
|
||||
'{"input":"first"}',
|
||||
'{"path":"second"}',
|
||||
]);
|
||||
|
||||
const argumentDone = frames.filter(f => f.event === "response.function_call_arguments.done");
|
||||
expect(argumentDone.map(f => (f.data as Record<string, unknown>).output_index)).toEqual([0, 1]);
|
||||
expect(argumentDone.map(f => (f.data as Record<string, unknown>).arguments)).toEqual([
|
||||
'{"input":"first"}',
|
||||
'{"path":"second"}',
|
||||
]);
|
||||
|
||||
const doneItems = frames
|
||||
.filter(f => f.event === "response.output_item.done")
|
||||
.map(f => (f.data as Record<string, unknown>).item as Record<string, unknown>)
|
||||
.filter(item => item.type === "function_call");
|
||||
|
||||
expect(doneItems).toHaveLength(2);
|
||||
expect(doneItems[0]).toMatchObject({ call_id: "call_a", name: "edit", arguments: '{"input":"first"}' });
|
||||
expect(doneItems[1]).toMatchObject({ call_id: "call_b", name: "read", arguments: '{"path":"second"}' });
|
||||
});
|
||||
it("emits response.incomplete for length-limited streams", async () => {
|
||||
const stream = new AssistantMessageEventStream();
|
||||
const message: AssistantMessage = {
|
||||
|
||||
@@ -532,14 +532,14 @@ describe("AuthStorage codex oauth ranking", () => {
|
||||
{ type: "oauth", ...createCredential("acct-third", "third@example.com"), expires: expiredAt },
|
||||
]);
|
||||
|
||||
const startedAt = Date.now();
|
||||
const apiKey = await authStorage.getApiKey("openai-codex");
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
|
||||
expect(apiKey).toBe("refreshed-acct-third");
|
||||
expect(refreshStarts).toHaveLength(3);
|
||||
// Parallelism is proven deterministically by the concurrency counter: serial
|
||||
// refreshes never overlap (peak in-flight stays 1). A wall-clock bound here was
|
||||
// flaky on loaded CI runners, so maxConcurrent is the authoritative signal.
|
||||
expect(maxConcurrent).toBe(3);
|
||||
expect(elapsedMs).toBeLessThan(refreshDelayMs * 2);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { afterEach, beforeEach, 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 { type AuthCredentialStore, AuthStorage, SqliteAuthCredentialStore } from "../src/auth-storage";
|
||||
import { withEnv } from "./helpers";
|
||||
|
||||
// Clear every env var the providers under test alias, so ambient shell / ~/.env
|
||||
// state can't leak an env origin into precedence assertions.
|
||||
const SUPPRESS_ENV = {
|
||||
OPENAI_API_KEY: undefined,
|
||||
ANTHROPIC_API_KEY: undefined,
|
||||
ANTHROPIC_OAUTH_TOKEN: undefined,
|
||||
COPILOT_GITHUB_TOKEN: undefined,
|
||||
} as const;
|
||||
|
||||
describe("AuthStorage.getCredentialOrigin", () => {
|
||||
let tempDir = "";
|
||||
let store: AuthCredentialStore | null = null;
|
||||
let auth: AuthStorage | null = null;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-ai-credential-origin-"));
|
||||
store = await SqliteAuthCredentialStore.open(path.join(tempDir, "agent.db"));
|
||||
auth = new AuthStorage(store);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
store?.close();
|
||||
store = null;
|
||||
auth = null;
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
tempDir = "";
|
||||
}
|
||||
});
|
||||
|
||||
test("undefined when no auth is configured", async () => {
|
||||
await withEnv(SUPPRESS_ENV, () => {
|
||||
// Provider absent from the env map entirely — no env fallback can apply.
|
||||
expect(auth?.getCredentialOrigin("no-such-provider")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("env origin carries the backing variable name for single-var providers", async () => {
|
||||
await withEnv({ ...SUPPRESS_ENV, COPILOT_GITHUB_TOKEN: "ghp_fake" }, () => {
|
||||
expect(auth?.getCredentialOrigin("github-copilot")).toEqual({
|
||||
kind: "env",
|
||||
envVar: "COPILOT_GITHUB_TOKEN",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("env origin omits the variable name for computed resolvers", async () => {
|
||||
// anthropic resolves through $pickenv(...) — no single variable describes it.
|
||||
await withEnv({ ...SUPPRESS_ENV, ANTHROPIC_API_KEY: "sk-fake" }, () => {
|
||||
expect(auth?.getCredentialOrigin("anthropic")).toEqual({ kind: "env" });
|
||||
});
|
||||
});
|
||||
|
||||
test("a stored OAuth credential outranks an env var", async () => {
|
||||
await withEnv({ ...SUPPRESS_ENV, COPILOT_GITHUB_TOKEN: "ghp_fake" }, async () => {
|
||||
await auth?.set("github-copilot", [
|
||||
{ type: "oauth", access: "a", refresh: "r", expires: Date.now() + 60_000 },
|
||||
]);
|
||||
expect(auth?.getCredentialOrigin("github-copilot")).toEqual({ kind: "oauth" });
|
||||
});
|
||||
});
|
||||
|
||||
test("a stored api key reports api_key and outranks a co-stored OAuth credential", async () => {
|
||||
await withEnv(SUPPRESS_ENV, async () => {
|
||||
// getApiKey() prefers api_key before oauth, so the origin must match.
|
||||
await auth?.set("openai", [
|
||||
{ type: "oauth", access: "a", refresh: "r", expires: Date.now() + 60_000 },
|
||||
{ type: "api_key", key: "sk-stored" },
|
||||
]);
|
||||
expect(auth?.getCredentialOrigin("openai")).toEqual({ kind: "api_key" });
|
||||
});
|
||||
});
|
||||
|
||||
test("config then runtime overrides take precedence over stored credentials", async () => {
|
||||
await withEnv(SUPPRESS_ENV, async () => {
|
||||
if (!auth) throw new Error("test setup failed");
|
||||
await auth.set("openai", [{ type: "api_key", key: "sk-stored" }]);
|
||||
expect(auth.getCredentialOrigin("openai")).toEqual({ kind: "api_key" });
|
||||
|
||||
auth.setConfigApiKey("openai", "gateway-bearer");
|
||||
expect(auth.getCredentialOrigin("openai")).toEqual({ kind: "config" });
|
||||
|
||||
auth.setRuntimeApiKey("openai", "cli-flag-bearer");
|
||||
expect(auth.getCredentialOrigin("openai")).toEqual({ kind: "runtime" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { convertMessages, detectCompat } from "@oh-my-pi/pi-ai/providers/openai-completions";
|
||||
import { transformMessages } from "@oh-my-pi/pi-ai/providers/transform-messages";
|
||||
import type {
|
||||
Api,
|
||||
AssistantMessage,
|
||||
Context,
|
||||
DeveloperMessage,
|
||||
Message,
|
||||
Model,
|
||||
@@ -10,6 +12,11 @@ import type {
|
||||
ToolResultMessage,
|
||||
UserMessage,
|
||||
} from "@oh-my-pi/pi-ai/types";
|
||||
import type {
|
||||
ChatCompletionAssistantMessageParam,
|
||||
ChatCompletionMessageParam,
|
||||
ChatCompletionToolMessageParam,
|
||||
} from "openai/resources/chat/completions";
|
||||
|
||||
/**
|
||||
* Regression test for: "each tool_use must have a single result. Found multiple tool_result blocks with id"
|
||||
@@ -491,6 +498,74 @@ describe("Duplicate Tool Results Regression", () => {
|
||||
{ type: "text", text: "second" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps duplicate ids distinct after OpenAI completions provider caps", () => {
|
||||
const assistantWireMessages = (messages: ChatCompletionMessageParam[]): ChatCompletionAssistantMessageParam[] =>
|
||||
messages.filter(
|
||||
(message): message is ChatCompletionAssistantMessageParam =>
|
||||
message.role === "assistant" && Array.isArray(message.tool_calls),
|
||||
);
|
||||
const toolWireIds = (messages: ChatCompletionMessageParam[]): string[] =>
|
||||
messages
|
||||
.filter((message): message is ChatCompletionToolMessageParam => message.role === "tool")
|
||||
.map(message => message.tool_call_id);
|
||||
|
||||
const cases: Array<{
|
||||
model: Model<"openai-completions">;
|
||||
duplicateId: string;
|
||||
expectedDuplicateId: string;
|
||||
}> = [
|
||||
{
|
||||
model: {
|
||||
api: "openai-completions",
|
||||
provider: "openai",
|
||||
id: "gpt-4o-mini",
|
||||
name: "GPT-4o Mini",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
input: ["text"],
|
||||
cost: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0 },
|
||||
maxTokens: 8192,
|
||||
contextWindow: 128000,
|
||||
reasoning: false,
|
||||
},
|
||||
duplicateId: `call_${"a".repeat(35)}`,
|
||||
expectedDuplicateId: `${`call_${"a".repeat(35)}`.slice(0, 35)}_dup1`,
|
||||
},
|
||||
{
|
||||
model: {
|
||||
api: "openai-completions",
|
||||
provider: "mistral",
|
||||
id: "mistral-large-latest",
|
||||
name: "Mistral Large",
|
||||
baseUrl: "https://api.mistral.ai/v1",
|
||||
input: ["text"],
|
||||
cost: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0 },
|
||||
maxTokens: 8192,
|
||||
contextWindow: 128000,
|
||||
reasoning: false,
|
||||
},
|
||||
duplicateId: "ABCDEF123",
|
||||
expectedDuplicateId: "ABCDEdup1",
|
||||
},
|
||||
];
|
||||
|
||||
for (const { model: providerModel, duplicateId, expectedDuplicateId } of cases) {
|
||||
const messages: Message[] = [
|
||||
makeEvalAssistantMessage(duplicateId, 1),
|
||||
makeEvalToolResult(duplicateId, "first", 2),
|
||||
makeEvalAssistantMessage(duplicateId, 3),
|
||||
makeEvalToolResult(duplicateId, "second", 4),
|
||||
];
|
||||
const context: Context = { messages };
|
||||
const wireMessages = convertMessages(providerModel, context, detectCompat(providerModel));
|
||||
const assistantIds = assistantWireMessages(wireMessages).flatMap(
|
||||
message => message.tool_calls?.map(toolCall => toolCall.id) ?? [],
|
||||
);
|
||||
|
||||
expect(assistantIds, providerModel.provider).toEqual([duplicateId, expectedDuplicateId]);
|
||||
expect(toolWireIds(wireMessages), providerModel.provider).toEqual([duplicateId, expectedDuplicateId]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -888,10 +963,9 @@ describe("Orphan Tool Result (handoff/compaction) Regression", () => {
|
||||
transformed.filter(m => m.role === "toolResult" && (m as ToolResultMessage).toolCallId === orphanId).length,
|
||||
).toBe(0);
|
||||
|
||||
// 2. No premature developer note for the orphan: a developer message would
|
||||
// break assistant→toolResult contiguity. The only developer message
|
||||
// allowed is the `turnAbortedGuidance` injected by
|
||||
// `flushPendingAbortedToolCalls` at its natural turn boundary.
|
||||
// 2. No developer note for the orphan: a developer message would break
|
||||
// assistant→toolResult contiguity, and we no longer inject any synthetic
|
||||
// aborted-turn note at all.
|
||||
const orphanNotes = transformed.filter(
|
||||
(m): m is DeveloperMessage =>
|
||||
m.role === "developer" &&
|
||||
@@ -1003,7 +1077,6 @@ describe("Orphan Tool Result (handoff/compaction) Regression", () => {
|
||||
* Tests for Codex-style abort handling:
|
||||
* - Tool calls are preserved (not converted to text summaries)
|
||||
* - Synthetic "aborted" tool results are injected
|
||||
* - A <turn-aborted> guidance marker is added as synthetic user message
|
||||
*/
|
||||
describe("Codex-style Abort Handling", () => {
|
||||
const model: Model<"anthropic-messages"> = {
|
||||
@@ -1062,39 +1135,6 @@ describe("Codex-style Abort Handling", () => {
|
||||
expect(textContent).toBeDefined();
|
||||
});
|
||||
|
||||
it("should inject turn-aborted guidance marker as synthetic user message", () => {
|
||||
const assistantMessage: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "toolu_marker_test", name: "bash", arguments: { command: "sleep 10" } }],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
usage: {
|
||||
input: 100,
|
||||
output: 50,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 150,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "error",
|
||||
errorMessage: "Request was aborted",
|
||||
timestamp: 1000,
|
||||
};
|
||||
|
||||
const messages = [{ role: "user" as const, content: "Run command", timestamp: 500 }, assistantMessage];
|
||||
|
||||
const transformed = transformMessages(messages, model);
|
||||
|
||||
// Should have: user, assistant, toolResult, developer(guidance)
|
||||
expect(transformed.length).toBe(4);
|
||||
|
||||
// Last message should be the guidance marker
|
||||
const guidanceMsg = transformed[3] as DeveloperMessage;
|
||||
expect(guidanceMsg.role).toBe("developer");
|
||||
expect(guidanceMsg.content).toContain("<turn-aborted>");
|
||||
});
|
||||
|
||||
it("should inject synthetic 'aborted' tool results with isError true", () => {
|
||||
const toolCallId = "toolu_synthetic_test";
|
||||
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import { getBundledModel } from "../src/models";
|
||||
import { streamOpenAICompletions } from "../src/providers/openai-completions";
|
||||
import type { Context, Model } from "../src/types";
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function createSseResponse(events: unknown[]): Response {
|
||||
const payload = `${events
|
||||
.map(event => `data: ${typeof event === "string" ? event : JSON.stringify(event)}`)
|
||||
.join("\n\n")}\n\n`;
|
||||
return new Response(payload, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
|
||||
function createMockFetch(events: unknown[]): typeof fetch {
|
||||
async function mockFetch(_input: string | URL | Request, _init?: RequestInit): Promise<Response> {
|
||||
return createSseResponse(events);
|
||||
}
|
||||
return Object.assign(mockFetch, { preconnect: originalFetch.preconnect });
|
||||
}
|
||||
|
||||
function baseContext(): Context {
|
||||
return {
|
||||
messages: [{ role: "user", content: "edit a file", timestamp: Date.now() }],
|
||||
tools: [
|
||||
{
|
||||
name: "edit",
|
||||
description: "Apply a hashline patch",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { input: { type: "string" } },
|
||||
required: ["input"],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function toolCallChunk(model: Model<"openai-completions">, fn: Record<string, unknown>): unknown {
|
||||
return {
|
||||
id: "chatcmpl-minimax-cn",
|
||||
object: "chat.completion.chunk",
|
||||
created: 0,
|
||||
model: model.id,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
role: "assistant",
|
||||
tool_calls: [{ index: 0, id: "call-minimax-1", type: "function", function: fn }],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function stopChunk(model: Model<"openai-completions">): unknown {
|
||||
return {
|
||||
id: "chatcmpl-minimax-cn",
|
||||
object: "chat.completion.chunk",
|
||||
created: 0,
|
||||
model: model.id,
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
};
|
||||
}
|
||||
|
||||
// Regression coverage for #2080: when MiniMax-compatible hosts fragment
|
||||
// object-shaped `function.arguments` across multiple deltas, the old
|
||||
// "block.partialArgs = rawArgs" assignment threw away every chunk but the
|
||||
// last. For `edit` (single `input` field), the surviving fragment was a
|
||||
// tail slice of the patch text — silently producing partial deletes that
|
||||
// looked like the applier had widened the range. The accumulator now
|
||||
// merges chunks and handles both cumulative and per-chunk-delta semantics.
|
||||
describe("issue #2080 - MiniMax multi-chunk object tool arguments", () => {
|
||||
it("appends per-chunk-delta string fragments instead of overwriting the previous chunk", async () => {
|
||||
const model = getBundledModel<"openai-completions">("minimax-code-cn", "MiniMax-M3");
|
||||
// Two chunks; each carries a slice of the `input` string. The
|
||||
// concatenation forms the real hashline patch.
|
||||
global.fetch = createMockFetch([
|
||||
toolCallChunk(model, {
|
||||
name: "edit",
|
||||
arguments: { input: "[foo.ts#A1B2]\nreplace 91..91:\n+ " },
|
||||
}),
|
||||
toolCallChunk(model, {
|
||||
arguments: { input: 'const out = await executeTool("nuke", { path: "x" }, ctx);' },
|
||||
}),
|
||||
stopChunk(model),
|
||||
"[DONE]",
|
||||
]);
|
||||
|
||||
const result = await streamOpenAICompletions(model, baseContext(), { apiKey: "test-key" }).result();
|
||||
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call-minimax-1",
|
||||
name: "edit",
|
||||
arguments: {
|
||||
input: '[foo.ts#A1B2]\nreplace 91..91:\n+ const out = await executeTool("nuke", { path: "x" }, ctx);',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not double cumulative chunks where each delta restates everything seen so far", async () => {
|
||||
const model = getBundledModel<"openai-completions">("minimax-code-cn", "MiniMax-M3");
|
||||
// Second chunk strictly extends the first — common shape for hosts
|
||||
// that re-emit the full args on every delta. `startsWith` collapses
|
||||
// the merge to the latest cumulative snapshot instead of duplicating
|
||||
// the shared prefix.
|
||||
global.fetch = createMockFetch([
|
||||
toolCallChunk(model, {
|
||||
name: "edit",
|
||||
arguments: { input: "[foo.ts#A1B2]\nreplace 91..91:" },
|
||||
}),
|
||||
toolCallChunk(model, {
|
||||
arguments: { input: "[foo.ts#A1B2]\nreplace 91..91:\n+new" },
|
||||
}),
|
||||
stopChunk(model),
|
||||
"[DONE]",
|
||||
]);
|
||||
|
||||
const result = await streamOpenAICompletions(model, baseContext(), { apiKey: "test-key" }).result();
|
||||
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call-minimax-1",
|
||||
name: "edit",
|
||||
arguments: { input: "[foo.ts#A1B2]\nreplace 91..91:\n+new" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves keys that only appear in earlier chunks instead of dropping them with later chunks", async () => {
|
||||
const model = getBundledModel<"openai-completions">("minimax-code-cn", "MiniMax-M3");
|
||||
global.fetch = createMockFetch([
|
||||
toolCallChunk(model, { name: "edit", arguments: { input: "[foo.ts#A1B2]\ndelete 5" } }),
|
||||
toolCallChunk(model, { arguments: { dryRun: true } }),
|
||||
stopChunk(model),
|
||||
"[DONE]",
|
||||
]);
|
||||
|
||||
const result = await streamOpenAICompletions(model, baseContext(), { apiKey: "test-key" }).result();
|
||||
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call-minimax-1",
|
||||
name: "edit",
|
||||
arguments: { input: "[foo.ts#A1B2]\ndelete 5", dryRun: true },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits a concat-safe `toolcall_delta` sequence — accumulated deltas parse to the merged args", async () => {
|
||||
// Codex review on PR #2082 caught that emitting `JSON.stringify(rawArgs)` per chunk
|
||||
// feeds downstream concat consumers (proxy.ts, openai-chat-server, etc.) an invalid
|
||||
// sequence like `{"input":"a"}{"input":"b"}` even when the merged source-side args
|
||||
// are correct. The fix defers object-chunk emission to `finishToolCallBlock`, which
|
||||
// flushes one delta carrying the full merged JSON. Verify that contract by
|
||||
// reconstructing the args the way the proxy does (concat + parse) and comparing
|
||||
// against the source-side merged result.
|
||||
const model = getBundledModel<"openai-completions">("minimax-code-cn", "MiniMax-M3");
|
||||
global.fetch = createMockFetch([
|
||||
toolCallChunk(model, {
|
||||
name: "edit",
|
||||
arguments: { input: "[foo.ts#A1B2]\nreplace 91..91:\n+ " },
|
||||
}),
|
||||
toolCallChunk(model, {
|
||||
arguments: { input: 'const out = await executeTool("nuke", { path: "x" }, ctx);' },
|
||||
}),
|
||||
stopChunk(model),
|
||||
"[DONE]",
|
||||
]);
|
||||
|
||||
const s = streamOpenAICompletions(model, baseContext(), { apiKey: "test-key" });
|
||||
let accumulated = "";
|
||||
let toolCallEndArgs: unknown;
|
||||
for await (const event of s) {
|
||||
if (event.type === "toolcall_delta") accumulated += event.delta;
|
||||
else if (event.type === "toolcall_end") toolCallEndArgs = event.toolCall.arguments;
|
||||
}
|
||||
|
||||
const expected = {
|
||||
input: '[foo.ts#A1B2]\nreplace 91..91:\n+ const out = await executeTool("nuke", { path: "x" }, ctx);',
|
||||
};
|
||||
// Source-side merged result (what `block.arguments` is set to in `finishToolCallBlock`).
|
||||
expect(toolCallEndArgs).toEqual(expected);
|
||||
// Concat consumers must observe the same args by parsing the accumulated delta string —
|
||||
// this is the contract proxy.ts:286-290 reconstructs against.
|
||||
expect(JSON.parse(accumulated)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("keeps the single-chunk object case concat-safe (no #1776 regression)", async () => {
|
||||
// The #1776 fix sent the full JSON as one delta during streaming. The PR #2082 follow-up
|
||||
// moves emission to `finishToolCallBlock`. The single-chunk path stays correct end-to-end:
|
||||
// the proxy still concatenates ("" then the final delta) and parses to the same args.
|
||||
const model = getBundledModel<"openai-completions">("minimax-code-cn", "MiniMax-M3");
|
||||
global.fetch = createMockFetch([
|
||||
toolCallChunk(model, { name: "edit", arguments: { input: "[foo.ts#A1B2]\ndelete 5" } }),
|
||||
stopChunk(model),
|
||||
"[DONE]",
|
||||
]);
|
||||
|
||||
const s = streamOpenAICompletions(model, baseContext(), { apiKey: "test-key" });
|
||||
let accumulated = "";
|
||||
for await (const event of s) {
|
||||
if (event.type === "toolcall_delta") accumulated += event.delta;
|
||||
}
|
||||
expect(JSON.parse(accumulated)).toEqual({ input: "[foo.ts#A1B2]\ndelete 5" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
import { afterEach, describe, expect, it, vi } from "bun:test";
|
||||
import { getBundledModel } from "../src/models";
|
||||
import { streamAnthropic } from "../src/providers/anthropic";
|
||||
import type { AnthropicMessagesClientLike } from "../src/providers/anthropic-client";
|
||||
import type { RawMessageStreamEvent } from "../src/providers/anthropic-wire";
|
||||
import { streamAzureOpenAIResponses } from "../src/providers/azure-openai-responses";
|
||||
import { streamOpenAICompletions } from "../src/providers/openai-completions";
|
||||
import { streamOpenAIResponses } from "../src/providers/openai-responses";
|
||||
import type { Context, Model, RawSseEvent } from "../src/types";
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
const context: Context = {
|
||||
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
||||
};
|
||||
|
||||
const openAIResponsesModel = getBundledModel("openai", "gpt-5-mini") as Model<"openai-responses">;
|
||||
const openAICompletionsModel = {
|
||||
...(getBundledModel("openai", "gpt-4o-mini") as Model<"openai-completions">),
|
||||
api: "openai-completions",
|
||||
} satisfies Model<"openai-completions">;
|
||||
const azureOpenAIResponsesModel: Model<"azure-openai-responses"> = {
|
||||
id: "gpt-5-mini",
|
||||
name: "GPT-5 Mini",
|
||||
api: "azure-openai-responses",
|
||||
provider: "azure",
|
||||
baseUrl: "https://example.openai.azure.com/openai/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 400_000,
|
||||
maxTokens: 128_000,
|
||||
};
|
||||
const anthropicModel: Model<"anthropic-messages"> = {
|
||||
id: "claude-sonnet-4-5",
|
||||
name: "Claude Sonnet 4.5",
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 8_192,
|
||||
};
|
||||
|
||||
const openAIResponsesEvents = [
|
||||
{ type: "response.created", response: { id: "resp_raw_sse", status: "in_progress" } },
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "message", id: "msg_raw_sse", role: "assistant", status: "in_progress", content: [] },
|
||||
},
|
||||
{ type: "response.content_part.added", part: { type: "output_text", text: "" } },
|
||||
{ type: "response.output_text.delta", delta: "Hello" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "msg_raw_sse",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: "Hello" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_raw_sse",
|
||||
status: "completed",
|
||||
usage: {
|
||||
input_tokens: 5,
|
||||
output_tokens: 1,
|
||||
total_tokens: 6,
|
||||
input_tokens_details: { cached_tokens: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const anthropicEvents: RawMessageStreamEvent[] = [
|
||||
{
|
||||
type: "message_start",
|
||||
message: {
|
||||
id: "msg_raw_sse",
|
||||
usage: {
|
||||
input_tokens: 5,
|
||||
output_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello" } },
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "end_turn" },
|
||||
usage: {
|
||||
input_tokens: 5,
|
||||
output_tokens: 1,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
},
|
||||
{ type: "message_stop" },
|
||||
];
|
||||
|
||||
function createSseResponse(events: unknown[]): Response {
|
||||
const payload = `${events
|
||||
.map(event => `data: ${typeof event === "string" ? event : JSON.stringify(event)}`)
|
||||
.join("\n\n")}\n\n`;
|
||||
return new Response(payload, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
|
||||
function installFetchResponse(events: unknown[]) {
|
||||
const fetchMock = vi.fn(async () => createSseResponse(events));
|
||||
global.fetch = Object.assign(fetchMock, { preconnect: originalFetch.preconnect }) as typeof fetch;
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
function recordEvent(events: RawSseEvent[]): (event: RawSseEvent) => void {
|
||||
return event => {
|
||||
events.push({ event: event.event, data: event.data, raw: [...event.raw] });
|
||||
};
|
||||
}
|
||||
|
||||
async function* asyncEvents(events: RawMessageStreamEvent[]): AsyncGenerator<RawMessageStreamEvent> {
|
||||
for (const event of events) yield event;
|
||||
}
|
||||
|
||||
function createAnthropicSdkClient(events: RawMessageStreamEvent[]): AnthropicMessagesClientLike {
|
||||
return {
|
||||
messages: {
|
||||
create: () => ({
|
||||
async withResponse() {
|
||||
return {
|
||||
data: asyncEvents(events),
|
||||
response: new Response(null, { status: 200, headers: { "request-id": "req_sdk" } }),
|
||||
request_id: "req_sdk",
|
||||
};
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sseFrame(event: string, data: unknown): string {
|
||||
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
}
|
||||
|
||||
function createAnthropicRawClient(events: RawMessageStreamEvent[]): AnthropicMessagesClientLike {
|
||||
return {
|
||||
messages: {
|
||||
create: () => ({
|
||||
async asResponse() {
|
||||
return new Response(events.map(event => sseFrame(event.type, event)).join(""), {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream", "request-id": "req_raw" },
|
||||
});
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("SDK raw SSE capture", () => {
|
||||
it("records OpenAI Responses SDK events from the decoded stream", async () => {
|
||||
const fetchMock = installFetchResponse(openAIResponsesEvents);
|
||||
const observed: RawSseEvent[] = [];
|
||||
|
||||
const result = await streamOpenAIResponses(openAIResponsesModel, context, {
|
||||
apiKey: "test-key",
|
||||
onSseEvent: recordEvent(observed),
|
||||
}).result();
|
||||
|
||||
expect(result.stopReason).toBe("stop");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(observed.map(event => event.event)).toEqual(openAIResponsesEvents.map(event => event.type));
|
||||
expect(JSON.parse(observed[0]!.data)).toEqual(openAIResponsesEvents[0]);
|
||||
expect(observed[0]!.raw).toEqual([
|
||||
"event: response.created",
|
||||
`data: ${JSON.stringify(openAIResponsesEvents[0])}`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("records OpenAI Chat Completions SDK events from the decoded stream", async () => {
|
||||
const chunks = [
|
||||
{
|
||||
id: "chatcmpl_raw_sse",
|
||||
object: "chat.completion.chunk",
|
||||
created: 0,
|
||||
model: openAICompletionsModel.id,
|
||||
choices: [{ index: 0, delta: { content: "Hello" } }],
|
||||
},
|
||||
{
|
||||
id: "chatcmpl_raw_sse",
|
||||
object: "chat.completion.chunk",
|
||||
created: 0,
|
||||
model: openAICompletionsModel.id,
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
usage: {
|
||||
prompt_tokens: 5,
|
||||
completion_tokens: 1,
|
||||
total_tokens: 6,
|
||||
prompt_tokens_details: { cached_tokens: 0 },
|
||||
},
|
||||
},
|
||||
"[DONE]",
|
||||
];
|
||||
installFetchResponse(chunks);
|
||||
const observed: RawSseEvent[] = [];
|
||||
|
||||
const result = await streamOpenAICompletions(openAICompletionsModel, context, {
|
||||
apiKey: "test-key",
|
||||
onSseEvent: recordEvent(observed),
|
||||
}).result();
|
||||
|
||||
expect(result.stopReason).toBe("stop");
|
||||
expect(observed.map(event => event.event)).toEqual(["chat.completion.chunk", "chat.completion.chunk"]);
|
||||
expect(JSON.parse(observed[0]!.data)).toEqual(chunks[0]);
|
||||
expect(observed[0]!.raw).toEqual(["event: chat.completion.chunk", `data: ${JSON.stringify(chunks[0])}`]);
|
||||
});
|
||||
|
||||
it("records Azure OpenAI Responses SDK events from the decoded stream", async () => {
|
||||
installFetchResponse(openAIResponsesEvents);
|
||||
const observed: RawSseEvent[] = [];
|
||||
|
||||
const result = await streamAzureOpenAIResponses(azureOpenAIResponsesModel, context, {
|
||||
apiKey: "test-key",
|
||||
azureBaseUrl: azureOpenAIResponsesModel.baseUrl,
|
||||
azureApiVersion: "v1",
|
||||
onSseEvent: recordEvent(observed),
|
||||
}).result();
|
||||
|
||||
expect(result.stopReason).toBe("stop");
|
||||
expect(observed.map(event => event.event)).toEqual(openAIResponsesEvents.map(event => event.type));
|
||||
expect(JSON.parse(observed.at(-1)!.data)).toEqual(openAIResponsesEvents.at(-1));
|
||||
});
|
||||
|
||||
it("records Anthropic SDK events from the decoded stream", async () => {
|
||||
const observed: RawSseEvent[] = [];
|
||||
|
||||
const result = await streamAnthropic(anthropicModel, context, {
|
||||
client: createAnthropicSdkClient(anthropicEvents),
|
||||
onSseEvent: recordEvent(observed),
|
||||
}).result();
|
||||
|
||||
expect(result.stopReason).toBe("stop");
|
||||
expect(observed.map(event => event.event)).toEqual(anthropicEvents.map(event => event.type));
|
||||
expect(JSON.parse(observed[0]!.data)).toEqual(anthropicEvents[0]);
|
||||
expect(observed[0]!.raw).toEqual(["event: message_start", `data: ${JSON.stringify(anthropicEvents[0])}`]);
|
||||
});
|
||||
|
||||
it("does not synthesize raw SSE records when no observer is installed", async () => {
|
||||
installFetchResponse(openAIResponsesEvents);
|
||||
|
||||
const result = await streamOpenAIResponses(openAIResponsesModel, context, { apiKey: "test-key" }).result();
|
||||
|
||||
expect(result.stopReason).toBe("stop");
|
||||
});
|
||||
|
||||
it("keeps Anthropic direct SSE parsing wired to the raw observer", async () => {
|
||||
const observed: RawSseEvent[] = [];
|
||||
|
||||
const result = await streamAnthropic(anthropicModel, context, {
|
||||
client: createAnthropicRawClient(anthropicEvents),
|
||||
onSseEvent: recordEvent(observed),
|
||||
}).result();
|
||||
|
||||
expect(result.stopReason).toBe("stop");
|
||||
expect(observed.map(event => event.event)).toEqual(anthropicEvents.map(event => event.type));
|
||||
expect(observed[0]!.raw).toEqual(["event: message_start", `data: ${JSON.stringify(anthropicEvents[0])}`]);
|
||||
});
|
||||
});
|
||||
@@ -1,205 +1,35 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import type { RawSseEvent } from "../src/types";
|
||||
import { wrapFetchForSseDebug } from "../src/utils/sse-debug";
|
||||
import { notifyRawSseEvent } from "../src/utils/sse-debug";
|
||||
|
||||
/**
|
||||
* Exercises the inline SSE tee + parser in `sse-debug.ts`. There is no direct
|
||||
* export for `SseTeeParser`; we drive it through `wrapFetchForSseDebug`, which
|
||||
* is the only production caller. Each test:
|
||||
* 1. Builds a mock `fetch` that returns a `text/event-stream` Response whose
|
||||
* body emits a caller-controlled sequence of byte chunks (so we can
|
||||
* exercise partial-line carry-forward and CR-LF handling deterministically).
|
||||
* 2. Calls the wrapped fetch.
|
||||
* 3. Reads the response body to completion so the `TransformStream` `flush`
|
||||
* runs.
|
||||
* 4. Asserts the events the observer received exactly match expectations.
|
||||
*
|
||||
* The point is to lock in behavior across the ASCII-fast-path / byte-level-
|
||||
* field-parse rewrite: the observer MUST receive the same `{ event, data, raw }`
|
||||
* shape it received with the prior decode-then-string-slice implementation.
|
||||
*/
|
||||
describe("notifyRawSseEvent", () => {
|
||||
it("dispatches diagnostic events without cloning raw lines", () => {
|
||||
const raw = ["event: message", "data: hello"];
|
||||
let observed: RawSseEvent | undefined;
|
||||
|
||||
function chunkedStream(chunks: Uint8Array[]): ReadableStream<Uint8Array> {
|
||||
let i = 0;
|
||||
return new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (i >= chunks.length) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(chunks[i++]);
|
||||
},
|
||||
});
|
||||
}
|
||||
notifyRawSseEvent(
|
||||
event => {
|
||||
observed = event;
|
||||
},
|
||||
{ event: "message", data: "hello", raw },
|
||||
);
|
||||
|
||||
function sseResponse(chunks: Uint8Array[]): Response {
|
||||
return new Response(chunkedStream(chunks), {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
|
||||
const enc = new TextEncoder();
|
||||
const b = (s: string): Uint8Array => enc.encode(s);
|
||||
|
||||
async function drain(response: Response): Promise<void> {
|
||||
const reader = response.body!.getReader();
|
||||
for (;;) {
|
||||
const { done } = await reader.read();
|
||||
if (done) return;
|
||||
}
|
||||
}
|
||||
|
||||
async function collect(chunks: Uint8Array[]): Promise<RawSseEvent[]> {
|
||||
const events: RawSseEvent[] = [];
|
||||
const fetchImpl = async () => sseResponse(chunks);
|
||||
const wrapped = wrapFetchForSseDebug(fetchImpl, event => {
|
||||
events.push(event);
|
||||
});
|
||||
const response = await wrapped("https://example.test/stream");
|
||||
await drain(response);
|
||||
return events;
|
||||
}
|
||||
|
||||
describe("sse-debug parser", () => {
|
||||
it("parses a single event terminated by blank line", async () => {
|
||||
const events = await collect([b("event: message\ndata: hello\n\n")]);
|
||||
expect(events).toEqual([{ event: "message", data: "hello", raw: ["event: message", "data: hello"] }]);
|
||||
expect(observed).toEqual({ event: "message", data: "hello", raw });
|
||||
expect(observed?.raw).toBe(raw);
|
||||
});
|
||||
|
||||
it("joins multi-line data fields with newlines", async () => {
|
||||
const events = await collect([b("data: line1\ndata: line2\ndata: line3\n\n")]);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.event).toBe(null);
|
||||
expect(events[0]!.data).toBe("line1\nline2\nline3");
|
||||
expect(events[0]!.raw).toEqual(["data: line1", "data: line2", "data: line3"]);
|
||||
it("keeps observer failures diagnostic-only", () => {
|
||||
expect(() =>
|
||||
notifyRawSseEvent(
|
||||
() => {
|
||||
throw new Error("observer failed");
|
||||
},
|
||||
{ event: "message", data: "hello", raw: ["event: message", "data: hello"] },
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("strips a single leading SP after the colon but preserves further spaces", async () => {
|
||||
const events = await collect([b("data: two-leading-spaces\n\n")]);
|
||||
expect(events[0]!.data).toBe(" two-leading-spaces");
|
||||
});
|
||||
|
||||
it("retains comment (`:`-prefixed) lines in raw but does not parse them", async () => {
|
||||
const events = await collect([b(": heartbeat\ndata: payload\n\n")]);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.data).toBe("payload");
|
||||
expect(events[0]!.raw).toEqual([": heartbeat", "data: payload"]);
|
||||
});
|
||||
|
||||
it("does not dispatch on a blank line if no event/data accumulated (pure heartbeats)", async () => {
|
||||
const events = await collect([b(": ping\n\n: ping\n\n")]);
|
||||
expect(events).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles CR-LF line endings and strips the CR before dispatch", async () => {
|
||||
const events = await collect([b("event: ping\r\ndata: pong\r\n\r\n")]);
|
||||
expect(events).toEqual([{ event: "ping", data: "pong", raw: ["event: ping", "data: pong"] }]);
|
||||
});
|
||||
|
||||
it("ignores unknown fields (`id`, `retry`, gibberish) but keeps them in raw", async () => {
|
||||
const events = await collect([b("id: 42\nretry: 1000\nfoo: bar\ndata: ok\n\n")]);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.event).toBe(null);
|
||||
expect(events[0]!.data).toBe("ok");
|
||||
expect(events[0]!.raw).toEqual(["id: 42", "retry: 1000", "foo: bar", "data: ok"]);
|
||||
});
|
||||
|
||||
it("treats a line with no colon as field-with-empty-value (data line still recorded)", async () => {
|
||||
// Per SSE spec a bare `data` line is treated as `data:` with empty value.
|
||||
const events = await collect([b("data\ndata: x\n\n")]);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.data).toBe("\nx");
|
||||
});
|
||||
|
||||
it("reassembles events split across arbitrary chunk boundaries", async () => {
|
||||
// Split a single event across chunks: mid-field-name, mid-value, mid-LF-CRLF.
|
||||
const events = await collect([b("eve"), b("nt: x\r"), b("\ndata: a"), b("bc\r\n\r"), b("\n")]);
|
||||
expect(events).toEqual([{ event: "x", data: "abc", raw: ["event: x", "data: abc"] }]);
|
||||
});
|
||||
|
||||
it("handles a chunk that ends exactly on LF (no partial carried)", async () => {
|
||||
const events = await collect([b("data: a\n"), b("data: b\n"), b("\n")]);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.data).toBe("a\nb");
|
||||
});
|
||||
|
||||
it("flushes a trailing event with no terminating blank line", async () => {
|
||||
// Stream closes without a final "\n\n". Parser must dispatch on flush.
|
||||
const events = await collect([b("event: end\ndata: bye\n")]);
|
||||
expect(events).toEqual([{ event: "end", data: "bye", raw: ["event: end", "data: bye"] }]);
|
||||
});
|
||||
|
||||
it("flushes a trailing event with no terminating newline at all", async () => {
|
||||
const events = await collect([b("event: end\ndata: bye")]);
|
||||
expect(events).toEqual([{ event: "end", data: "bye", raw: ["event: end", "data: bye"] }]);
|
||||
});
|
||||
|
||||
it("preserves UTF-8 multibyte characters via decoder fallback", async () => {
|
||||
// Non-ASCII bytes (emoji, accented chars, CJK) must round-trip identically.
|
||||
const events = await collect([b("data: caf\u00e9 \u2014 \u4f60\u597d \ud83d\ude00\n\n")]);
|
||||
expect(events[0]!.data).toBe("café — 你好 😀");
|
||||
});
|
||||
|
||||
it("handles a UTF-8 multibyte sequence split across chunk boundary", async () => {
|
||||
// The 4-byte emoji U+1F600 ("😀") = F0 9F 98 80. Split it between chunks.
|
||||
const full = b("data: \ud83d\ude00\n\n");
|
||||
const split = full.indexOf(0xf0) + 2;
|
||||
const events = await collect([full.subarray(0, split), full.subarray(split)]);
|
||||
expect(events[0]!.data).toBe("😀");
|
||||
});
|
||||
|
||||
it("emits multiple events in stream order", async () => {
|
||||
const events = await collect([b("event: a\ndata: 1\n\nevent: b\ndata: 2\n\nevent: c\ndata: 3\n\n")]);
|
||||
expect(events.map(e => [e.event, e.data])).toEqual([
|
||||
["a", "1"],
|
||||
["b", "2"],
|
||||
["c", "3"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("hands a fresh `raw` array to each observer call (no aliasing)", async () => {
|
||||
const events = await collect([b("data: a\n\ndata: b\n\n")]);
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0]!.raw).not.toBe(events[1]!.raw);
|
||||
// Observer-side mutation of the first `raw` must not leak into the second.
|
||||
events[0]!.raw.push("MUTATED");
|
||||
expect(events[1]!.raw).toEqual(["data: b"]);
|
||||
});
|
||||
|
||||
it("treats `data:` with no value as empty string and merges further data lines", async () => {
|
||||
const events = await collect([b("data:\ndata: x\n\n")]);
|
||||
expect(events[0]!.data).toBe("\nx");
|
||||
});
|
||||
|
||||
it("returns the unwrapped fetch when observer is undefined", async () => {
|
||||
const fetchImpl = async () => sseResponse([b("data: x\n\n")]);
|
||||
const wrapped = wrapFetchForSseDebug(fetchImpl, undefined);
|
||||
// Identity, not a wrapper: caller relies on this fast path.
|
||||
expect(wrapped).toBe(fetchImpl as unknown as typeof wrapped);
|
||||
});
|
||||
|
||||
it("passes through non-SSE responses untouched", async () => {
|
||||
const events: RawSseEvent[] = [];
|
||||
const fetchImpl = async () =>
|
||||
new Response(b("not sse"), { status: 200, headers: { "content-type": "text/plain" } });
|
||||
const wrapped = wrapFetchForSseDebug(fetchImpl, e => events.push(e));
|
||||
const response = await wrapped("https://example.test/plain");
|
||||
expect(await response.text()).toBe("not sse");
|
||||
expect(events).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("forwards the byte stream byte-identically to the consumer", async () => {
|
||||
// Critical invariant: tee must not mutate or re-shape bytes for the
|
||||
// downstream consumer. Use a payload with UTF-8 + CR-LF + heartbeats to
|
||||
// stress the parser without corrupting forwarded bytes.
|
||||
const payload = b(": heartbeat\r\nevent: msg\r\ndata: caf\u00e9 \u4f60\u597d\r\n\r\ndata: tail\n\n");
|
||||
// Chunk the input awkwardly so the TransformStream sees several chunks.
|
||||
const chunks = [payload.subarray(0, 5), payload.subarray(5, 17), payload.subarray(17)];
|
||||
const fetchImpl = async () => sseResponse(chunks);
|
||||
const wrapped = wrapFetchForSseDebug(fetchImpl, () => {});
|
||||
const response = await wrapped("https://example.test/stream");
|
||||
const forwarded = new Uint8Array(await response.arrayBuffer());
|
||||
expect(Array.from(forwarded)).toEqual(Array.from(payload));
|
||||
it("is a no-op when no observer is installed", () => {
|
||||
expect(() => notifyRawSseEvent(undefined, { event: null, data: "{}", raw: ["data: {}"] })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,21 +5,137 @@
|
||||
|
||||
- Added isolated profile support via `--profile <name>` / `OMP_PROFILE` and shell alias bootstrap via `--alias <command>`, including launch/ACP bootstrap handling, extension-flag-safe parsing, profile-scoped user config discovery, and symlinked extension-directory discovery.
|
||||
|
||||
## [15.10.4] - 2026-06-08
|
||||
|
||||
### Added
|
||||
|
||||
- macOS release binaries are now signed with a Developer ID Application identity (hardened runtime + secure timestamp + JIT/library-validation entitlements) and notarized in CI when the `APPLE_*` signing secrets are configured; releases auto-fall back to ad-hoc signing until then. This makes the shipped binaries Gatekeeper-acceptable, unblocking an official Homebrew submission ([#776](https://github.com/can1357/oh-my-pi/issues/776)). See `docs/macos-signing-notarization.md`.
|
||||
- Added a Homebrew install path: `brew install can1357/tap/omp`. The [can1357/homebrew-tap](https://github.com/can1357/homebrew-tap) formula installs the prebuilt release binary, and a `release_brew` CI job regenerates it (version + per-asset sha256) from each published release via `scripts/ci-update-brew-formula.ts` ([#776](https://github.com/can1357/oh-my-pi/issues/776)).
|
||||
|
||||
### Changed
|
||||
|
||||
- Adjusted `completion()` model resolution so the `default` tier now prefers the session’s active model and falls back to the configured default role when needed
|
||||
- Rewrote the session auto-title prompt (`prompts/system/title-system.md`) and the `set_title` tool description to ask for a concise, sentence-case title (3-7 words) that captures the session's topic/goal, with good/bad examples and explicit guidance to treat the first message as data (no following embedded links/instructions, no refusals, describe URL/reference asks). The local on-device title prompt (`tiny-title-system.md`) was aligned to the same 3-7 word, sentence-case convention. The deterministic greeting/low-signal filter and the `none` deferral sentinel are unchanged.
|
||||
- Renamed the eval oneshot helper from `llm()` to `completion()` in both JavaScript and Python preludes, including status events, prompt docs, and runtime tests.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `completion()` to always send a non-empty default system prompt when `system` is omitted so providers that require instructions no longer reject requests
|
||||
- Fixed structured `completion()` mode to return parsed JSON from plain text output when the model skips the forced `respond` tool call
|
||||
- Fixed slow-tier `completion()` reasoning requests to avoid unsupported effort settings by only enabling reasoning on reasoning-capable models and capping effort to supported levels
|
||||
- Fixed JS eval worker reset/dispose to close workers gracefully before forced termination, avoiding Bun 1.3.14 N-API teardown crashes with native modules such as `canvas`.
|
||||
|
||||
## [15.10.3] - 2026-06-08
|
||||
|
||||
### Added
|
||||
|
||||
- Added clickable file path hyperlinks to read tool outputs (read-call rows, grouped summaries, and inline previews) using resolved or absolute file targets with selector-based line anchors for quick navigation
|
||||
- Added a resolved-span echo to `replace block`/`delete block` edits: a successful block op now prints `replace block N → resolved lines A-B (K lines)` between the section header and the diff preview, so the model can confirm tree-sitter matched the construct it intended (e.g. catch a decorator left outside the block) instead of inferring the span from the diff after the fact.
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the `find` tool to process each explicit multi-path target separately before merging results so searches stay scoped to the requested paths
|
||||
- Changed multi-path `find` handling so invalid extra targets no longer fail the whole query and now return matches from valid targets only
|
||||
- Changed background-job completion and late LSP diagnostic delivery to inject at the next agent step boundary (mid-run), via the new non-interrupting "aside" channel, instead of only when the agent reaches a yield/follow-up point. The model now sees these notifications between its own requests without the turn having to end first, and in-flight tools are never interrupted; `job`-poll acknowledgement still suppresses results the agent already saw.
|
||||
- Changed late LSP diagnostics after edit or write to surface in the chat transcript as `Late diagnostics` entries rendered through the same grouped tree renderer the `edit`/`write` tools use (per-file nodes, severity icons, `:line:col` locations), and to honor the global tool-output expand toggle (collapsed entries cap at 5 diagnostics with a `… N more` hint)
|
||||
- Changed delayed diagnostics delivery to batch late results in one message per flush instead of a raw hidden custom payload
|
||||
- Changed hidden custom messages and file-mention context to reach providers as `developer` messages instead of user-authored turns, so system reminders no longer pollute compacted user history.
|
||||
- Rewrote the plan-mode active prompt (`prompts/system/plan-mode-active.md`) from scratch to stop producing shallow plans. Reframed the artifact as an **execution spec** a fresh agent runs after the planning conversation is cleared/compacted (zero design decisions for the implementer) rather than a brevity-capped summary. Folded high-consensus requirements into the existing sections as inline, conditional rules — no new boilerplate sections: ordered Approach steps that keep the build/tests green after each step (sequencing); exact signatures/literals for new or load-bearing symbols (contracts); full callsite list + clean cutover for renames/signature-changes/removals; Verification that must exercise the new behavior (input → observable output) with run preconditions, not just build/typecheck; Assumptions restricted to user-overridable choices plus pre-decided fallbacks for load-bearing assumptions; a provenance rule (plan facts must come from a read this session; unverified claims flagged inline); and bans on conversation back-references and decision-free sections (Non-Goals/Alternatives/Risks/Future Work). Kept the decision-complete self-check and the brevity-vs-completeness tiebreak (completeness wins). Render contract (Handlebars vars/conditionals) unchanged; verified across all `planExists`/`reentry`/`iterative` branch combinations.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed duplicate `find` matches in multi-target queries by deduplicating overlapping paths in merged results
|
||||
- Fixed `find` partial updates to avoid repeated streamed rows while scans are still running
|
||||
- Fixed stale late diagnostics from older edits being shown after a file was edited again
|
||||
- Fixed read output paths so selector suffixes are preserved when corrected paths were returned without selectors
|
||||
- Fixed `read` surfacing a misleading red "Operation aborted" on a plain-file or directory read when a turn was interrupted mid-read. Those reads are deterministic and fast, so `execute` now runs them to completion instead of cancelling them; slower/non-deterministic reads (archive, sqlite, document, image, summary, conflict scan, URL) stay cancellable.
|
||||
- Fixed edit tool headers to hide first-change line suffixes, middle-elide long paths only when the header width needs it, show compact change stats, and target encoded `file://` hyperlinks.
|
||||
- Fixed Esc interrupts rendering a redundant `Interrupted by user` assistant transcript line while preserving the interrupt reason for tool-result placeholders and continuation logic.
|
||||
- LSP writethrough no longer burns the full diagnostics poll on every edit/write. `typescript-language-server` never echoes the document version in `publishDiagnostics` ([upstream #983](https://github.com/typescript-language-server/typescript-language-server/issues/983)), so the exact-version gate never passed; `waitForDiagnostics` now accepts an exact version match instantly and otherwise settles on the latest publish after a short quiescence window, dropping superseded in-flight diagnostics.
|
||||
- LSP writethrough no longer blocks the whole edit/write on slow diagnostics: it now waits only a short inline window (~500ms) for a settled result, then hands the in-flight fetch to the deferred channel so a slow or cold language server (e.g. a large-project `tsserver`) delivers its diagnostics as a follow-up message instead of stalling the tool 3–5s on every edit. The background fetch also gets a longer budget so slow servers still surface late rather than being dropped.
|
||||
- Fixed the `c`/`.` continue shortcut making the agent second-guess itself after an Esc interrupt. Continuing used to submit an *empty* user turn, which left the model with only the aborted-turn context — so it tended to restate the halted state and ask whether to proceed rather than just continuing. The shortcut now resumes with a hidden agent-authored `developer` directive ("keep going — don't stop to summarize or re-confirm the plan") instead of an empty turn. It still produces no visible transcript entry, same as before.
|
||||
- Fixed native scrollback commit boundaries to be computed generically from finalized transcript blocks and observed append-only live growth, so tall final tool results and streaming previews keep their scrolled-off heads on ED3-risk terminals without per-tool append-only predicates; live blocks that re-layout remain deferred until finalization or the next checkpoint.
|
||||
- Fixed read-group summaries for multi-path `read` results to use result-provided display targets so each resolved path is shown as its own row
|
||||
- Fixed read-group range summaries to abbreviate long merged selectors with ellipsis to keep repeated-file range rows readable
|
||||
- Fixed read-group TUI summaries so a single delimited `read` call renders as separate read rows, and repeated reads of the same file collapse under one file with full-file/range children.
|
||||
- Fixed grouped `read` rows freezing on their pending "⏳ Read <path>" preview on ED3-risk terminals (ghostty/kitty/iTerm2/…) when a parallel sibling tool closed the read run and appended a block below the group before the read's result arrived. The read-group block now stays in the repaintable live region until its entries settle, so the late success result repaints instead of being stranded; a `seal()` escape hatch (turn end / transcript rebuild) still lets a never-delivered read freeze rather than pinning the live region.
|
||||
- Fixed session search to return all sessions unchanged when the query is blank
|
||||
- Fixed duplicate session suggestions by deduplicating history matches by session path when merging metadata and prompt-history results
|
||||
- Fixed `/resume` search ranking so sessions whose prompts or metadata match the query now prefer prompt recency and recent literal matches instead of letting older earlier-title fuzzy matches outrank a just-used session.
|
||||
- Fixed `omp --resume <id>` / `--fork <id>` crashing with `[Uncaught Exception]` when the id did not match a known session. `createSessionManager` now throws a dedicated `SessionResolutionError`, which `runRootCommand` catches to print `Error: Session "..." not found.` plus a hint to stderr and exit with code 1. The same path covers `--fork` combined with `--no-session` and the non-interactive cross-project / moved-cwd prompts that previously surfaced raw stack traces ([#2084](https://github.com/can1357/oh-my-pi/issues/2084)).
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed the animated pending border ("shimmer") on running `bash`, `eval`, and `ssh` execution blocks. While pending, a block now shows a static accent border instead of sweeping a dark segment around its bottom edge; `display.shimmer` still governs the working-status line and `task` row animations.
|
||||
- Removed the tool-level `nonAbortable` bypass so `write` and `edit` honor the active turn `AbortSignal`. `read` is abortable for everything that is slow or non-deterministic (URL/internal-URL reads, archive, sqlite, document conversion, image decode, structural summary, conflict scan, suffix glob); only the deterministic plain-file line/range reads and directory listings run to completion.
|
||||
|
||||
## [15.10.2] - 2026-06-08
|
||||
|
||||
### Added
|
||||
|
||||
- Added `raw-sse.txt` to debug report bundles, exporting recent raw provider SSE diagnostics when captured
|
||||
- Added `/model` visibility for auto-selected role defaults: inferred `pi/smol`/`pi/slow`/designer choices now show as compact `[ROLE auto]` badges, while explicitly configured roles keep the existing solid badges and thinking labels.
|
||||
- Added credential provenance to the `/login` and `/logout` provider picker: each authenticated provider now shows where its credential comes from — `(login)`, `(api key)`, `(env: VAR_NAME)`, `(config)`, `(--api-key)`, or `(custom provider)` — so a real OAuth login is distinguishable from an env var that merely aliases the provider (e.g. `COPILOT_GITHUB_TOKEN`). The origin is also matched by the picker's type-to-search filter.
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed raw SSE debug export output to prepend dropped-record metadata so truncated sessions in debug bundles now report dropped record and character counts
|
||||
- Changed settings reads to cache pre-split schema paths and resolved values, with coarse invalidation on source/cwd changes.
|
||||
- Changed status-line rendering to cache merged effective settings until `updateSettings()` changes the configuration.
|
||||
- Changed `CustomEditor` app shortcut dispatch to parse each input packet once and match against precomputed canonical key sets, preserving the existing shortcut precedence while avoiding repeated key reparses.
|
||||
- Changed `lsp references` to retry only when no references or only the queried declaration are returned, using two fixed 250ms retries for project-aware servers
|
||||
- Changed `read` handling of `https://github.com/<owner>/<repo>:raw` to use raw page rendering only, removing the GitHub API README fallback
|
||||
- Changed model resolution to apply provider-priority ordering when selecting models for roles and ambiguous patterns, using `modelProviderOrder` settings and built-in provider priority so first-party providers are preferred over relays in tie cases
|
||||
- Changed model canonical variant selection to use the same provider-priority ordering instead of candidate order when deduplicating equivalent upstream models
|
||||
- Changed the working-message shimmer to sweep at a fixed velocity (cells/second) instead of a fixed sweep duration divided by the message length. The band now advances ≤1 cell per 30fps redraw frame and stays equally smooth on short and long messages — previously a longer message swept proportionally faster and stepped visibly because it outran the redraw cadence. Sweep/round-trip duration now scales with length. Additionally, when `display.shimmer = disabled` the working line is static, so the loader no longer schedules 30fps redraws for it and falls back to the spinner-only ~12.5fps cadence.
|
||||
- Changed the eval fan-out trigger keyword from `workflow`/`workflows` to `workflowz`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed working-message loader session accents so spinner/message color math is cached per session name, session-accent setting, and theme luminance while still updating immediately on renames, setting toggles, and theme changes.
|
||||
- Fixed startup model fallback selection so sessions now prefer each provider’s configured default model before choosing the first available authenticated model
|
||||
- Fixed implicit model selection path for tools and sessions by honoring persisted model-provider order when no explicit pattern is provided
|
||||
- Fixed the working spinner appearing to ignore Esc for 2-3 seconds when an interrupt lands mid-tool. Esc fires the abort synchronously, but the agent loop only stops the loader at `agent_end`, which it cannot reach until every in-flight tool settles in `executeToolCalls`' `await Promise.allSettled(...)` — and process/subagent/kernel-owning tools tear down gracefully (SIGTERM, 2-3s grace, SIGKILL), so the loader kept showing the unchanged "Working…/<intent>" line and read as a dropped keypress. The loader now switches to "Interrupting…" the instant Esc requests the abort and freezes intent-driven label updates until the turn unwinds (`EventController.notifyInterrupting`), so the interrupt is acknowledged immediately even while teardown completes.
|
||||
- Fixed a flaky JS eval worker startup that intermittently failed unrelated CI runs. The worker-ready wait reused Bun's 5s default per-test timeout as its floor, so a slow cold-start under `--isolate` + high concurrency was aborted mid-init; terminating a still-initializing Bun worker is the documented SIGILL/SIGTRAP crash trigger, which took down the whole test file. Worker init now floors at a fixed 15s infrastructure budget (independent of, and still dominated by, a larger per-cell `timeout`), and the JS eval test suites set a 20s file-local timeout so cold starts complete instead of being torn down.
|
||||
- Fixed reviewer-style subagent yields crashing the calling eval cell when a caller-supplied output schema declares `additionalProperties: false` without a `findings` property. `normalizeCompleteData` now consults the active validator before splicing collected `report_finding` entries onto the yielded payload, so injection is suppressed when the schema would reject it — keeping the executor's post-mortem validation in lockstep with the in-tool `yield` validation that already accepted the same raw payload ([#2070](https://github.com/can1357/oh-my-pi/issues/2070))
|
||||
- Fixed Anthropic empty `toolUse` stops without tool calls corrupting session history by retrying them and removing orphaned turns even at the retry cap.
|
||||
- Fixed MCP tools hanging in non-yolo modes by declaring `approval = "write"` on `MCPTool` and `DeferredMCPTool`, and propagating the `approval` property through `customToolToDefinition()` in `sdk.ts`
|
||||
- Fixed session resumption after a working directory is moved/renamed (e.g. `git worktree move`): `--continue` now re-roots the terminal's last session into the new directory when its original directory no longer exists, instead of silently starting a fresh empty session; cross-project `--resume <id>` offers to move (re-root) the session rather than only forking a duplicate copy when the source directory is gone
|
||||
- Fixed Kitty OSC 5522 paste rejecting plain text as "no supported text or image data": the listing parser now decodes the `mime="."` DATA payload (whitespace-separated MIME list) Kitty actually sends, in addition to the per-type DATA packets described by the ancillary 5522-mode spec ([#2051](https://github.com/can1357/oh-my-pi/issues/2051))
|
||||
- Fixed session resumption after a working directory is moved/renamed (e.g. `git worktree move`): `--continue` now re-roots the terminal's last session into the new directory when its original directory no longer exists, explicit `--resume <id> --session-dir <dir>` local matches re-root instead of reopening with the stale cwd, and cross-project `--resume <id>` offers to move (re-root) the session rather than only forking a duplicate copy when the source directory is gone
|
||||
- Fixed Kitty OSC 5522 paste rejecting plain text as "no supported text or image data": the listing parser now decodes the `mime="."` DATA payload (whitespace-separated MIME list) Kitty actually sends, in addition to the per-type DATA packets described by the ancillary 5522-mode spec, and per-type spec listings now request the selected payload with `type=read:mime=...` instead of Kitty's dot-payload request shape ([#2051](https://github.com/can1357/oh-my-pi/issues/2051))
|
||||
- Fixed follow-up shortcut submission of builtin slash commands so `/goal set ...` applies goal mode instead of queueing as plain text.
|
||||
- Fixed Ctrl+Z crashing the agent on Windows with `TypeError: Unknown signal: SIGTSTP`. `InputController.handleCtrlZ` called `process.kill(0, "SIGTSTP")` unconditionally, but `SIGTSTP` is POSIX job-control and Bun/Node on Windows rejects the signal name from the JS side; the throw propagated out of the TUI input dispatcher as an uncaught exception. The handler now no-ops with a "Suspend (Ctrl+Z) is not supported on this platform" status on Windows, and on POSIX wraps `process.kill` in a try/catch that detaches the registered SIGCONT resume hook and re-`start()`s the TUI on failure so a rejected signal can never leave the UI stranded with a leaked listener ([#2036](https://github.com/can1357/oh-my-pi/issues/2036)).
|
||||
- Fixed a relative `--cwd` target (e.g. `omp --cwd repo` launched from `/tmp`) leaking the raw relative string into the session config. `applyStartupCwd` chdired into the resolved directory via `setProjectDir` but left `parsed.cwd` as `"repo"`, so `buildSessionOptions` (which prefers `parsed.cwd` over `getProjectDir()`) handed downstream settings/discovery/session creation a value that re-resolved against the new process cwd (`/tmp/repo/repo`) or persisted a relative session cwd. `parsed.cwd` is now re-synced to the resolved absolute project dir after the chdir.
|
||||
- Fixed the `--cwd` launch flag so it is parsed and can override the startup directory instead of always falling back to the current process directory or home auto-switch target.
|
||||
- Fixed session auto-retry for generic `upstream_error: Upstream request failed` gateway failures.
|
||||
- Stripped read-output line-number prefixes (`N:`) from auto-piped bare body rows in the hashline edit parser, so pasting `3:text` without a `+` prefix no longer injects `3:` as literal content. Uses single-pass stripping to avoid corrupting content whose own text starts with `digits:` ([#1492](https://github.com/can1357/oh-my-pi/issues/1492)).
|
||||
- Fixed `eval` `llm()` returning HTTP 400 "Instructions are required" when called without a `system` prompt against providers (notably `openai-codex`) whose Responses transformer drops the `instructions` field on an empty system prompt. `runEvalLlm` now sends a minimal default system prompt ("You are a helpful assistant.") when no `system` is supplied, so `llm("question")` works against every provider; an explicit `system=` still wins.
|
||||
- Fixed the Python `read(path, offset, limit)` prelude helper rejecting documented positional arguments with `TypeError: read() takes 1 positional argument but 3 were given`. The signature was keyword-only (`def read(path, *, offset=1, limit=None)`) while the eval helper table advertises positional optional args; agents that called `read("file.py", 10, 20)` literally crashed. The `*` is removed so both `read("f", 10, 20)` and `read("f", offset=10, limit=20)` work.
|
||||
- Fixed `eval` reset cells failing with `"Python kernel reset already in progress"` / `"JS context reset already in progress"` when two cells happened to overlap on the same session (e.g. a rapid resubmit, or a parallel-cell race). The executor now coalesces concurrent resets — additional callers wait for the in-flight reset to finish and then run on the freshly restarted kernel — instead of throwing a user-visible error for what is purely an internal coordination state.
|
||||
- Fixed the `eval` tool description advertising the `agent()` helper unconditionally even in subagent sessions whose parent forbids spawning. When `getSessionSpawns()` returns `""`, the prelude doc now omits `agent()` so the model is not promised a helper that can only ever throw "Cannot spawn 'task'. Allowed: none (spawns disabled for this agent)".
|
||||
- Fixed plan mode rejecting `local://` plan-artifact edits when addressed via the absolute path the `read` tool echoes back in the `[path#tag]` header. `enforcePlanModeWrite` previously only matched the literal `local://` scheme; it now also accepts any absolute path whose realpath resolves inside the session's local sandbox root, so the absolute spelling and the `local://` spelling are interchangeable in plan mode.
|
||||
- Fixed snapshot tags freshly minted by `read` being rejected as stale by a subsequent `edit` against the same file when the two sides reached the file via symlink-equivalent spellings (e.g. macOS `/tmp/…` vs `/private/tmp/…`, or `read local://foo.md` recording under the file's `fs.realpath` while `edit local://foo.md` looked up under the raw `path.resolve(localRoot, …)` form). The file snapshot store now keys every record/lookup through a `realpath`-canonicalized key (`canonicalSnapshotKey`), fusing all spellings of the same on-disk file onto one snapshot entry.
|
||||
- Fixed `read` of a `github.com/<owner>/<repo>` URL with `:raw` returning the full JS-rendered HTML shell. Repo roots now resolve to the decoded README via the GitHub API (`/repos/<owner>/<repo>/readme`), falling back to the raw HTML only when the API returns no usable payload.
|
||||
- Fixed `issue://` and `pr://` reads returning stale OPEN/CLOSED state after a successful `gh issue close` / `gh pr merge` (or any other state-changing `gh` invocation) in the same session. The `bash` tool now invalidates the matching `github-cache` rows before executing any `gh (issue|pr) <close|reopen|merge|delete|edit|comment|lock|unlock|pin|unpin|transfer|develop|ready|review>` command.
|
||||
- Fixed line-range selectors on PDF/DOCX/PPTX/XLSX/RTF/EPUB reads being ignored. The markit-converted markdown body now flows through the same in-memory range slicer used for plain text, so `file.pdf:50-100` and `file.pdf:5-16,40-80` slice the converted body instead of returning the whole document.
|
||||
- Fixed the `read` selector cheatsheet incorrectly promising "exactly one line" for `:N+1` while the implementation pads single-line reads with ≤1 leading and ≤3 trailing context; documented that multi-range selectors do not pad, giving callers a way to request exact bounds.
|
||||
- Documented the `bash.autoBackground.enabled` behavior in the `bash` tool prompt so the `Background job <id> started: …` notice for foreground commands that exceed `autoBackgroundThresholdSeconds` no longer reads as a tool malfunction.
|
||||
- Fixed `task` subagents whose in-tool `yield` validator had already accepted a payload after exhausting `MAX_SCHEMA_RETRIES` being rejected a second time by the post-mortem executor validator. The override now propagates through the yield tool's `details.schemaOverridden` flag, and the executor surfaces a `SUBAGENT_WARNING_SCHEMA_OVERRIDDEN` stderr line instead of re-emitting `schema_violation` for data the subagent already had to ship. Finalize also degrades to no validation (matching the yield tool's `looseRecordSchema` fallback) when the caller-supplied output schema fails to normalize.
|
||||
- Fixed the `web_search` `codex` provider returning `(see attached image)` / `[Attached image]` / `See image above` and similar non-informational image-placeholder strings as the answer. Detection broadened to a small regex set, and when annotations did produce sources we now drop the placeholder prose from `answer` (returning sources only); when neither annotations nor a real answer materialize, we throw 502 to advance the provider chain.
|
||||
- Fixed `search`, `find`, `ast_grep`, and `ast_edit` rejecting bracket-containing file paths (Next.js routes like `apps/[id]/page.tsx`) as glob patterns when the literal path exists on disk. `parseSearchPathPreferringLiteral` now prefers the literal interpretation for paths that resolve on disk and only falls back to glob expansion when the literal does not exist.
|
||||
- Fixed `search` with an external `http(s)/ftp/ws/file://` URL in `paths` surfacing a misleading "Path not found" error. The tool now rejects external URLs with a clear "use `read` for URLs" message.
|
||||
- Fixed `search` rejecting `skip: null` at the schema layer; `null` now normalizes to `0` alongside the omitted case, matching how callers serialize default pagination state.
|
||||
- Fixed `search` returning zero matches with no explanation when explicit file targets exceed the native grep cap (4 MB). The tool now surfaces a `Skipped oversized files (>4MB grep limit; …)` notice listing the truncated paths.
|
||||
- Fixed the archive-extraction error message in `search` recommending `grep` — which the system prompt forbids — instead of pointing to `read <archive>:<member>`.
|
||||
- Fixed `browser` `tab.open(name, { viewport })` on an existing tab not applying the new viewport: `acquireTab`'s reuse path now resizes the page in addition to navigating.
|
||||
- Fixed `browser` tab metadata leaking `user:pass@` basic-auth credentials in the URL surfaced to transcripts and observe snapshots; URLs are now redacted via `redactUrlCredentials()`.
|
||||
- Fixed `browser` `tab.extract(format)` returning a `ReadableResult | null` shape that the tool prompt advertised as plain content. The helper now returns the markdown/text string directly (or throws a clear `ToolError` when extraction is empty), and the prompt matches.
|
||||
- Fixed `lsp` requests timing out at a hard-coded 30 s ceiling when the caller supplied an explicit abort signal (e.g. the tool wall-clock). The signal is now the deadline; the 30 s default still applies when neither a signal nor an explicit `timeoutMs` is provided.
|
||||
- Fixed `lsp status` reporting servers as `Active language servers: …` when the binary resolves on PATH but never spawns (rustup wrapper, missing toolchain component, etc.). Status now labels each entry as `(ready)` or `(configured, not started)`.
|
||||
- Fixed `lsp rename_file` fanning `willRenameFiles` requests across every configured server (including ones with no jurisdiction over the file type) and burning the wall-clock timeout. The action now pre-filters configured LSPs to those whose `fileTypes` cover the source or destination path, falling back to a plain filesystem rename when no server claims the type.
|
||||
- Fixed `lsp references` returning only the queried declaration (or only in-file results) on project-aware servers that had not finished indexing. The retry budget is raised from 2 → 3 with 250 / 500 / 1000 ms backoff, and the retry trigger now also fires when all results live in the queried file.
|
||||
- Fixed `lsp config` accepting `fileTypes` entries with or without a leading dot inconsistently across actions; both `.ts` and `ts` are now normalized so a missing-dot entry no longer silently excludes a server from extension-based routing.
|
||||
- Fixed `lsp request` error path swallowing the params that were sent, making shape/coercion bugs on raw LSP calls impossible to diagnose in one round-trip; the error now echoes a truncated copy of the request params.
|
||||
- Fixed `find` with a single-star segment like `dir/*` recursing into subdirectories and returning nested matches. `parseFindPattern` already prepends `**/` for top-level globs (`*.ts` → `**/*.ts`), so anything reaching native without `**/` was deliberately scoped by the user; `recursive: false` is now passed to `natives.glob` to honor that scope.
|
||||
|
||||
## [15.10.1] - 2026-06-07
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Edit/write LSP-writethrough latency probe.
|
||||
*
|
||||
* The pure hashline apply is sub-2ms for normal files (see
|
||||
* `packages/hashline/bench/apply-edit.ts`). The real source of "applying an
|
||||
* edit takes a LOT of time" is the LSP writethrough's *synchronous* wait for
|
||||
* fresh diagnostics:
|
||||
*
|
||||
* runLspWritethrough -> getDiagnosticsForFile -> waitForDiagnostics
|
||||
*
|
||||
* `waitForDiagnostics` polls every 100ms. Servers that echo the edited
|
||||
* document version are accepted immediately; servers that omit or mismatch it
|
||||
* (typescript-language-server) settle on the latest publish after a 250ms quiet
|
||||
* window so stale in-flight publishes can be superseded without burning the
|
||||
* full timeout.
|
||||
*
|
||||
* Gated by settings:
|
||||
* - edit tool: `lsp.diagnosticsOnEdit` (default FALSE — edits fast by default)
|
||||
* - write tool: `lsp.diagnosticsOnWrite` (default TRUE — writes pay it by default)
|
||||
* - both: `lsp.formatOnWrite` (default FALSE — ~24ms when on, fine)
|
||||
*
|
||||
* Requires a TypeScript language server on PATH and a tsconfig at the repo
|
||||
* root. Mutates a temp .ts file inside the repo so tsserver resolves it under
|
||||
* the project, then deletes it.
|
||||
*
|
||||
* Run: `bun run packages/coding-agent/bench/edit-lsp-writethrough.bench.ts`
|
||||
*/
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { createLspWritethrough, writethroughNoop } from "../src/lsp";
|
||||
|
||||
const REPO = path.resolve(import.meta.dir, "../../..");
|
||||
const target = path.join(REPO, "packages/coding-agent/src/__bench_lsp_tmp.ts");
|
||||
|
||||
function body(n: number): string {
|
||||
return `// bench scratch file with an intentional type diagnostic
|
||||
export function benchAdd_${n}(a: number, b: number): number {
|
||||
const result = a + b;
|
||||
return result;
|
||||
}
|
||||
export const benchValue_${n}: string = benchAdd_${n}(${n}, ${n + 1});
|
||||
`;
|
||||
}
|
||||
|
||||
async function timeCall(label: string, fn: () => Promise<unknown>): Promise<void> {
|
||||
const t0 = Bun.nanoseconds();
|
||||
await fn();
|
||||
console.log(` ${label.padEnd(46)} ${((Bun.nanoseconds() - t0) / 1e6).toFixed(1).padStart(9)} ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a one-shot deferred handle mirroring the edit tool's
|
||||
* `beginDeferredDiagnosticsForPath`: `onDeferredDiagnostics` is the late-injection
|
||||
* sink, `signal` keeps the background fetch alive, `finalize` reports whether the
|
||||
* inline result arrived. Logs when late diagnostics land so #2 is observable.
|
||||
*/
|
||||
function makeDeferred(label: string) {
|
||||
const controller = new AbortController();
|
||||
const lateAt = { t: 0 };
|
||||
const startedAt = Bun.nanoseconds();
|
||||
return {
|
||||
handle: {
|
||||
onDeferredDiagnostics: (_d: unknown) => {
|
||||
lateAt.t = (Bun.nanoseconds() - startedAt) / 1e6;
|
||||
console.log(` └─ ${label}: late diagnostics injected at +${lateAt.t.toFixed(0)} ms`);
|
||||
},
|
||||
signal: controller.signal,
|
||||
finalize: (_d: unknown) => {},
|
||||
},
|
||||
controller,
|
||||
};
|
||||
}
|
||||
|
||||
await fs.writeFile(target, body(0));
|
||||
try {
|
||||
console.log("\n--- writethroughNoop (LSP off — default edit path) ---");
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await timeCall(`noop write #${i}`, () => writethroughNoop(target, body(i), undefined, Bun.file(target)));
|
||||
}
|
||||
|
||||
console.log("\n--- diagnostics, NO deferred channel (blocks until settle/timeout) ---");
|
||||
const wtDiag = createLspWritethrough(REPO, { enableDiagnostics: true, enableFormat: false });
|
||||
for (let i = 10; i <= 14; i++) {
|
||||
const label = i === 10 ? "write #1 (COLD: spawn+warm)" : `write #${i - 9} (warm)`;
|
||||
await timeCall(label, () => wtDiag(target, body(i), undefined, Bun.file(target)));
|
||||
}
|
||||
|
||||
console.log("\n--- diagnostics, WITH deferred channel (short inline wait, then late) ---");
|
||||
for (let i = 30; i <= 34; i++) {
|
||||
const { handle } = makeDeferred(`write #${i - 29}`);
|
||||
await timeCall(`write #${i - 29} (inline)`, () =>
|
||||
wtDiag(target, body(i), undefined, Bun.file(target), undefined, () => handle),
|
||||
);
|
||||
}
|
||||
// Give any in-flight late fetches a moment to land before teardown.
|
||||
await Bun.sleep(6000);
|
||||
|
||||
console.log("\n--- format writethrough (formatOnWrite) ---");
|
||||
const wtFmt = createLspWritethrough(REPO, { enableDiagnostics: false, enableFormat: true });
|
||||
for (let i = 20; i <= 22; i++) {
|
||||
await timeCall(`write #${i - 19}`, () => wtFmt(target, body(i), undefined, Bun.file(target)));
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(target, { force: true });
|
||||
}
|
||||
|
||||
console.log("\n(done)");
|
||||
process.exit(0);
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-coding-agent",
|
||||
"version": "15.10.1",
|
||||
"version": "15.10.4",
|
||||
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
@@ -19,7 +19,7 @@ import type { CanonicalModelVariant } from "../config/model-equivalence";
|
||||
import { type CanonicalModelQueryOptions, ModelRegistry } from "../config/model-registry";
|
||||
import {
|
||||
formatModelString,
|
||||
type ModelMatchPreferences,
|
||||
getModelMatchPreferences,
|
||||
resolveAllowedModels,
|
||||
resolveCliModel,
|
||||
resolveModelRoleValue,
|
||||
@@ -542,9 +542,7 @@ async function resolveDryBalanceModel(
|
||||
settings: Settings | undefined,
|
||||
randomSessionId: () => string,
|
||||
): Promise<{ model: Model<Api>; warning?: string }> {
|
||||
const preferences: ModelMatchPreferences = {
|
||||
usageOrder: settings?.getStorage()?.getModelUsageOrder(),
|
||||
};
|
||||
const preferences = getModelMatchPreferences(settings);
|
||||
if (modelSelector) {
|
||||
const resolved = resolveCliModel({
|
||||
cliModel: modelSelector,
|
||||
|
||||
@@ -105,6 +105,10 @@ export async function renderGalleryState(
|
||||
width: number,
|
||||
expanded = false,
|
||||
): Promise<string[]> {
|
||||
if (fixture.renderState) {
|
||||
return await fixture.renderState(state, width, expanded);
|
||||
}
|
||||
|
||||
const tool = fakeToolFor(name, fixture);
|
||||
const streamingArgs = state === "streaming" ? (fixture.streamingArgs ?? fixture.args) : fixture.args;
|
||||
// The component only calls `requestRender` during a static render;
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { GalleryFixture } from "./types";
|
||||
export const codeintelFixtures: Record<string, GalleryFixture> = {
|
||||
lsp: {
|
||||
label: "LSP",
|
||||
customRendered: true,
|
||||
streamingArgs: {
|
||||
action: "references",
|
||||
file: "src/server/auth.ts",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// biome-ignore-all lint/suspicious/noTemplateCurlyInString: sample source-code strings (read fixtures) intentionally contain literal ${...}.
|
||||
// Gallery fixtures for the filesystem tools (read, write, find).
|
||||
import type { GalleryFixture } from "./types";
|
||||
import { ReadToolGroupComponent } from "../../modes/components/read-tool-group";
|
||||
import type { GalleryFixture, GalleryFixtureState, GalleryResult } from "./types";
|
||||
|
||||
const readSnippet = [
|
||||
"export const findToolRenderer = {",
|
||||
@@ -36,6 +37,64 @@ const writtenContent = [
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const groupedReadTargets = [
|
||||
"packages/coding-agent/test/streaming-preview-height.test.ts:301-409",
|
||||
"packages/coding-agent/test/tool-live-region-scrollback.test.ts:143-310",
|
||||
"packages/tui/test/streaming-scrollback-defer.test.ts:89-464",
|
||||
];
|
||||
|
||||
const groupedReadDelimitedPath = groupedReadTargets.join(",");
|
||||
const groupedReadRepeatedFile = "packages/coding-agent/src/task/render.ts";
|
||||
const groupedReadRepeatedRanges = `${groupedReadRepeatedFile}:507-605,1070-1194,1210-1240,1270-1274`;
|
||||
|
||||
function textResult(text: string, details?: unknown, isError?: boolean): GalleryResult {
|
||||
return { content: [{ type: "text", text }], details, isError };
|
||||
}
|
||||
|
||||
function addGroupedReadArgs(component: ReadToolGroupComponent): void {
|
||||
component.updateArgs({ path: groupedReadDelimitedPath }, "read-delimited");
|
||||
component.updateArgs({ path: groupedReadRepeatedRanges }, "read-ranges");
|
||||
}
|
||||
|
||||
function renderReadGroupFixtureState(state: GalleryFixtureState, width: number, expanded: boolean): string[] {
|
||||
const component = new ReadToolGroupComponent();
|
||||
component.setExpanded(expanded);
|
||||
|
||||
if (state === "streaming") {
|
||||
component.updateArgs(
|
||||
{
|
||||
path: [
|
||||
"packages/coding-agent/test/streaming-preview-height.test.ts:301-409",
|
||||
"packages/coding-agent/test/tool-live-region-scrollback.test.ts:143-",
|
||||
].join(","),
|
||||
},
|
||||
"read-delimited",
|
||||
);
|
||||
return component.render(width);
|
||||
}
|
||||
|
||||
addGroupedReadArgs(component);
|
||||
if (state === "progress") return component.render(width);
|
||||
|
||||
component.updateResult(
|
||||
textResult("Read three focused test ranges.", { displayReadTargets: groupedReadTargets }),
|
||||
false,
|
||||
"read-delimited",
|
||||
);
|
||||
|
||||
if (state === "error") {
|
||||
component.updateResult(
|
||||
textResult("Error: selector 1270-1274 is outside the file", undefined, true),
|
||||
false,
|
||||
"read-ranges",
|
||||
);
|
||||
return component.render(width);
|
||||
}
|
||||
|
||||
component.updateResult(textResult("Read four render.ts ranges."), false, "read-ranges");
|
||||
return component.render(width);
|
||||
}
|
||||
|
||||
export const fsFixtures: Record<string, GalleryFixture> = {
|
||||
read: {
|
||||
label: "Read",
|
||||
@@ -81,6 +140,14 @@ export const fsFixtures: Record<string, GalleryFixture> = {
|
||||
},
|
||||
},
|
||||
|
||||
read_group: {
|
||||
label: "Read Groups",
|
||||
args: {},
|
||||
result: textResult("Rendered grouped read calls."),
|
||||
errorResult: textResult("Rendered grouped read errors.", undefined, true),
|
||||
renderState: renderReadGroupFixtureState,
|
||||
},
|
||||
|
||||
write: {
|
||||
label: "Write",
|
||||
// Streaming: path known, content still arriving (only the imports so far).
|
||||
|
||||
@@ -11,14 +11,21 @@ export interface GalleryResult {
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
export type GalleryFixtureState = "streaming" | "progress" | "success" | "error";
|
||||
|
||||
export interface GalleryFixture {
|
||||
/** Display label for the tool header (defaults to the tool name). */
|
||||
label?: string;
|
||||
/** Edit mode for edit-like tools so the streaming preview dispatches correctly. */
|
||||
editMode?: EditMode;
|
||||
/**
|
||||
* Custom gallery-only renderer for fixtures that are not one ToolExecutionComponent
|
||||
* (for example the read-group transcript component).
|
||||
*/
|
||||
renderState?: (state: GalleryFixtureState, width: number, expanded: boolean) => string[] | Promise<string[]>;
|
||||
/**
|
||||
* Set for tools whose real `AgentTool` attaches `renderCall`/`renderResult`
|
||||
* directly on the instance (e.g. `lsp`, `task`). The harness then attaches
|
||||
* directly on the instance (e.g. `task`). The harness then attaches
|
||||
* the registry renderer onto the fake tool so the component routes through
|
||||
* the custom-tool branch — the same path production takes — instead of the
|
||||
* built-in registry branch. The two branches can diverge, so exercising the
|
||||
|
||||
@@ -170,6 +170,7 @@ export async function runCommitAgentSession(input: CommitAgentInput): Promise<Co
|
||||
await session.prompt(reminder, {
|
||||
attribution: "agent",
|
||||
expandPromptTemplates: false,
|
||||
synthetic: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Api, ApiKey, Model } from "@oh-my-pi/pi-ai";
|
||||
import type { ApiKeyResolverRegistry } from "../config/api-key-resolver";
|
||||
import { MODEL_ROLE_IDS } from "../config/model-registry";
|
||||
import {
|
||||
getModelMatchPreferences,
|
||||
type ModelLookupRegistry,
|
||||
parseModelPattern,
|
||||
resolveModelRoleValue,
|
||||
@@ -33,7 +34,7 @@ export async function resolvePrimaryModel(
|
||||
modelRegistry: CommitModelRegistry,
|
||||
): Promise<ResolvedCommitModel> {
|
||||
const available = modelRegistry.getAvailable();
|
||||
const matchPreferences = { usageOrder: settings.getStorage()?.getModelUsageOrder() };
|
||||
const matchPreferences = getModelMatchPreferences(settings);
|
||||
const resolved = override
|
||||
? resolveModelRoleValue(override, available, { settings, matchPreferences, modelRegistry })
|
||||
: resolveRoleSelection(["commit", "smol", ...MODEL_ROLE_IDS], settings, available, modelRegistry);
|
||||
@@ -73,7 +74,7 @@ export async function resolveSmolModel(
|
||||
}
|
||||
}
|
||||
|
||||
const matchPreferences = { usageOrder: settings.getStorage()?.getModelUsageOrder() };
|
||||
const matchPreferences = getModelMatchPreferences(settings);
|
||||
for (const pattern of MODEL_PRIO.smol) {
|
||||
const candidate = parseModelPattern(pattern, available, matchPreferences, { modelRegistry }).model;
|
||||
if (!candidate) continue;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
const DEFAULT_MODEL_PROVIDER_ORDER = [
|
||||
// First-party / native account providers. Prefer these over relays when the
|
||||
// same upstream model is available in more than one place.
|
||||
"openai-codex",
|
||||
"anthropic",
|
||||
"openai",
|
||||
"google-gemini-cli",
|
||||
"google",
|
||||
"google-vertex",
|
||||
"kimi-code",
|
||||
"moonshot",
|
||||
"qwen-portal",
|
||||
"zai",
|
||||
"xai-oauth",
|
||||
"xai",
|
||||
"mistral",
|
||||
"deepseek",
|
||||
"groq",
|
||||
|
||||
// High-quality aggregators / hosted inference providers.
|
||||
"fireworks",
|
||||
"cerebras",
|
||||
"openrouter",
|
||||
"together",
|
||||
|
||||
// Generic gateways and editor/proxy providers. These are useful when picked
|
||||
// explicitly, but should not win ambiguous automatic role selection.
|
||||
"alibaba-coding-plan",
|
||||
"google-antigravity",
|
||||
"opencode-zen",
|
||||
"gitlab-duo",
|
||||
"opencode-go",
|
||||
"kilo",
|
||||
"vercel-ai-gateway",
|
||||
"cloudflare-ai-gateway",
|
||||
"nanogpt",
|
||||
"github-copilot",
|
||||
] as const;
|
||||
|
||||
function addProviderRank(rank: Map<string, number>, provider: string): void {
|
||||
const normalized = provider.trim().toLowerCase();
|
||||
if (!normalized || rank.has(normalized)) return;
|
||||
rank.set(normalized, rank.size);
|
||||
}
|
||||
|
||||
export function buildModelProviderPriorityRank(configuredProviderOrder?: readonly string[]): Map<string, number> {
|
||||
const rank = new Map<string, number>();
|
||||
for (const provider of configuredProviderOrder ?? []) {
|
||||
addProviderRank(rank, provider);
|
||||
}
|
||||
for (const provider of DEFAULT_MODEL_PROVIDER_ORDER) {
|
||||
addProviderRank(rank, provider);
|
||||
}
|
||||
return rank;
|
||||
}
|
||||
@@ -118,6 +118,7 @@ import {
|
||||
getModelLikeIdSegments,
|
||||
stripBracketedModelIdAffixes,
|
||||
} from "./model-id-affixes";
|
||||
import { buildModelProviderPriorityRank } from "./model-provider-priority";
|
||||
import {
|
||||
type ModelOverride,
|
||||
type ModelsConfig,
|
||||
@@ -2208,27 +2209,8 @@ export class ModelRegistry {
|
||||
});
|
||||
}
|
||||
|
||||
#providerRank(models: readonly Model<Api>[]): Map<string, number> {
|
||||
const configuredProviders = getConfiguredProviderOrderFromSettings();
|
||||
const result = new Map<string, number>();
|
||||
let nextRank = 0;
|
||||
for (const provider of configuredProviders) {
|
||||
const normalized = provider.trim().toLowerCase();
|
||||
if (!normalized || result.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
result.set(normalized, nextRank);
|
||||
nextRank += 1;
|
||||
}
|
||||
for (const model of models) {
|
||||
const normalized = model.provider.toLowerCase();
|
||||
if (result.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
result.set(normalized, nextRank);
|
||||
nextRank += 1;
|
||||
}
|
||||
return result;
|
||||
#providerRank(): Map<string, number> {
|
||||
return buildModelProviderPriorityRank(getConfiguredProviderOrderFromSettings());
|
||||
}
|
||||
|
||||
#resolveCanonicalVariant(
|
||||
@@ -2238,7 +2220,7 @@ export class ModelRegistry {
|
||||
if (variants.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const providerRank = this.#providerRank(allCandidates);
|
||||
const providerRank = this.#providerRank();
|
||||
const modelOrder = new Map<string, number>();
|
||||
for (let index = 0; index < allCandidates.length; index += 1) {
|
||||
modelOrder.set(formatCanonicalVariantSelector(allCandidates[index]!), index);
|
||||
|
||||
@@ -17,6 +17,7 @@ import { logger } from "@oh-my-pi/pi-utils";
|
||||
import chalk from "chalk";
|
||||
import MODEL_PRIO from "../priority.json" with { type: "json" };
|
||||
import { parseThinkingLevel, resolveThinkingLevelForModel } from "../thinking";
|
||||
import { buildModelProviderPriorityRank } from "./model-provider-priority";
|
||||
import { isAuthenticated, kNoAuth, MODEL_ROLE_IDS, type ModelRegistry, type ModelRole } from "./model-registry";
|
||||
import type { Settings } from "./settings";
|
||||
|
||||
@@ -179,7 +180,9 @@ export function resolveProviderModelReference(
|
||||
export interface ModelMatchPreferences {
|
||||
/** Most-recently-used model keys (provider/modelId) to prefer when ambiguous. */
|
||||
usageOrder?: string[];
|
||||
/** Providers to deprioritize when no recent usage is available. */
|
||||
/** Provider precedence used for ambiguous unqualified model patterns. */
|
||||
providerOrder?: readonly string[];
|
||||
/** Providers to deprioritize when no recent usage or provider priority is available. */
|
||||
deprioritizeProviders?: string[];
|
||||
}
|
||||
|
||||
@@ -194,6 +197,7 @@ type RestorableModelRegistry = Pick<ModelRegistry, "getAvailable" | "find" | "ge
|
||||
interface ModelPreferenceContext {
|
||||
modelUsageRank: Map<string, number>;
|
||||
providerUsageRank: Map<string, number>;
|
||||
providerPriorityRank: Map<string, number>;
|
||||
deprioritizedProviders: Set<string>;
|
||||
modelOrder: Map<string, number>;
|
||||
}
|
||||
@@ -215,14 +219,35 @@ function buildPreferenceContext(
|
||||
providerUsageRank.set(parsed.provider, i);
|
||||
}
|
||||
}
|
||||
|
||||
const deprioritizedProviders = new Set(preferences?.deprioritizeProviders ?? ["openrouter"]);
|
||||
const providerPriorityRank = buildModelProviderPriorityRank(preferences?.providerOrder);
|
||||
const deprioritizedProviders = new Set(preferences?.deprioritizeProviders ?? []);
|
||||
const modelOrder = new Map<string, number>();
|
||||
for (let i = 0; i < availableModels.length; i += 1) {
|
||||
modelOrder.set(formatModelString(availableModels[i]), i);
|
||||
}
|
||||
|
||||
return { modelUsageRank, providerUsageRank, deprioritizedProviders, modelOrder };
|
||||
return { modelUsageRank, providerUsageRank, providerPriorityRank, deprioritizedProviders, modelOrder };
|
||||
}
|
||||
|
||||
export function getModelMatchPreferences(
|
||||
settings?: Partial<Pick<Settings, "get" | "getStorage">>,
|
||||
): ModelMatchPreferences {
|
||||
return {
|
||||
usageOrder: settings?.getStorage?.()?.getModelUsageOrder(),
|
||||
providerOrder: settings?.get?.("modelProviderOrder"),
|
||||
};
|
||||
}
|
||||
|
||||
function mergeModelMatchPreferences(
|
||||
settings: Settings | undefined,
|
||||
preferences: ModelMatchPreferences | undefined,
|
||||
): ModelMatchPreferences {
|
||||
const settingsPreferences = getModelMatchPreferences(settings);
|
||||
return {
|
||||
usageOrder: preferences?.usageOrder ?? settingsPreferences.usageOrder,
|
||||
providerOrder: preferences?.providerOrder ?? settingsPreferences.providerOrder,
|
||||
deprioritizeProviders: preferences?.deprioritizeProviders,
|
||||
};
|
||||
}
|
||||
|
||||
function pickPreferredModel(candidates: Model<Api>[], context: ModelPreferenceContext): Model<Api> {
|
||||
@@ -236,6 +261,12 @@ function pickPreferredModel(candidates: Model<Api>[], context: ModelPreferenceCo
|
||||
return (aUsage ?? Number.POSITIVE_INFINITY) - (bUsage ?? Number.POSITIVE_INFINITY);
|
||||
}
|
||||
|
||||
const aProviderPriority = context.providerPriorityRank.get(a.provider.toLowerCase());
|
||||
const bProviderPriority = context.providerPriorityRank.get(b.provider.toLowerCase());
|
||||
if (aProviderPriority !== undefined || bProviderPriority !== undefined) {
|
||||
return (aProviderPriority ?? Number.POSITIVE_INFINITY) - (bProviderPriority ?? Number.POSITIVE_INFINITY);
|
||||
}
|
||||
|
||||
const aProviderUsage = context.providerUsageRank.get(a.provider);
|
||||
const bProviderUsage = context.providerUsageRank.get(b.provider);
|
||||
if (aProviderUsage !== undefined || bProviderUsage !== undefined) {
|
||||
@@ -618,8 +649,9 @@ export function resolveModelRoleValue(
|
||||
}
|
||||
|
||||
let warning: string | undefined;
|
||||
const matchPreferences = mergeModelMatchPreferences(options?.settings, options?.matchPreferences);
|
||||
for (const effectivePattern of effectivePatterns) {
|
||||
const resolved = parseModelPattern(effectivePattern, availableModels, options?.matchPreferences, {
|
||||
const resolved = parseModelPattern(effectivePattern, availableModels, matchPreferences, {
|
||||
modelRegistry: options?.modelRegistry,
|
||||
});
|
||||
if (resolved.model) {
|
||||
@@ -720,7 +752,7 @@ export function resolveModelOverride(
|
||||
): { model?: Model<Api>; thinkingLevel?: ThinkingLevel; explicitThinkingLevel: boolean } {
|
||||
if (modelPatterns.length === 0) return { explicitThinkingLevel: false };
|
||||
const availableModels = modelRegistry.getAvailable();
|
||||
const matchPreferences = { usageOrder: settings?.getStorage()?.getModelUsageOrder() };
|
||||
const matchPreferences = getModelMatchPreferences(settings);
|
||||
for (const pattern of modelPatterns) {
|
||||
const { model, thinkingLevel, explicitThinkingLevel } = resolveModelRoleValue(pattern, availableModels, {
|
||||
settings,
|
||||
@@ -800,7 +832,7 @@ export function resolveRoleSelection(
|
||||
availableModels: Model<Api>[],
|
||||
modelRegistry?: CanonicalModelRegistry,
|
||||
): { model: Model<Api>; thinkingLevel?: ThinkingLevel } | undefined {
|
||||
const matchPreferences = { usageOrder: settings.getStorage()?.getModelUsageOrder() };
|
||||
const matchPreferences = getModelMatchPreferences(settings);
|
||||
for (const role of roles) {
|
||||
const resolved = resolveModelRoleValue(settings.getModelRole(role), availableModels, {
|
||||
settings,
|
||||
|
||||
@@ -72,7 +72,7 @@ export interface SettingsOptions {
|
||||
/**
|
||||
* Get a nested value from an object by path segments.
|
||||
*/
|
||||
function getByPath(obj: RawSettings, segments: string[]): unknown {
|
||||
function getByPath(obj: RawSettings, segments: readonly string[]): unknown {
|
||||
let current: unknown = obj;
|
||||
for (const segment of segments) {
|
||||
if (current === null || current === undefined || typeof current !== "object") {
|
||||
@@ -83,6 +83,10 @@ function getByPath(obj: RawSettings, segments: string[]): unknown {
|
||||
return current;
|
||||
}
|
||||
|
||||
const SETTING_PATH_SEGMENTS: Record<SettingPath, readonly string[]> = Object.fromEntries(
|
||||
(Object.keys(SETTINGS_SCHEMA) as SettingPath[]).map(settingPath => [settingPath, settingPath.split(".")]),
|
||||
) as unknown as Record<SettingPath, readonly string[]>;
|
||||
|
||||
/**
|
||||
* Set a nested value in an object by path segments.
|
||||
* Creates intermediate objects as needed.
|
||||
@@ -196,6 +200,8 @@ export class Settings {
|
||||
#overrides: RawSettings = {};
|
||||
/** Merged view (global + project + overrides) */
|
||||
#merged: RawSettings = {};
|
||||
/** Cached resolved values from the merged view, including defaults/path scoping */
|
||||
#resolvedCache = new Map<SettingPath, unknown>();
|
||||
|
||||
/** Paths modified during this session (for partial save) */
|
||||
#modified = new Set<string>();
|
||||
@@ -282,13 +288,15 @@ export class Settings {
|
||||
* Returns the merged value from global + project + overrides, or the default.
|
||||
*/
|
||||
get<P extends SettingPath>(path: P): SettingValue<P> {
|
||||
const segments = path.split(".");
|
||||
const value = getByPath(this.#merged, segments);
|
||||
if (value !== undefined) {
|
||||
const pathScopedValue = resolvePathScopedStringArray(path, value, this.#cwd);
|
||||
return (pathScopedValue ?? value) as SettingValue<P>;
|
||||
if (this.#resolvedCache.has(path)) {
|
||||
return this.#resolvedCache.get(path) as SettingValue<P>;
|
||||
}
|
||||
return getDefault(path);
|
||||
|
||||
const value = getByPath(this.#merged, SETTING_PATH_SEGMENTS[path]);
|
||||
const resolved =
|
||||
value !== undefined ? (resolvePathScopedStringArray(path, value, this.#cwd) ?? value) : getDefault(path);
|
||||
this.#resolvedCache.set(path, resolved);
|
||||
return resolved as SettingValue<P>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -302,6 +310,7 @@ export class Settings {
|
||||
setByPath(this.#global, segments, value);
|
||||
this.#modified.add(path);
|
||||
this.#rebuildMerged();
|
||||
const next = this.get(path);
|
||||
this.#queueSave();
|
||||
|
||||
// Trigger hook if exists
|
||||
@@ -309,21 +318,25 @@ export class Settings {
|
||||
if (hook) {
|
||||
hook(value, prev);
|
||||
}
|
||||
this.#fireEffectiveSettingChanged(path, next, prev);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply runtime overrides (not persisted).
|
||||
*/
|
||||
override<P extends SettingPath>(path: P, value: SettingValue<P>): void {
|
||||
const prev = this.get(path);
|
||||
const segments = path.split(".");
|
||||
setByPath(this.#overrides, segments, value);
|
||||
this.#rebuildMerged();
|
||||
this.#fireEffectiveSettingChanged(path, this.get(path), prev);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a runtime override.
|
||||
*/
|
||||
clearOverride(path: SettingPath): void {
|
||||
const prev = this.get(path);
|
||||
const segments = path.split(".");
|
||||
let current = this.#overrides;
|
||||
for (let i = 0; i < segments.length - 1; i++) {
|
||||
@@ -333,6 +346,14 @@ export class Settings {
|
||||
}
|
||||
delete current[segments[segments.length - 1]];
|
||||
this.#rebuildMerged();
|
||||
this.#fireEffectiveSettingChanged(path, this.get(path), prev);
|
||||
}
|
||||
|
||||
#fireEffectiveSettingChanged(path: SettingPath, value: unknown, prev: unknown): void {
|
||||
if (Object.is(value, prev)) return;
|
||||
if (path === "statusLine.sessionAccent") {
|
||||
statusLineSessionAccentSignal.fire();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -842,6 +863,7 @@ export class Settings {
|
||||
#rebuildMerged(): void {
|
||||
this.#merged = this.#deepMerge(this.#deepMerge({}, this.#global), this.#project);
|
||||
this.#merged = this.#deepMerge(this.#merged, this.#overrides);
|
||||
this.#resolvedCache.clear();
|
||||
}
|
||||
|
||||
#fireAllHooks(): void {
|
||||
@@ -885,6 +907,45 @@ export class Settings {
|
||||
|
||||
type SettingHook<P extends SettingPath> = (value: SettingValue<P>, prev: SettingValue<P>) => void;
|
||||
|
||||
/**
|
||||
* Minimal change-notification primitive backing the exported `on*Changed`
|
||||
* subscriptions. Holds a listener set, hands out unsubscribe closures, and
|
||||
* isolates errors so a single throwing listener can't abort the rest or bubble
|
||||
* out of `Settings.set()`.
|
||||
*
|
||||
* @typeParam A - argument tuple forwarded to each listener on `fire`.
|
||||
*/
|
||||
class SettingSignal<A extends unknown[] = []> {
|
||||
#listeners = new Set<(...args: A) => void>();
|
||||
|
||||
constructor(private readonly label: string) {}
|
||||
|
||||
/** Subscribe `cb`; returns an unsubscribe function. */
|
||||
on(cb: (...args: A) => void): () => void {
|
||||
this.#listeners.add(cb);
|
||||
return () => {
|
||||
this.#listeners.delete(cb);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke every listener with `args`. Iterates a snapshot so a listener may
|
||||
* (un)subscribe mid-fire without re-entrancy — the Hindsight backend
|
||||
* re-registers the fresh state's listener on every rebuild — and wraps each
|
||||
* call so a throwing listener is logged and skipped instead of aborting the
|
||||
* rest.
|
||||
*/
|
||||
fire(...args: A): void {
|
||||
for (const cb of [...this.#listeners]) {
|
||||
try {
|
||||
cb(...args);
|
||||
} catch (err) {
|
||||
logger.warn(`Settings: ${this.label} hook failed`, { error: String(err) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SETTING_HOOKS: Partial<Record<SettingPath, SettingHook<any>>> = {
|
||||
"theme.dark": value => {
|
||||
if (typeof value === "string") {
|
||||
@@ -917,45 +978,34 @@ const SETTING_HOOKS: Partial<Record<SettingPath, SettingHook<any>>> = {
|
||||
},
|
||||
"provider.appendOnlyContext": value => {
|
||||
if (typeof value === "string") {
|
||||
for (const cb of appendOnlyModeCallbacks) cb(value);
|
||||
appendOnlyModeSignal.fire(value);
|
||||
}
|
||||
},
|
||||
"hindsight.bankId": () => fireHindsightScopeChanged(),
|
||||
"hindsight.bankIdPrefix": () => fireHindsightScopeChanged(),
|
||||
"hindsight.scoping": () => fireHindsightScopeChanged(),
|
||||
"hindsight.bankId": () => hindsightScopeSignal.fire(),
|
||||
"hindsight.bankIdPrefix": () => hindsightScopeSignal.fire(),
|
||||
"hindsight.scoping": () => hindsightScopeSignal.fire(),
|
||||
};
|
||||
/** Callbacks invoked when `provider.appendOnlyContext` changes at runtime. */
|
||||
const appendOnlyModeCallbacks = new Set<(value: string) => void>();
|
||||
/** Fires when `provider.appendOnlyContext` changes at runtime. */
|
||||
const appendOnlyModeSignal = new SettingSignal<[value: string]>("provider.appendOnlyContext");
|
||||
|
||||
/**
|
||||
* Subscribe to append-only mode setting changes.
|
||||
* Returns an unsubscribe function. Multiple sessions (main + subagents)
|
||||
* can register independently without overwriting each other.
|
||||
*/
|
||||
export function onAppendOnlyModeChanged(cb: (value: string) => void): () => void {
|
||||
appendOnlyModeCallbacks.add(cb);
|
||||
return () => {
|
||||
appendOnlyModeCallbacks.delete(cb);
|
||||
};
|
||||
}
|
||||
export const onAppendOnlyModeChanged = (cb: (value: string) => void) => appendOnlyModeSignal.on(cb);
|
||||
|
||||
/** Callbacks fired when any `hindsight.bankId` / `bankIdPrefix` / `scoping` value changes. */
|
||||
const hindsightScopeCallbacks = new Set<() => void>();
|
||||
/** Fires when `statusLine.sessionAccent` changes at runtime. */
|
||||
const statusLineSessionAccentSignal = new SettingSignal("statusLine.sessionAccent");
|
||||
|
||||
function fireHindsightScopeChanged(): void {
|
||||
// Snapshot the callback set before invoking — a callback's body is allowed
|
||||
// to subscribe a NEW callback (the Hindsight backend re-registers the
|
||||
// fresh state's listener on every rebuild). Iterating the live Set would
|
||||
// re-invoke those just-added callbacks within the same fire, which spins
|
||||
// in place: subscribe → invoke → subscribe → invoke → …
|
||||
for (const cb of [...hindsightScopeCallbacks]) {
|
||||
try {
|
||||
cb();
|
||||
} catch (err) {
|
||||
logger.warn("Settings: hindsight scope hook failed", { error: String(err) });
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Subscribe to session-accent setting changes.
|
||||
* Returns an unsubscribe function. Callers should re-read settings in the callback.
|
||||
*/
|
||||
export const onStatusLineSessionAccentChanged = (cb: () => void) => statusLineSessionAccentSignal.on(cb);
|
||||
|
||||
/** Fires when any `hindsight.bankId` / `bankIdPrefix` / `scoping` value changes. */
|
||||
const hindsightScopeSignal = new SettingSignal("hindsight scope");
|
||||
|
||||
/**
|
||||
* Subscribe to changes in the Hindsight bank-scoping settings. Lets the
|
||||
@@ -967,12 +1017,7 @@ function fireHindsightScopeChanged(): void {
|
||||
* Returns an unsubscribe function. The callback receives no arguments — the
|
||||
* caller is expected to re-read the relevant settings via `Settings.get`.
|
||||
*/
|
||||
export function onHindsightScopeChanged(cb: () => void): () => void {
|
||||
hindsightScopeCallbacks.add(cb);
|
||||
return () => {
|
||||
hindsightScopeCallbacks.delete(cb);
|
||||
};
|
||||
}
|
||||
export const onHindsightScopeChanged = (cb: () => void) => hindsightScopeSignal.on(cb);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Global Singleton
|
||||
|
||||
@@ -195,6 +195,7 @@ export class DebugSelectorComponent extends Container {
|
||||
const result = await createReportBundle({
|
||||
sessionFile: this.ctx.sessionManager.getSessionFile(),
|
||||
settings: this.#getResolvedSettings(),
|
||||
rawSseText: this.#getRawSseText(),
|
||||
cpuProfile,
|
||||
workProfile,
|
||||
});
|
||||
@@ -253,6 +254,7 @@ export class DebugSelectorComponent extends Container {
|
||||
const result = await createReportBundle({
|
||||
sessionFile: this.ctx.sessionManager.getSessionFile(),
|
||||
settings: this.#getResolvedSettings(),
|
||||
rawSseText: this.#getRawSseText(),
|
||||
});
|
||||
|
||||
loader.stop();
|
||||
@@ -288,6 +290,7 @@ export class DebugSelectorComponent extends Container {
|
||||
const result = await createReportBundle({
|
||||
sessionFile: this.ctx.sessionManager.getSessionFile(),
|
||||
settings: this.#getResolvedSettings(),
|
||||
rawSseText: this.#getRawSseText(),
|
||||
heapSnapshot,
|
||||
});
|
||||
|
||||
@@ -490,6 +493,11 @@ export class DebugSelectorComponent extends Container {
|
||||
}
|
||||
}
|
||||
|
||||
#getRawSseText(): string | undefined {
|
||||
const rawSseText = resolveRawSseDebugBuffer(this.ctx.session).toRawText();
|
||||
return rawSseText.trim().length > 0 ? rawSseText : undefined;
|
||||
}
|
||||
|
||||
#getResolvedSettings(): Record<string, unknown> {
|
||||
// Extract key settings for the report
|
||||
return {
|
||||
|
||||
@@ -152,9 +152,9 @@ export class RawSseDebugBuffer {
|
||||
}
|
||||
|
||||
// Ownership contract for `event.raw`:
|
||||
// The caller (either `notifyRawSseEvent` in `packages/ai/src/utils/sse-debug.ts`
|
||||
// or `SseTeeParser.#dispatch` directly) hands us a freshly-allocated
|
||||
// `string[]` per event and never retains, mutates, or re-dispatches it.
|
||||
// The caller (`notifyRawSseEvent` in `packages/ai/src/utils/sse-debug.ts`)
|
||||
// hands us a freshly-allocated `string[]` per event and never retains,
|
||||
// mutates, or re-dispatches it.
|
||||
// That lets `trimRawLines` keep the array by reference instead of
|
||||
// cloning on every chunk — a measurable savings on the streaming hot
|
||||
// path. If a future observer-chain mutates the array, restore the
|
||||
@@ -192,7 +192,10 @@ export class RawSseDebugBuffer {
|
||||
toRawText(): string {
|
||||
// Reads the live array directly: `rawRecordText` only computes a string
|
||||
// from each record, so no caller-visible mutation is possible.
|
||||
return this.#records.map(rawRecordText).join("\n");
|
||||
const body = this.#records.map(rawRecordText).join("\n");
|
||||
if (this.#droppedRecords === 0) return body;
|
||||
const dropped = `: omp-debug-dropped records=${this.#droppedRecords} chars=${this.#droppedChars}\n\n`;
|
||||
return body.length > 0 ? `${dropped}${body}` : dropped;
|
||||
}
|
||||
|
||||
#append(record: RawSseDebugRecord, chars: number): void {
|
||||
|
||||
@@ -45,6 +45,8 @@ export interface ReportBundleOptions {
|
||||
heapSnapshot?: HeapSnapshot;
|
||||
/** Work profile (for work scheduling reports) */
|
||||
workProfile?: WorkProfile;
|
||||
/** Raw provider SSE diagnostics captured by the session buffer */
|
||||
rawSseText?: string;
|
||||
}
|
||||
|
||||
export interface ReportBundleResult {
|
||||
@@ -70,6 +72,7 @@ export interface DebugLogSource {
|
||||
* - env.json: Sanitized environment variables
|
||||
* - config.json: Resolved settings
|
||||
* - profile.cpuprofile: CPU profile (performance report only)
|
||||
* - raw-sse.txt: Recent raw provider SSE diagnostics (when captured)
|
||||
* - profile.md: Markdown CPU profile (performance report only)
|
||||
* - heap.heapsnapshot: Heap snapshot (memory report only)
|
||||
* - work.folded: Work profile folded stacks (work report only)
|
||||
@@ -109,6 +112,12 @@ export async function createReportBundle(options: ReportBundleOptions): Promise<
|
||||
files.push("logs.txt");
|
||||
}
|
||||
|
||||
// Recent raw provider SSE diagnostics
|
||||
if (options.rawSseText && options.rawSseText.trim().length > 0) {
|
||||
data["raw-sse.txt"] = options.rawSseText;
|
||||
files.push("raw-sse.txt");
|
||||
}
|
||||
|
||||
// Session file
|
||||
if (options.sessionFile) {
|
||||
try {
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
* from `@oh-my-pi/hashline`; the only coding-agent-specific concern here
|
||||
* is wiring it onto the per-session owner object.
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { InMemorySnapshotStore } from "@oh-my-pi/hashline";
|
||||
import { normalizeToLF } from "./normalize";
|
||||
|
||||
@@ -33,6 +35,36 @@ export function getFileSnapshotStore(session: FileSnapshotStoreOwner): InMemoryS
|
||||
return session.fileSnapshotStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalize an absolute path into the stable key the snapshot store uses.
|
||||
*
|
||||
* Different code paths reach the snapshot store via different path forms:
|
||||
* `read local://foo.md` records under the file's `fs.realpath` (the local
|
||||
* protocol handler resolves symlinks); a subsequent `edit` may address the
|
||||
* same artifact via `local://foo.md`, whose resolver does NOT realpath, or
|
||||
* via the absolute path returned in the `[path#tag]` header. macOS adds the
|
||||
* same hazard at the working-tree level (`/tmp/...` vs `/private/tmp/...`).
|
||||
* Collapsing every key through `realpath` makes those forms fuse onto one
|
||||
* snapshot entry, so a freshly-minted tag is never rejected as stale just
|
||||
* because the lookup spelled the same file differently.
|
||||
*
|
||||
* Non-existent paths (new-file writes) fall back to a realpath of the parent
|
||||
* directory + basename, then to the input. This keeps creates and updates on
|
||||
* the same canonical key.
|
||||
*/
|
||||
export function canonicalSnapshotKey(absolutePath: string): string {
|
||||
try {
|
||||
return fs.realpathSync.native(absolutePath);
|
||||
} catch {
|
||||
try {
|
||||
const parent = fs.realpathSync.native(path.dirname(absolutePath));
|
||||
return path.join(parent, path.basename(absolutePath));
|
||||
} catch {
|
||||
return absolutePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the full text of `absolutePath` (within {@link SNAPSHOT_MAX_BYTES}),
|
||||
* record it as a version snapshot, and return its content-hash tag. Returns
|
||||
@@ -52,7 +84,7 @@ export async function recordFileSnapshot(
|
||||
const file = Bun.file(absolutePath);
|
||||
if (file.size > SNAPSHOT_MAX_BYTES) return undefined;
|
||||
const normalized = normalizeToLF(await file.text());
|
||||
return getFileSnapshotStore(session).record(absolutePath, normalized);
|
||||
return getFileSnapshotStore(session).record(canonicalSnapshotKey(absolutePath), normalized);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import {
|
||||
type ApplyResult,
|
||||
applyEdits,
|
||||
type Cursor,
|
||||
computeFileHash,
|
||||
type Edit,
|
||||
Patch as HashlinePatch,
|
||||
@@ -131,6 +132,86 @@ function applyPreviewEdits(args: {
|
||||
throw createMismatchError(section, absolutePath, normalized, snapshots, expected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an insert cursor to the 1-indexed line where its payload lands, used to
|
||||
* number the `+` rows of a streaming preview. Deliberately approximate: it
|
||||
* ignores line shifts introduced by sibling ops, because the args-complete
|
||||
* pass renumbers everything through the real unified diff.
|
||||
*/
|
||||
function insertCursorLine(cursor: Cursor, fileLineCount: number): number {
|
||||
switch (cursor.kind) {
|
||||
case "bof":
|
||||
return 1;
|
||||
case "eof":
|
||||
return fileLineCount + 1;
|
||||
case "before_anchor":
|
||||
return cursor.anchor.line;
|
||||
case "after_anchor":
|
||||
return cursor.anchor.line + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a streaming diff preview by emitting, per op in patch order, the
|
||||
* removed file lines followed by the op's `+` payload rows — never a whole-file
|
||||
* Myers re-diff. {@link generateDiffString} re-aligns the in-flight payload
|
||||
* against the removed block on every streamed chunk (it greedily matches shared
|
||||
* `}`/blank/`return` rows), so additions jump between hunks and the tail window
|
||||
* the renderer pins stutters tick to tick. Natural order keeps the removed
|
||||
* block fixed and grows the payload monotonically at the bottom so the streamed
|
||||
* cursor stays put. Mirrors the apply_patch streaming strategy; the
|
||||
* args-complete pass still produces the real unified diff.
|
||||
*/
|
||||
function buildStreamingSectionDiff(
|
||||
section: PatchSection,
|
||||
normalized: string,
|
||||
): { diff: string; firstChangedLine: number | undefined } | { error: string } {
|
||||
const { edits } = parsePatchStreaming(section.diff);
|
||||
const resolved = resolveBlockEdits(edits, normalized, section.path, nativeBlockResolver, { onUnresolved: "drop" });
|
||||
if (resolved.length === 0) return { error: `No changes would be made to ${section.path}.` };
|
||||
|
||||
const fileLines = normalized.split("\n");
|
||||
const rows: string[] = [];
|
||||
let firstChangedLine: number | undefined;
|
||||
|
||||
// Every edit emitted from one op header carries that header's patch line
|
||||
// number and the edits sit contiguously (a replace lays down its replacement
|
||||
// inserts then its range deletes; block ops expand to the same shape). Group
|
||||
// on that boundary so each op stays intact and ordered.
|
||||
for (let i = 0; i < resolved.length; ) {
|
||||
const opLine = resolved[i].lineNum;
|
||||
const deletes: number[] = [];
|
||||
const inserts: string[] = [];
|
||||
let insertBase: number | undefined;
|
||||
while (i < resolved.length && resolved[i].lineNum === opLine) {
|
||||
const edit = resolved[i];
|
||||
if (edit.kind === "delete") deletes.push(edit.anchor.line);
|
||||
else if (edit.kind === "insert") {
|
||||
insertBase ??= insertCursorLine(edit.cursor, fileLines.length);
|
||||
inserts.push(edit.text);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
// Removed lines first (a fixed block), payload second (grows at the
|
||||
// bottom = the streamed cursor).
|
||||
deletes.sort((a, b) => a - b);
|
||||
for (const line of deletes) {
|
||||
firstChangedLine ??= line;
|
||||
const content = line >= 1 && line <= fileLines.length ? fileLines[line - 1] : "";
|
||||
rows.push(`-${line}|${content}`);
|
||||
}
|
||||
let newLine = insertBase ?? deletes[0] ?? 1;
|
||||
for (const text of inserts) {
|
||||
firstChangedLine ??= newLine;
|
||||
rows.push(`+${newLine}|${text}`);
|
||||
newLine++;
|
||||
}
|
||||
}
|
||||
|
||||
if (rows.length === 0) return { error: `No changes would be made to ${section.path}.` };
|
||||
return { diff: rows.join("\n"), firstChangedLine };
|
||||
}
|
||||
|
||||
export async function computeHashlineSectionDiff(
|
||||
section: PatchSection,
|
||||
cwd: string,
|
||||
@@ -142,6 +223,11 @@ export async function computeHashlineSectionDiff(
|
||||
const rawContent = await readSectionText(absolutePath, section.path);
|
||||
const { text: content } = stripBom(rawContent);
|
||||
const normalized = normalizeToLF(content);
|
||||
// Streaming favors a stable, monotonic preview over an exact unified
|
||||
// diff: feed the in-flight ops through the natural-order builder so the
|
||||
// streamed cursor stays pinned to the bottom. The args-complete pass
|
||||
// (`streaming` unset) falls through to the real Myers diff below.
|
||||
if (options.streaming) return buildStreamingSectionDiff(section, normalized);
|
||||
const result = applyPreviewEdits({ section, absolutePath, normalized, snapshots, options });
|
||||
if (normalized === result.text) return { error: `No changes would be made to ${section.path}.` };
|
||||
return generateDiffString(normalized, result.text);
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* round-trip once.
|
||||
*/
|
||||
import {
|
||||
type BlockResolution,
|
||||
buildCompactDiffPreview,
|
||||
MismatchError as HashlineMismatchError,
|
||||
Patch,
|
||||
@@ -76,6 +77,14 @@ interface RenderedSection {
|
||||
perFileResult: EditToolPerFileResult;
|
||||
}
|
||||
|
||||
function formatBlockResolution(resolution: BlockResolution): string {
|
||||
const op = resolution.isDelete ? "delete block" : "replace block";
|
||||
const lines = resolution.end - resolution.start + 1;
|
||||
const span =
|
||||
resolution.start === resolution.end ? `line ${resolution.start}` : `lines ${resolution.start}-${resolution.end}`;
|
||||
return `${op} ${resolution.anchorLine} → resolved ${span} (${lines} line${lines === 1 ? "" : "s"})`;
|
||||
}
|
||||
|
||||
function renderSection(result: PatchSectionResult, diagnostics: FileDiagnosticsResult | undefined): RenderedSection {
|
||||
if (result.op === "noop") {
|
||||
const toolResult: AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema> = {
|
||||
@@ -96,10 +105,14 @@ function renderSection(result: PatchSectionResult, diagnostics: FileDiagnosticsR
|
||||
|
||||
const warningsBlock = result.warnings.length > 0 ? `\n\nWarnings:\n${result.warnings.join("\n")}` : "";
|
||||
const previewBlock = preview.preview ? `\n${preview.preview}` : "";
|
||||
const blockBlock =
|
||||
result.blockResolutions && result.blockResolutions.length > 0
|
||||
? `\n${result.blockResolutions.map(formatBlockResolution).join("\n")}`
|
||||
: "";
|
||||
const firstChangedLine = result.firstChangedLine ?? diff.firstChangedLine;
|
||||
return {
|
||||
toolResult: {
|
||||
content: [{ type: "text", text: `${result.header}${previewBlock}${warningsBlock}` }],
|
||||
content: [{ type: "text", text: `${result.header}${blockBlock}${previewBlock}${warningsBlock}` }],
|
||||
details: {
|
||||
diff: diff.diff,
|
||||
firstChangedLine,
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { ToolSession } from "../../tools";
|
||||
import { assertEditableFileContent } from "../../tools/auto-generated-guard";
|
||||
import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation";
|
||||
import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
|
||||
import { canonicalSnapshotKey } from "../file-snapshot-store";
|
||||
import { readEditFileText, serializeEditFileText } from "../read-file";
|
||||
import type { LspBatchRequest } from "../renderer";
|
||||
|
||||
@@ -81,7 +82,7 @@ export class HashlineFilesystem extends Filesystem {
|
||||
}
|
||||
|
||||
canonicalPath(relativePath: string): string {
|
||||
return this.resolveAbsolute(relativePath);
|
||||
return canonicalSnapshotKey(this.resolveAbsolute(relativePath));
|
||||
}
|
||||
|
||||
async readText(relativePath: string): Promise<string> {
|
||||
|
||||
@@ -14,7 +14,7 @@ import { getDiagnosticsLedger } from "../lsp/diagnostics-ledger";
|
||||
import applyPatchDescription from "../prompts/tools/apply-patch.md" with { type: "text" };
|
||||
import patchDescription from "../prompts/tools/patch.md" with { type: "text" };
|
||||
import replaceDescription from "../prompts/tools/replace.md" with { type: "text" };
|
||||
import type { ToolSession } from "../tools";
|
||||
import type { DeferredDiagnosticsEntry, ToolSession } from "../tools";
|
||||
import { truncateForPrompt } from "../tools/approval";
|
||||
import { isInternalUrlPath } from "../tools/path-utils";
|
||||
import { type EditMode, normalizeEditMode, resolveEditMode } from "../utils/edit-mode";
|
||||
@@ -297,7 +297,6 @@ export class EditTool implements AgentTool<TInput> {
|
||||
readonly name = "edit";
|
||||
readonly label = "Edit";
|
||||
readonly loadMode = "essential";
|
||||
readonly nonAbortable = true;
|
||||
readonly concurrency = "exclusive";
|
||||
readonly strict = true;
|
||||
|
||||
@@ -307,6 +306,10 @@ export class EditTool implements AgentTool<TInput> {
|
||||
readonly #editMode?: EditMode;
|
||||
readonly #dedupDiagnostics: boolean;
|
||||
readonly #pendingDeferredFetches = new Map<string, AbortController>();
|
||||
/** Fallback per-path mutation counter used only when the session does not expose
|
||||
* a shared one. Prefer `session.bumpFileMutationVersion` so write (and any other
|
||||
* tool) mutating the same file also invalidates pending late-diagnostics. */
|
||||
readonly #editVersionByPath = new Map<string, number>();
|
||||
|
||||
constructor(private readonly session: ToolSession) {
|
||||
const {
|
||||
@@ -503,10 +506,11 @@ export class EditTool implements AgentTool<TInput> {
|
||||
}
|
||||
|
||||
const deferredController = new AbortController();
|
||||
const editVersion = this.#bumpFileVersion(path);
|
||||
return {
|
||||
onDeferredDiagnostics: (lateDiagnostics: FileDiagnosticsResult) => {
|
||||
this.#pendingDeferredFetches.delete(path);
|
||||
this.#injectLateDiagnostics(path, lateDiagnostics);
|
||||
this.#injectLateDiagnostics(path, lateDiagnostics, editVersion);
|
||||
},
|
||||
signal: deferredController.signal,
|
||||
finalize: (diagnostics: FileDiagnosticsResult | undefined) => {
|
||||
@@ -519,24 +523,34 @@ export class EditTool implements AgentTool<TInput> {
|
||||
};
|
||||
}
|
||||
|
||||
#injectLateDiagnostics(path: string, diagnostics: FileDiagnosticsResult): void {
|
||||
#injectLateDiagnostics(path: string, diagnostics: FileDiagnosticsResult, editVersion: number): void {
|
||||
const effective = this.#dedupDiagnostics
|
||||
? getDiagnosticsLedger(this.session).reduce(path, diagnostics)
|
||||
: diagnostics;
|
||||
if (this.#dedupDiagnostics && effective.messages.length === 0) return;
|
||||
|
||||
const summary = effective.summary ?? "";
|
||||
const lines = effective.messages ?? [];
|
||||
const body = [`Late LSP diagnostics for ${path} (arrived after the edit tool returned):`, summary, ...lines]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
const entry: DeferredDiagnosticsEntry = {
|
||||
path,
|
||||
summary: effective.summary ?? "",
|
||||
messages: effective.messages ?? [],
|
||||
errored: effective.errored,
|
||||
// Drop at flush time if a later edit to the same file superseded this fetch.
|
||||
isStale: () => this.#fileVersion(path) !== editVersion,
|
||||
};
|
||||
this.session.queueDeferredDiagnostics?.(entry);
|
||||
}
|
||||
|
||||
this.session.queueDeferredMessage?.({
|
||||
role: "custom",
|
||||
customType: "lsp-late-diagnostic",
|
||||
content: body,
|
||||
display: false,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
/** Bump the file's mutation counter (session-global when available). */
|
||||
#bumpFileVersion(path: string): number {
|
||||
if (this.session.bumpFileMutationVersion) return this.session.bumpFileMutationVersion(path);
|
||||
const next = (this.#editVersionByPath.get(path) ?? 0) + 1;
|
||||
this.#editVersionByPath.set(path, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Read the file's current mutation counter (session-global when available). */
|
||||
#fileVersion(path: string): number {
|
||||
if (this.session.getFileMutationVersion) return this.session.getFileMutationVersion(path);
|
||||
return this.#editVersionByPath.get(path) ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { HL_FILE_PREFIX, HL_FILE_SUFFIX } from "@oh-my-pi/hashline";
|
||||
import type { Component } from "@oh-my-pi/pi-tui";
|
||||
import { visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
|
||||
import { sliceWithWidth, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
|
||||
import { sanitizeText } from "@oh-my-pi/pi-utils";
|
||||
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
||||
import type { FileDiagnosticsResult } from "../lsp";
|
||||
@@ -13,7 +13,6 @@ import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
|
||||
import type { OutputMeta } from "../tools/output-meta";
|
||||
import {
|
||||
formatDiagnostics,
|
||||
formatDiffStats,
|
||||
formatExpandHint,
|
||||
formatStatusIcon,
|
||||
getDiffStats,
|
||||
@@ -182,44 +181,120 @@ function getOperationTitle(op: Operation | undefined): string {
|
||||
return op === "create" ? "Create" : op === "delete" ? "Delete" : "Edit";
|
||||
}
|
||||
|
||||
interface EditPathDisplayOptions {
|
||||
rename?: string;
|
||||
firstChangedLine?: number;
|
||||
linkPath?: string;
|
||||
renameLinkPath?: string;
|
||||
maxPathWidth?: number;
|
||||
}
|
||||
|
||||
function truncateEditTitlePath(displayPath: string, maxWidth: number | undefined): string {
|
||||
if (maxWidth === undefined) return displayPath;
|
||||
const width = visibleWidth(displayPath);
|
||||
const safeMaxWidth = Math.max(0, Math.floor(maxWidth));
|
||||
if (width <= safeMaxWidth) return displayPath;
|
||||
|
||||
const contentWidth = safeMaxWidth - 1;
|
||||
if (contentWidth <= 0) return "…";
|
||||
|
||||
const headWidth = Math.floor(contentWidth / 2);
|
||||
const tailWidth = contentWidth - headWidth;
|
||||
const head = sliceWithWidth(displayPath, 0, headWidth, true).text;
|
||||
const tail = sliceWithWidth(displayPath, Math.max(0, width - tailWidth), tailWidth, true).text;
|
||||
return `${head}…${tail}`;
|
||||
}
|
||||
|
||||
function formatEditTitlePath(pathValue: string, maxWidth?: number): string {
|
||||
return truncateEditTitlePath(replaceTabs(shortenPath(pathValue), pathValue), maxWidth);
|
||||
}
|
||||
|
||||
function formatEditPathDisplay(
|
||||
rawPath: string,
|
||||
uiTheme: Theme,
|
||||
options?: { rename?: string; firstChangedLine?: number; linkPath?: string; renameLinkPath?: string },
|
||||
): string {
|
||||
options?: EditPathDisplayOptions,
|
||||
): { text: string; pathWidth: number } {
|
||||
// `rawPath`/`rename` are shown (cwd-relative) but the OSC 8 link targets the
|
||||
// absolute path when known — a relative `rawPath` would yield a `file:///rel`
|
||||
// URI that resolves against filesystem root instead of cwd.
|
||||
// absolute path when known — a relative `rawPath` would otherwise yield a
|
||||
// `file:///rel` URI that resolves against filesystem root instead of cwd.
|
||||
const linkTarget = options?.linkPath || rawPath;
|
||||
const lineLink = options?.firstChangedLine ? { line: options.firstChangedLine } : undefined;
|
||||
const primaryDisplay = rawPath ? formatEditTitlePath(rawPath, options?.maxPathWidth) : "…";
|
||||
let pathDisplay = rawPath
|
||||
? fileHyperlink(linkTarget, uiTheme.fg("accent", shortenPath(rawPath)))
|
||||
: uiTheme.fg("toolOutput", "…");
|
||||
|
||||
if (options?.firstChangedLine) {
|
||||
pathDisplay += uiTheme.fg("warning", `:${options.firstChangedLine}`);
|
||||
}
|
||||
? fileHyperlink(linkTarget, uiTheme.fg("accent", primaryDisplay), lineLink)
|
||||
: uiTheme.fg("toolOutput", primaryDisplay);
|
||||
let pathWidth = visibleWidth(primaryDisplay);
|
||||
|
||||
if (options?.rename) {
|
||||
const renameTarget = options.renameLinkPath || options.rename;
|
||||
pathDisplay += ` ${uiTheme.fg("dim", "→")} ${fileHyperlink(renameTarget, uiTheme.fg("accent", shortenPath(options.rename)))}`;
|
||||
const renameDisplay = formatEditTitlePath(options.rename, options.maxPathWidth);
|
||||
pathDisplay += ` ${uiTheme.fg("dim", "→")} ${fileHyperlink(renameTarget, uiTheme.fg("accent", renameDisplay))}`;
|
||||
pathWidth += visibleWidth(renameDisplay);
|
||||
}
|
||||
|
||||
return pathDisplay;
|
||||
return { text: pathDisplay, pathWidth };
|
||||
}
|
||||
|
||||
function formatEditDescription(
|
||||
rawPath: string,
|
||||
uiTheme: Theme,
|
||||
options?: { rename?: string; firstChangedLine?: number; linkPath?: string; renameLinkPath?: string },
|
||||
): { language: string; description: string } {
|
||||
options?: EditPathDisplayOptions,
|
||||
): { language: string; description: string; pathWidth: number } {
|
||||
const language = getLanguageFromPath(rawPath) ?? "text";
|
||||
const icon = uiTheme.fg("muted", uiTheme.getLangIcon(language));
|
||||
const pathDisplay = formatEditPathDisplay(rawPath, uiTheme, options);
|
||||
return {
|
||||
language,
|
||||
description: `${icon} ${formatEditPathDisplay(rawPath, uiTheme, options)}`,
|
||||
description: `${icon} ${pathDisplay.text}`,
|
||||
pathWidth: pathDisplay.pathWidth,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function renderEditHeader(
|
||||
width: number,
|
||||
uiTheme: Theme,
|
||||
options: {
|
||||
icon: "pending" | "success" | "error";
|
||||
spinnerFrame?: number;
|
||||
op?: Operation;
|
||||
rawPath: string;
|
||||
rename?: string;
|
||||
firstChangedLine?: number;
|
||||
linkPath?: string;
|
||||
statsSuffix?: string;
|
||||
extraSuffix?: string;
|
||||
},
|
||||
): string {
|
||||
const title = getOperationTitle(options.op);
|
||||
const descriptionOptions: EditPathDisplayOptions = {
|
||||
rename: options.rename,
|
||||
firstChangedLine: options.firstChangedLine,
|
||||
linkPath: options.linkPath,
|
||||
};
|
||||
const formatted = formatEditDescription(options.rawPath, uiTheme, descriptionOptions);
|
||||
const suffix = `${options.statsSuffix ?? ""}${options.extraSuffix ?? ""}`;
|
||||
const buildHeader = (description: string): string =>
|
||||
renderStatusLine({ icon: options.icon, spinnerFrame: options.spinnerFrame, title, description }, uiTheme) +
|
||||
suffix;
|
||||
|
||||
const header = buildHeader(formatted.description);
|
||||
const overflow = visibleWidth(header) - editHeaderLabelBudget(width, uiTheme);
|
||||
if (overflow <= 0 || formatted.pathWidth <= 1) return header;
|
||||
|
||||
const pathCount = Math.max(1, (options.rawPath ? 1 : 0) + (options.rename ? 1 : 0));
|
||||
const fittedPathWidth = Math.max(1, Math.floor((formatted.pathWidth - overflow) / pathCount));
|
||||
const fitted = formatEditDescription(options.rawPath, uiTheme, {
|
||||
...descriptionOptions,
|
||||
maxPathWidth: fittedPathWidth,
|
||||
});
|
||||
return buildHeader(fitted.description);
|
||||
}
|
||||
|
||||
function renderPlainTextPreview(text: string, uiTheme: Theme, filePath?: string): string {
|
||||
const previewLines = sanitizeText(text).split("\n");
|
||||
let preview = "\n\n";
|
||||
@@ -379,10 +454,13 @@ function getApplyPatchRenderSummary(
|
||||
}
|
||||
|
||||
function formatDiffStatsSuffix(diff: string, uiTheme: Theme): string {
|
||||
const { added, removed, hunks } = getDiffStats(diff);
|
||||
const stats = formatDiffStats(added, removed, hunks, uiTheme);
|
||||
if (!stats) return "";
|
||||
return ` ${uiTheme.fg("dim", uiTheme.format.bracketLeft)}${stats}${uiTheme.fg("dim", uiTheme.format.bracketRight)}`;
|
||||
const { added, removed } = getDiffStats(diff);
|
||||
if (added === 0 && removed === 0) return "";
|
||||
const stats = [
|
||||
added > 0 ? uiTheme.fg("toolDiffAdded", `+${added}`) : undefined,
|
||||
removed > 0 ? uiTheme.fg("toolDiffRemoved", `-${removed}`) : undefined,
|
||||
].filter(value => value !== undefined);
|
||||
return ` ${uiTheme.fg("dim", uiTheme.format.bracketLeft)}${stats.join(uiTheme.fg("dim", "/"))}${uiTheme.fg("dim", uiTheme.format.bracketRight)}`;
|
||||
}
|
||||
|
||||
function renderDiffSection(
|
||||
@@ -462,17 +540,19 @@ export const editToolRenderer = {
|
||||
"";
|
||||
const rename = editArgs.rename || firstEdit?.rename || firstEdit?.move || firstApplyPatchEntry?.rename;
|
||||
const op = editArgs.op || firstEdit?.op || firstApplyPatchEntry?.op;
|
||||
const { description } = formatEditDescription(rawPath, uiTheme, { rename });
|
||||
let fileCount = hashlineInputSummary?.entries.length ?? applyPatchSummary?.entries.length ?? 0;
|
||||
if (Array.isArray(editArgs.edits)) {
|
||||
fileCount = countEditFiles(editArgs.edits);
|
||||
}
|
||||
return framedBlock(uiTheme, width => {
|
||||
let header = renderStatusLine(
|
||||
{ icon: "pending", spinnerFrame: options?.spinnerFrame, title: getOperationTitle(op), description },
|
||||
uiTheme,
|
||||
);
|
||||
if (fileCount > 1) header += uiTheme.fg("dim", ` (+${fileCount - 1} more)`);
|
||||
const header = renderEditHeader(width, uiTheme, {
|
||||
icon: "pending",
|
||||
spinnerFrame: options?.spinnerFrame,
|
||||
op,
|
||||
rawPath,
|
||||
rename,
|
||||
extraSuffix: fileCount > 1 ? uiTheme.fg("dim", ` (+${fileCount - 1} more)`) : undefined,
|
||||
});
|
||||
let body = getCallPreview(editArgs, rawPath, uiTheme, renderContext, options.expanded);
|
||||
if (applyPatchSummary?.error) {
|
||||
body += `\n${uiTheme.fg("error", truncateToWidth(replaceTabs(applyPatchSummary.error, rawPath), Math.max(1, width - 2)))}`;
|
||||
@@ -546,15 +626,20 @@ function renderSingleFileResult(
|
||||
(editDiffPreview && "firstChangedLine" in editDiffPreview ? editDiffPreview.firstChangedLine : undefined) ||
|
||||
(details && !isError ? details.firstChangedLine : undefined);
|
||||
const linkPath = details && "path" in details ? details.path : undefined;
|
||||
const { description } = formatEditDescription(rawPath, uiTheme, { rename, firstChangedLine, linkPath });
|
||||
|
||||
// Change stats ride inline on the header bar next to the path.
|
||||
const previewDiff = editDiffPreview && !("error" in editDiffPreview) ? editDiffPreview.diff : undefined;
|
||||
const headerDiff = isError ? undefined : details?.diff || previewDiff;
|
||||
const statsSuffix = headerDiff ? formatDiffStatsSuffix(headerDiff, uiTheme) : "";
|
||||
const header =
|
||||
renderStatusLine({ icon: isError ? "error" : "success", title: getOperationTitle(op), description }, uiTheme) +
|
||||
statsSuffix;
|
||||
const header = renderEditHeader(width, uiTheme, {
|
||||
icon: isError ? "error" : "success",
|
||||
op,
|
||||
rawPath,
|
||||
rename,
|
||||
firstChangedLine,
|
||||
linkPath,
|
||||
statsSuffix,
|
||||
});
|
||||
|
||||
let body = "";
|
||||
if (isError) {
|
||||
|
||||
@@ -205,6 +205,19 @@ describe("runEvalAgent", () => {
|
||||
expect(secondOptions.outputSchema).toBeUndefined();
|
||||
});
|
||||
|
||||
it("forces LSP off for bridge subagents even when task.enableLsp is on", async () => {
|
||||
mockAgents();
|
||||
const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => singleResult(options));
|
||||
// makeSession() defaults to enableLsp: true and task.enableLsp: true.
|
||||
const session = makeSession();
|
||||
|
||||
await runEvalAgent({ prompt: "hello" }, { session });
|
||||
|
||||
const options = runSpy.mock.calls[0]?.[0];
|
||||
if (!options) throw new Error("runSubprocess was not called");
|
||||
expect(options.enableLsp).toBe(false);
|
||||
});
|
||||
|
||||
it("maps successful and failed subagent results", async () => {
|
||||
mockAgents();
|
||||
const runSpy = vi.spyOn(taskExecutor, "runSubprocess");
|
||||
|
||||
+76
-50
@@ -10,10 +10,10 @@ import { Settings } from "../../config/settings";
|
||||
import type { ToolSession } from "../../tools";
|
||||
import { ToolError } from "../../tools/tool-errors";
|
||||
import { EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP } from "../bridge-timeout";
|
||||
import { runEvalCompletion } from "../completion-bridge";
|
||||
import { IdleTimeout } from "../idle-timeout";
|
||||
import { disposeAllVmContexts } from "../js/context-manager";
|
||||
import { executeJs } from "../js/executor";
|
||||
import { runEvalLlm } from "../llm-bridge";
|
||||
import { disposeAllKernelSessions, type PythonResult } from "../py/executor";
|
||||
|
||||
function makeModel(provider: string, id: string, extra: Partial<Model<Api>> = {}): Model<Api> {
|
||||
@@ -98,16 +98,19 @@ function assistant(opts: {
|
||||
};
|
||||
}
|
||||
|
||||
async function runPythonLlmInSubprocess(options: { structured: boolean; tempDir: TempDir }): Promise<PythonResult> {
|
||||
async function runPythonCompletionInSubprocess(options: {
|
||||
structured: boolean;
|
||||
tempDir: TempDir;
|
||||
}): Promise<PythonResult> {
|
||||
const repoRoot = path.resolve(import.meta.dir, "../../../..");
|
||||
const scriptPath = path.join(options.tempDir.path(), "run-python-llm.ts");
|
||||
const resultPath = path.join(options.tempDir.path(), "python-llm-result.json");
|
||||
const scriptPath = path.join(options.tempDir.path(), "run-python-completion.ts");
|
||||
const resultPath = path.join(options.tempDir.path(), "python-completion-result.json");
|
||||
const aiPath = path.resolve(import.meta.dir, "../../../../ai/src/index.ts");
|
||||
const executorPath = path.resolve(import.meta.dir, "../py/executor.ts");
|
||||
const settingsPath = path.resolve(import.meta.dir, "../../config/settings.ts");
|
||||
const code = options.structured
|
||||
? 'import json\nprint(json.dumps(llm("hi", schema={"type": "object"})))'
|
||||
: 'print(llm("hi", model="smol"))';
|
||||
? 'import json\nprint(json.dumps(completion("hi", schema={"type": "object"})))'
|
||||
: 'print(completion("hi", model="smol"))';
|
||||
const responseContent = options.structured
|
||||
? '[{ type: "toolCall", id: "tc-1", name: "respond", arguments: { ok: true } }]'
|
||||
: '[{ type: "text", text: "hello from python" }]';
|
||||
@@ -153,7 +156,7 @@ vi.spyOn(ai, "completeSimple").mockResolvedValue({
|
||||
});
|
||||
const result = await executePython(${JSON.stringify(code)}, {
|
||||
cwd: ${JSON.stringify(options.tempDir.path())},
|
||||
sessionId: ${JSON.stringify(`py-llm:${options.structured ? "struct" : "plain"}`)},
|
||||
sessionId: ${JSON.stringify(`py-completion:${options.structured ? "struct" : "plain"}`)},
|
||||
sessionFile: ${JSON.stringify(path.join(options.tempDir.path(), "session.jsonl"))},
|
||||
toolSession: session,
|
||||
kernelMode: "per-call",
|
||||
@@ -165,11 +168,12 @@ process.exit(0);
|
||||
const child = await $`bun ${scriptPath}`.cwd(repoRoot).quiet().nothrow();
|
||||
const stdout = child.stdout.toString();
|
||||
const stderr = child.stderr.toString();
|
||||
if (child.exitCode !== 0) throw new Error(stderr || stdout || `Python llm subprocess exited with ${child.exitCode}`);
|
||||
if (child.exitCode !== 0)
|
||||
throw new Error(stderr || stdout || `Python completion subprocess exited with ${child.exitCode}`);
|
||||
return (await Bun.file(resultPath).json()) as PythonResult;
|
||||
}
|
||||
|
||||
describe("runEvalLlm", () => {
|
||||
describe("runEvalCompletion", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -178,9 +182,9 @@ describe("runEvalLlm", () => {
|
||||
const spy = vi.spyOn(ai, "completeSimple").mockResolvedValue(assistant({ text: "ok" }));
|
||||
const session = makeSession();
|
||||
|
||||
await runEvalLlm({ prompt: "q", model: "smol" }, { session });
|
||||
await runEvalLlm({ prompt: "q", model: "default" }, { session });
|
||||
await runEvalLlm({ prompt: "q", model: "slow" }, { session });
|
||||
await runEvalCompletion({ prompt: "q", model: "smol" }, { session });
|
||||
await runEvalCompletion({ prompt: "q", model: "default" }, { session });
|
||||
await runEvalCompletion({ prompt: "q", model: "slow" }, { session });
|
||||
|
||||
const resolved = spy.mock.calls.map(call => {
|
||||
const model = call[0] as Model<Api>;
|
||||
@@ -193,7 +197,7 @@ describe("runEvalLlm", () => {
|
||||
const spy = vi.spyOn(ai, "completeSimple").mockResolvedValue(assistant({ text: "ok" }));
|
||||
const session = makeSession({ available: [SMOL, DEFAULT, SLOW], activeModel: "p/slow" });
|
||||
|
||||
await runEvalLlm({ prompt: "q", model: "default" }, { session });
|
||||
await runEvalCompletion({ prompt: "q", model: "default" }, { session });
|
||||
|
||||
const model = spy.mock.calls[0]?.[0] as Model<Api>;
|
||||
expect(`${model.provider}/${model.id}`).toBe("p/slow");
|
||||
@@ -201,16 +205,36 @@ describe("runEvalLlm", () => {
|
||||
|
||||
it("returns the completion text in plain mode", async () => {
|
||||
vi.spyOn(ai, "completeSimple").mockResolvedValue(assistant({ text: "the answer" }));
|
||||
const result = await runEvalLlm({ prompt: "q", model: "smol" }, { session: makeSession() });
|
||||
const result = await runEvalCompletion({ prompt: "q", model: "smol" }, { session: makeSession() });
|
||||
expect(result.text).toBe("the answer");
|
||||
expect(result.details).toEqual({ model: "p/smol", tier: "smol", structured: false });
|
||||
});
|
||||
|
||||
it("supplies a non-empty systemPrompt when system is omitted (codex 'Instructions are required' guard)", async () => {
|
||||
// The openai-codex Responses transformer drops `instructions` when no
|
||||
// system prompt is provided, and the remote endpoint then 400s with
|
||||
// "Instructions are required". runEvalCompletion must always carry a non-empty
|
||||
// systemPrompt so `completion("…")` without a `system` argument works.
|
||||
const spy = vi.spyOn(ai, "completeSimple").mockResolvedValue(assistant({ text: "ok" }));
|
||||
await runEvalCompletion({ prompt: "q", model: "smol" }, { session: makeSession() });
|
||||
const ctx = spy.mock.calls[0]?.[1] as { systemPrompt?: string[] };
|
||||
expect(ctx.systemPrompt).toBeDefined();
|
||||
expect(ctx.systemPrompt?.length).toBeGreaterThan(0);
|
||||
expect(ctx.systemPrompt?.[0]).toMatch(/.+/);
|
||||
});
|
||||
|
||||
it("honors an explicit system prompt instead of overriding it", async () => {
|
||||
const spy = vi.spyOn(ai, "completeSimple").mockResolvedValue(assistant({ text: "ok" }));
|
||||
await runEvalCompletion({ prompt: "q", model: "smol", system: "Be terse." }, { session: makeSession() });
|
||||
const ctx = spy.mock.calls[0]?.[1] as { systemPrompt?: string[] };
|
||||
expect(ctx.systemPrompt).toEqual(["Be terse."]);
|
||||
});
|
||||
|
||||
it("forces a respond tool call and returns its arguments in structured mode", async () => {
|
||||
const spy = vi
|
||||
.spyOn(ai, "completeSimple")
|
||||
.mockResolvedValue(assistant({ toolCall: { name: "respond", arguments: { answer: 42 } } }));
|
||||
const result = await runEvalLlm(
|
||||
const result = await runEvalCompletion(
|
||||
{ prompt: "q", model: "smol", schema: { type: "object", properties: { answer: { type: "number" } } } },
|
||||
{ session: makeSession() },
|
||||
);
|
||||
@@ -226,7 +250,7 @@ describe("runEvalLlm", () => {
|
||||
|
||||
it("falls back to JSON embedded in text when the model skips the respond tool", async () => {
|
||||
vi.spyOn(ai, "completeSimple").mockResolvedValue(assistant({ text: 'here: {"answer": 7}' }));
|
||||
const result = await runEvalLlm(
|
||||
const result = await runEvalCompletion(
|
||||
{ prompt: "q", model: "smol", schema: { type: "object" } },
|
||||
{ session: makeSession() },
|
||||
);
|
||||
@@ -237,8 +261,8 @@ describe("runEvalLlm", () => {
|
||||
const spy = vi.spyOn(ai, "completeSimple").mockResolvedValue(assistant({ text: "ok" }));
|
||||
const session = makeSession({ available: [SMOL, DEFAULT, REASONING_SLOW] });
|
||||
|
||||
await runEvalLlm({ prompt: "q", model: "smol" }, { session });
|
||||
await runEvalLlm({ prompt: "q", model: "slow" }, { session });
|
||||
await runEvalCompletion({ prompt: "q", model: "smol" }, { session });
|
||||
await runEvalCompletion({ prompt: "q", model: "slow" }, { session });
|
||||
|
||||
const smolOpts = spy.mock.calls[0]?.[2] as { reasoning?: unknown };
|
||||
const slowOpts = spy.mock.calls[1]?.[2] as { reasoning?: unknown };
|
||||
@@ -249,47 +273,49 @@ describe("runEvalLlm", () => {
|
||||
it("does not request reasoning for the slow tier on a non-reasoning model", async () => {
|
||||
const spy = vi.spyOn(ai, "completeSimple").mockResolvedValue(assistant({ text: "ok" }));
|
||||
// SLOW is reasoning:false — must not trip requireSupportedEffort downstream.
|
||||
const result = await runEvalLlm({ prompt: "q", model: "slow" }, { session: makeSession() });
|
||||
const result = await runEvalCompletion({ prompt: "q", model: "slow" }, { session: makeSession() });
|
||||
expect(result.text).toBe("ok");
|
||||
const opts = spy.mock.calls[0]?.[2] as { reasoning?: unknown };
|
||||
expect(opts.reasoning).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws ToolError on invalid arguments", async () => {
|
||||
await expect(runEvalLlm({ prompt: "" }, { session: makeSession() })).rejects.toBeInstanceOf(ToolError);
|
||||
await expect(runEvalLlm({ prompt: "q", model: "huge" }, { session: makeSession() })).rejects.toBeInstanceOf(
|
||||
ToolError,
|
||||
);
|
||||
await expect(runEvalCompletion({ prompt: "" }, { session: makeSession() })).rejects.toBeInstanceOf(ToolError);
|
||||
await expect(
|
||||
runEvalCompletion({ prompt: "q", model: "huge" }, { session: makeSession() }),
|
||||
).rejects.toBeInstanceOf(ToolError);
|
||||
});
|
||||
|
||||
it("throws ToolError when no model resolves for the tier", async () => {
|
||||
const session = makeSession({ available: [DEFAULT], roles: { smol: "missing/model" } });
|
||||
await expect(runEvalLlm({ prompt: "q", model: "smol" }, { session })).rejects.toBeInstanceOf(ToolError);
|
||||
await expect(runEvalCompletion({ prompt: "q", model: "smol" }, { session })).rejects.toBeInstanceOf(ToolError);
|
||||
});
|
||||
|
||||
it("throws ToolError when the resolved model has no API key", async () => {
|
||||
const session = makeSession({ apiKey: null });
|
||||
await expect(runEvalLlm({ prompt: "q", model: "smol" }, { session })).rejects.toBeInstanceOf(ToolError);
|
||||
await expect(runEvalCompletion({ prompt: "q", model: "smol" }, { session })).rejects.toBeInstanceOf(ToolError);
|
||||
});
|
||||
|
||||
it("maps error and aborted stop reasons to ToolError", async () => {
|
||||
vi.spyOn(ai, "completeSimple").mockResolvedValueOnce(assistant({ stopReason: "error", errorMessage: "boom" }));
|
||||
await expect(runEvalLlm({ prompt: "q", model: "smol" }, { session: makeSession() })).rejects.toThrow("boom");
|
||||
await expect(runEvalCompletion({ prompt: "q", model: "smol" }, { session: makeSession() })).rejects.toThrow(
|
||||
"boom",
|
||||
);
|
||||
|
||||
vi.spyOn(ai, "completeSimple").mockResolvedValueOnce(assistant({ stopReason: "aborted" }));
|
||||
await expect(runEvalLlm({ prompt: "q", model: "smol" }, { session: makeSession() })).rejects.toBeInstanceOf(
|
||||
ToolError,
|
||||
);
|
||||
await expect(
|
||||
runEvalCompletion({ prompt: "q", model: "smol" }, { session: makeSession() }),
|
||||
).rejects.toBeInstanceOf(ToolError);
|
||||
});
|
||||
|
||||
it("throws ToolError when plain mode produces no text", async () => {
|
||||
vi.spyOn(ai, "completeSimple").mockResolvedValue(assistant({ text: "" }));
|
||||
await expect(runEvalLlm({ prompt: "q", model: "smol" }, { session: makeSession() })).rejects.toBeInstanceOf(
|
||||
ToolError,
|
||||
);
|
||||
await expect(
|
||||
runEvalCompletion({ prompt: "q", model: "smol" }, { session: makeSession() }),
|
||||
).rejects.toBeInstanceOf(ToolError);
|
||||
});
|
||||
|
||||
it("pauses the idle watchdog while a slow llm() request is in flight", async () => {
|
||||
it("pauses the idle watchdog while a slow completion() request is in flight", async () => {
|
||||
// A oneshot completion emits no status until it returns; delegated model
|
||||
// time must be invisible to the eval timeout budget.
|
||||
vi.spyOn(ai, "completeSimple").mockImplementation(async () => {
|
||||
@@ -299,7 +325,7 @@ describe("runEvalLlm", () => {
|
||||
|
||||
const ops: string[] = [];
|
||||
using idle = new IdleTimeout(60);
|
||||
const result = await runEvalLlm(
|
||||
const result = await runEvalCompletion(
|
||||
{ prompt: "q", model: "smol" },
|
||||
{
|
||||
session: makeSession(),
|
||||
@@ -313,12 +339,12 @@ describe("runEvalLlm", () => {
|
||||
);
|
||||
|
||||
expect(result.text).toBe("the answer");
|
||||
expect(ops).toEqual([EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP, "llm"]);
|
||||
expect(ops).toEqual([EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP, "completion"]);
|
||||
expect(idle.signal.aborted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("llm() through eval runtimes", () => {
|
||||
describe("completion() through eval runtimes", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -328,13 +354,13 @@ describe("llm() through eval runtimes", () => {
|
||||
await disposeAllKernelSessions();
|
||||
});
|
||||
|
||||
it("exposes llm() in the JavaScript runtime", async () => {
|
||||
using tempDir = TempDir.createSync("@omp-eval-llm-js-");
|
||||
it("exposes completion() in the JavaScript runtime", async () => {
|
||||
using tempDir = TempDir.createSync("@omp-eval-completion-js-");
|
||||
const sessionFile = path.join(tempDir.path(), "session.jsonl");
|
||||
const sessionId = `js-llm:${crypto.randomUUID()}`;
|
||||
const sessionId = `js-completion:${crypto.randomUUID()}`;
|
||||
vi.spyOn(ai, "completeSimple").mockResolvedValue(assistant({ text: "hello from smol" }));
|
||||
|
||||
const result = await executeJs('return await llm("hi", { model: "smol" });', {
|
||||
const result = await executeJs('return await completion("hi", { model: "smol" });', {
|
||||
cwd: tempDir.path(),
|
||||
sessionId,
|
||||
session: makeSession(),
|
||||
@@ -345,16 +371,16 @@ describe("llm() through eval runtimes", () => {
|
||||
expect(result.output.trim()).toBe("hello from smol");
|
||||
});
|
||||
|
||||
it("parses structured llm() output in the JavaScript runtime", async () => {
|
||||
using tempDir = TempDir.createSync("@omp-eval-llm-js-struct-");
|
||||
it("parses structured completion() output in the JavaScript runtime", async () => {
|
||||
using tempDir = TempDir.createSync("@omp-eval-completion-js-struct-");
|
||||
const sessionFile = path.join(tempDir.path(), "session.jsonl");
|
||||
const sessionId = `js-llm-struct:${crypto.randomUUID()}`;
|
||||
const sessionId = `js-completion-struct:${crypto.randomUUID()}`;
|
||||
vi.spyOn(ai, "completeSimple").mockResolvedValue(
|
||||
assistant({ toolCall: { name: "respond", arguments: { ok: true, n: 3 } } }),
|
||||
);
|
||||
|
||||
const result = await executeJs(
|
||||
'const r = await llm("hi", { schema: { type: "object" } }); return JSON.stringify(r);',
|
||||
'const r = await completion("hi", { schema: { type: "object" } }); return JSON.stringify(r);',
|
||||
{ cwd: tempDir.path(), sessionId, session: makeSession(), sessionFile },
|
||||
);
|
||||
|
||||
@@ -362,10 +388,10 @@ describe("llm() through eval runtimes", () => {
|
||||
expect(JSON.parse(result.output.trim())).toEqual({ ok: true, n: 3 });
|
||||
});
|
||||
|
||||
it("exposes llm() in the Python runtime", async () => {
|
||||
const tempDir = TempDir.createSync("@omp-eval-llm-py-");
|
||||
it("exposes completion() in the Python runtime", async () => {
|
||||
const tempDir = TempDir.createSync("@omp-eval-completion-py-");
|
||||
try {
|
||||
const result = await runPythonLlmInSubprocess({ structured: false, tempDir });
|
||||
const result = await runPythonCompletionInSubprocess({ structured: false, tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.output.trim()).toBe("hello from python");
|
||||
} finally {
|
||||
@@ -373,10 +399,10 @@ describe("llm() through eval runtimes", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("parses structured llm() output in the Python runtime", async () => {
|
||||
const tempDir = TempDir.createSync("@omp-eval-llm-py-struct-");
|
||||
it("parses structured completion() output in the Python runtime", async () => {
|
||||
const tempDir = TempDir.createSync("@omp-eval-completion-py-struct-");
|
||||
try {
|
||||
const result = await runPythonLlmInSubprocess({ structured: true, tempDir });
|
||||
const result = await runPythonCompletionInSubprocess({ structured: true, tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(JSON.parse(result.output.trim())).toEqual({ ok: true });
|
||||
} finally {
|
||||
@@ -0,0 +1,241 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import { TempDir } from "@oh-my-pi/pi-utils";
|
||||
import { Settings } from "../../config/settings";
|
||||
import type { ToolSession } from "../../tools";
|
||||
import { disposeAllVmContexts } from "../js/context-manager";
|
||||
import { executeJs } from "../js/executor";
|
||||
|
||||
const originalWorker = globalThis.Worker;
|
||||
|
||||
interface FakeWorkerStats {
|
||||
closeRequests: number;
|
||||
terminateCalls: number;
|
||||
}
|
||||
|
||||
interface FakeWorkerBehavior {
|
||||
exitOnClose: boolean;
|
||||
settleRuns: boolean;
|
||||
}
|
||||
|
||||
function makeSession(cwd: string): ToolSession {
|
||||
return {
|
||||
cwd,
|
||||
hasUI: false,
|
||||
settings: Settings.isolated({
|
||||
"async.enabled": false,
|
||||
"task.isolation.mode": "none",
|
||||
"task.enableLsp": true,
|
||||
}),
|
||||
taskDepth: 0,
|
||||
enableLsp: true,
|
||||
getSessionFile: () => null,
|
||||
getSessionSpawns: () => "*",
|
||||
getActiveModelString: () => "p/active",
|
||||
getModelString: () => "p/fallback",
|
||||
getArtifactsDir: () => null,
|
||||
getSessionId: () => "test-session",
|
||||
getEvalSessionId: () => "test-eval-session",
|
||||
};
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error(`${label} timed out`)), ms);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForRealWorkerExitAfterClose(cwd: string): Promise<void> {
|
||||
const worker = new originalWorker(new URL("../js/worker-entry.ts", import.meta.url).href, { type: "module" });
|
||||
const ready = Promise.withResolvers<void>();
|
||||
const runComplete = Promise.withResolvers<void>();
|
||||
const closedAck = Promise.withResolvers<void>();
|
||||
const workerClosed = Promise.withResolvers<void>();
|
||||
const runId = `keep-alive:${crypto.randomUUID()}`;
|
||||
const snapshot = { cwd, sessionId: `worker-exit:${crypto.randomUUID()}` };
|
||||
|
||||
worker.addEventListener("message", event => {
|
||||
const msg = event.data as { type?: string; runId?: string; ok?: boolean };
|
||||
if (msg.type === "ready") ready.resolve();
|
||||
else if (msg.type === "result" && msg.runId === runId && msg.ok) runComplete.resolve();
|
||||
else if (msg.type === "closed") closedAck.resolve();
|
||||
});
|
||||
worker.addEventListener("close", () => workerClosed.resolve());
|
||||
|
||||
try {
|
||||
await withTimeout(ready.promise, 1_000, "worker ready");
|
||||
worker.postMessage({
|
||||
type: "run",
|
||||
runId,
|
||||
code: "globalThis.__keepAlive = setInterval(() => {}, 1000);\nundefined;",
|
||||
filename: "keep-alive.js",
|
||||
snapshot,
|
||||
});
|
||||
await withTimeout(runComplete.promise, 1_000, "worker run");
|
||||
worker.postMessage({ type: "close" });
|
||||
await withTimeout(closedAck.promise, 1_000, "worker closed ack");
|
||||
await withTimeout(workerClosed.promise, 1_000, "worker close event");
|
||||
} finally {
|
||||
worker.terminate();
|
||||
}
|
||||
}
|
||||
|
||||
function installFakeWorker(stats: FakeWorkerStats, behavior: FakeWorkerBehavior): void {
|
||||
class FakeWorker {
|
||||
#messageListeners = new Set<(event: MessageEvent) => void>();
|
||||
#closeListeners = new Set<(event: Event) => void>();
|
||||
#readyQueued = false;
|
||||
#exited = false;
|
||||
|
||||
postMessage(message: unknown): void {
|
||||
if (!message || typeof message !== "object") return;
|
||||
const typed = message as { type?: string; runId?: string };
|
||||
if (typed.type === "run" && typed.runId && behavior.settleRuns) {
|
||||
queueMicrotask(() => this.#emitMessage({ type: "result", runId: typed.runId, ok: true }));
|
||||
return;
|
||||
}
|
||||
if (typed.type === "close") {
|
||||
stats.closeRequests++;
|
||||
queueMicrotask(() => {
|
||||
this.#emitMessage({ type: "closed" });
|
||||
if (behavior.exitOnClose) this.#emitClose();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
addEventListener(type: string, listener: (event: MessageEvent | Event) => void): void {
|
||||
if (type === "close") {
|
||||
this.#closeListeners.add(listener as (event: Event) => void);
|
||||
return;
|
||||
}
|
||||
if (type !== "message") return;
|
||||
this.#messageListeners.add(listener as (event: MessageEvent) => void);
|
||||
if (!this.#readyQueued) {
|
||||
this.#readyQueued = true;
|
||||
queueMicrotask(() => this.#emitMessage({ type: "ready" }));
|
||||
}
|
||||
}
|
||||
|
||||
removeEventListener(type: string, listener: (event: MessageEvent | Event) => void): void {
|
||||
if (type === "close") {
|
||||
this.#closeListeners.delete(listener as (event: Event) => void);
|
||||
return;
|
||||
}
|
||||
if (type !== "message") return;
|
||||
this.#messageListeners.delete(listener as (event: MessageEvent) => void);
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
stats.terminateCalls++;
|
||||
this.#emitClose();
|
||||
}
|
||||
|
||||
#emitMessage(data: unknown): void {
|
||||
const event = new MessageEvent("message", { data });
|
||||
for (const listener of this.#messageListeners) listener(event);
|
||||
}
|
||||
|
||||
#emitClose(): void {
|
||||
if (this.#exited) return;
|
||||
this.#exited = true;
|
||||
const event = new Event("close");
|
||||
for (const listener of this.#closeListeners) listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, "Worker", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: FakeWorker as unknown as typeof Worker,
|
||||
});
|
||||
}
|
||||
|
||||
describe("JavaScript eval worker lifecycle", () => {
|
||||
afterEach(async () => {
|
||||
await disposeAllVmContexts();
|
||||
Object.defineProperty(globalThis, "Worker", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalWorker,
|
||||
});
|
||||
});
|
||||
|
||||
it("exits a real worker on graceful close even with ref'ed user handles", async () => {
|
||||
using tempDir = TempDir.createSync("@omp-js-worker-real-close-");
|
||||
|
||||
await waitForRealWorkerExitAfterClose(tempDir.path());
|
||||
});
|
||||
|
||||
it("waits for the worker to close on reset instead of force-terminating it", async () => {
|
||||
using tempDir = TempDir.createSync("@omp-js-worker-close-");
|
||||
const stats: FakeWorkerStats = { closeRequests: 0, terminateCalls: 0 };
|
||||
installFakeWorker(stats, { exitOnClose: true, settleRuns: true });
|
||||
|
||||
const session = makeSession(tempDir.path());
|
||||
const sessionId = `js-close:${crypto.randomUUID()}`;
|
||||
|
||||
const first = await executeJs("globalThis.marker = 1;", { cwd: tempDir.path(), sessionId, session });
|
||||
expect(first.exitCode).toBe(0);
|
||||
|
||||
const second = await executeJs("globalThis.marker = 2;", {
|
||||
cwd: tempDir.path(),
|
||||
sessionId,
|
||||
session,
|
||||
reset: true,
|
||||
});
|
||||
expect(second.exitCode).toBe(0);
|
||||
expect(stats.closeRequests).toBe(1);
|
||||
expect(stats.terminateCalls).toBe(0);
|
||||
});
|
||||
|
||||
it("terminates when close is acknowledged but the worker does not exit", async () => {
|
||||
using tempDir = TempDir.createSync("@omp-js-worker-close-hung-");
|
||||
const stats: FakeWorkerStats = { closeRequests: 0, terminateCalls: 0 };
|
||||
installFakeWorker(stats, { exitOnClose: false, settleRuns: true });
|
||||
|
||||
const session = makeSession(tempDir.path());
|
||||
const sessionId = `js-close-hung:${crypto.randomUUID()}`;
|
||||
|
||||
const first = await executeJs("globalThis.marker = 1;", { cwd: tempDir.path(), sessionId, session });
|
||||
expect(first.exitCode).toBe(0);
|
||||
|
||||
const second = await executeJs("globalThis.marker = 2;", {
|
||||
cwd: tempDir.path(),
|
||||
sessionId,
|
||||
session,
|
||||
reset: true,
|
||||
});
|
||||
expect(second.exitCode).toBe(0);
|
||||
expect(stats.closeRequests).toBe(1);
|
||||
expect(stats.terminateCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("force-terminates instead of closing when an in-flight run is aborted", async () => {
|
||||
using tempDir = TempDir.createSync("@omp-js-worker-abort-");
|
||||
const stats: FakeWorkerStats = { closeRequests: 0, terminateCalls: 0 };
|
||||
installFakeWorker(stats, { exitOnClose: true, settleRuns: false });
|
||||
|
||||
const session = makeSession(tempDir.path());
|
||||
const sessionId = `js-abort:${crypto.randomUUID()}`;
|
||||
const controller = new AbortController();
|
||||
const resultPromise = executeJs("globalThis.neverFinishes = true;", {
|
||||
cwd: tempDir.path(),
|
||||
sessionId,
|
||||
session,
|
||||
signal: controller.signal,
|
||||
});
|
||||
setTimeout(() => controller.abort(new DOMException("Execution aborted", "AbortError")), 0);
|
||||
|
||||
const result = await resultPromise;
|
||||
expect(result.cancelled).toBe(true);
|
||||
expect(stats.closeRequests).toBe(0);
|
||||
expect(stats.terminateCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -272,7 +272,12 @@ export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOption
|
||||
persistArtifacts: Boolean(sessionFile),
|
||||
artifactsDir,
|
||||
contextFile,
|
||||
enableLsp: (options.session.enableLsp ?? true) && options.session.settings.get("task.enableLsp"),
|
||||
// Eval `agent()` subagents are short-lived programmatic helpers (data
|
||||
// collection, structured output, parallel() fan-out). LSP server
|
||||
// cold-start costs tens of seconds and is pure overhead here, so it is
|
||||
// forced off regardless of the `task.enableLsp` setting — that knob only
|
||||
// governs LSP-aware delegation through the `task` tool.
|
||||
enableLsp: false,
|
||||
signal: options.signal,
|
||||
eventBus: options.session.eventBus,
|
||||
onProgress: progress => emitProgressStatus(options.emitStatus, progress),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Timeout suspension for in-flight host-side eval bridge calls.
|
||||
*
|
||||
* The eval watchdog caps a cell's `timeout` as a budget on the cell runtime's
|
||||
* own work. Host-side `agent()` / `parallel()` / `llm()` bridge calls hand
|
||||
* own work. Host-side `agent()` / `parallel()` / `completion()` bridge calls hand
|
||||
* control to the outer TypeScript process, where the Python kernel or JS VM is
|
||||
* only waiting for a result. While that delegated work is in flight, the cell
|
||||
* timeout must be ignored completely; once the bridge returns and the runtime is
|
||||
|
||||
+43
-29
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Host-side handler for the eval `llm()` helper.
|
||||
* Host-side handler for the eval `completion()` helper.
|
||||
*
|
||||
* Both eval runtimes (JS worker + Python kernel) route helper→host calls
|
||||
* through {@link callSessionTool}. Reserving the synthetic tool name
|
||||
* {@link EVAL_LLM_BRIDGE_NAME} lets a single host handler serve both
|
||||
* {@link EVAL_COMPLETION_BRIDGE_NAME} lets a single host handler serve both
|
||||
* transports without registering an agent-visible tool: cell code calls
|
||||
* `llm(prompt, opts)`, the prelude forwards `{ prompt, model, system?, schema? }`
|
||||
* `completion(prompt, opts)`, the prelude forwards `{ prompt, model, system?, schema? }`
|
||||
* through the bridge, and this module performs one stateless completion.
|
||||
*
|
||||
* The call is oneshot and toolless from the model's perspective — pure text
|
||||
@@ -16,42 +16,47 @@ import { type Api, Effort, getSupportedEfforts, type Model, type Tool } from "@o
|
||||
import * as z from "zod/v4";
|
||||
import { extractTextContent, extractToolCall, parseJsonPayload } from "../commit/utils";
|
||||
|
||||
import { expandRoleAlias, formatModelString, resolveModelFromString } from "../config/model-resolver";
|
||||
import {
|
||||
expandRoleAlias,
|
||||
formatModelString,
|
||||
getModelMatchPreferences,
|
||||
resolveModelFromString,
|
||||
} from "../config/model-resolver";
|
||||
import type { ToolSession } from "../tools";
|
||||
import { ToolError } from "../tools/tool-errors";
|
||||
import { withBridgeTimeoutPause } from "./bridge-timeout";
|
||||
import type { JsStatusEvent } from "./js/shared/types";
|
||||
|
||||
/** Synthetic bridge name reserved for the `llm()` helper across both runtimes. */
|
||||
export const EVAL_LLM_BRIDGE_NAME = "__llm__";
|
||||
/** Synthetic bridge name reserved for the `completion()` helper across both runtimes. */
|
||||
export const EVAL_COMPLETION_BRIDGE_NAME = "__completion__";
|
||||
|
||||
/** Synthetic tool the model is forced to call when a `schema` is supplied. */
|
||||
const STRUCTURED_TOOL_NAME = "respond";
|
||||
|
||||
type LlmTier = "smol" | "default" | "slow";
|
||||
type CompletionTier = "smol" | "default" | "slow";
|
||||
|
||||
const TIER_TO_PATTERN: Record<LlmTier, string> = {
|
||||
const TIER_TO_PATTERN: Record<CompletionTier, string> = {
|
||||
smol: "pi/smol",
|
||||
default: "pi/default",
|
||||
slow: "pi/slow",
|
||||
};
|
||||
|
||||
const llmArgsSchema = z.object({
|
||||
const completionArgsSchema = z.object({
|
||||
prompt: z.string().min(1, "prompt must be a non-empty string"),
|
||||
model: z.enum(["smol", "default", "slow"]).default("default"),
|
||||
system: z.string().optional(),
|
||||
schema: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export interface EvalLlmBridgeOptions {
|
||||
export interface EvalCompletionBridgeOptions {
|
||||
session: ToolSession;
|
||||
signal?: AbortSignal;
|
||||
emitStatus?: (event: JsStatusEvent) => void;
|
||||
}
|
||||
|
||||
export interface EvalLlmResult {
|
||||
export interface EvalCompletionResult {
|
||||
text: string;
|
||||
details: { model: string; tier: LlmTier; structured: boolean };
|
||||
details: { model: string; tier: CompletionTier; structured: boolean };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,13 +64,13 @@ export interface EvalLlmResult {
|
||||
* active model and falls back to the `pi/default` role; `smol`/`slow` resolve
|
||||
* their respective role patterns. Returns `undefined` when nothing matches.
|
||||
*/
|
||||
function resolveTierModel(tier: LlmTier, session: ToolSession): Model<Api> | undefined {
|
||||
function resolveTierModel(tier: CompletionTier, session: ToolSession): Model<Api> | undefined {
|
||||
const modelRegistry = session.modelRegistry;
|
||||
if (!modelRegistry) return undefined;
|
||||
const available = modelRegistry.getAvailable();
|
||||
if (available.length === 0) return undefined;
|
||||
|
||||
const matchPreferences = { usageOrder: session.settings.getStorage()?.getModelUsageOrder() };
|
||||
const matchPreferences = getModelMatchPreferences(session.settings);
|
||||
const resolve = (pattern: string | undefined): Model<Api> | undefined => {
|
||||
if (!pattern) return undefined;
|
||||
const expanded = expandRoleAlias(pattern, session.settings);
|
||||
@@ -85,7 +90,7 @@ function resolveTierModel(tier: LlmTier, session: ToolSession): Model<Api> | und
|
||||
* throwing downstream on models that cannot reason. Clamps to the highest
|
||||
* supported effort so a reasoning model without `high` does not 400.
|
||||
*/
|
||||
function reasoningForTier(tier: LlmTier, model: Model<Api>): Effort | undefined {
|
||||
function reasoningForTier(tier: CompletionTier, model: Model<Api>): Effort | undefined {
|
||||
if (tier !== "slow" || !model.reasoning) return undefined;
|
||||
const efforts = getSupportedEfforts(model);
|
||||
if (efforts.length === 0) return undefined;
|
||||
@@ -93,23 +98,26 @@ function reasoningForTier(tier: LlmTier, model: Model<Api>): Effort | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single stateless completion on behalf of an eval cell's `llm()` call.
|
||||
* Run a single stateless completion on behalf of an eval cell's `completion()` call.
|
||||
* Returns a `{ text, details }` value shaped like a {@link callSessionTool}
|
||||
* result so the existing bridge transport carries it to either runtime.
|
||||
*/
|
||||
export async function runEvalLlm(args: unknown, options: EvalLlmBridgeOptions): Promise<EvalLlmResult> {
|
||||
const parsed = llmArgsSchema.safeParse(args);
|
||||
export async function runEvalCompletion(
|
||||
args: unknown,
|
||||
options: EvalCompletionBridgeOptions,
|
||||
): Promise<EvalCompletionResult> {
|
||||
const parsed = completionArgsSchema.safeParse(args);
|
||||
if (!parsed.success) {
|
||||
const issue = parsed.error.issues[0];
|
||||
const where = issue?.path.length ? `${issue.path.join(".")}: ` : "";
|
||||
throw new ToolError(`llm() received invalid arguments: ${where}${issue?.message ?? "bad input"}`);
|
||||
throw new ToolError(`completion() received invalid arguments: ${where}${issue?.message ?? "bad input"}`);
|
||||
}
|
||||
const { prompt, model: tier, system, schema } = parsed.data;
|
||||
|
||||
const model = resolveTierModel(tier, options.session);
|
||||
if (!model) {
|
||||
throw new ToolError(
|
||||
`llm() could not resolve a model for the "${tier}" tier. Configure modelRoles.${tier === "default" ? "default" : tier} or ensure a provider is available.`,
|
||||
`completion() could not resolve a model for the "${tier}" tier. Configure modelRoles.${tier === "default" ? "default" : tier} or ensure a provider is available.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -117,7 +125,7 @@ export async function runEvalLlm(args: unknown, options: EvalLlmBridgeOptions):
|
||||
const apiKey = await registry?.getApiKey(model);
|
||||
if (!registry || !apiKey) {
|
||||
throw new ToolError(
|
||||
`llm() has no API key for ${formatModelString(model)}. Configure credentials for this provider or choose another tier.`,
|
||||
`completion() has no API key for ${formatModelString(model)}. Configure credentials for this provider or choose another tier.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -134,13 +142,19 @@ export async function runEvalLlm(args: unknown, options: EvalLlmBridgeOptions):
|
||||
|
||||
const telemetry = resolveTelemetry(options.session.getTelemetry?.(), options.session.getSessionId?.() ?? undefined);
|
||||
|
||||
// Some providers (notably openai-codex) require a non-empty `instructions`
|
||||
// field on every Responses request and 400 with "Instructions are required"
|
||||
// when it is missing. Fall back to a minimal default so `completion(prompt)` works
|
||||
// without forcing every caller to pass a `system` prompt.
|
||||
const systemPrompt = system ? [system] : ["You are a helpful assistant."];
|
||||
|
||||
// Suspend eval timeout accounting while the model request owns control. The
|
||||
// timeout clock restarts once the bridge returns to the cell runtime.
|
||||
const response = await withBridgeTimeoutPause(options.emitStatus, () =>
|
||||
instrumentedCompleteSimple(
|
||||
model,
|
||||
{
|
||||
systemPrompt: system ? [system] : undefined,
|
||||
systemPrompt,
|
||||
messages: [{ role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now() }],
|
||||
tools,
|
||||
},
|
||||
@@ -153,15 +167,15 @@ export async function runEvalLlm(args: unknown, options: EvalLlmBridgeOptions):
|
||||
reasoning: reasoningForTier(tier, model),
|
||||
toolChoice: schema ? { type: "tool", name: STRUCTURED_TOOL_NAME } : undefined,
|
||||
},
|
||||
{ telemetry, oneshotKind: "eval_llm" },
|
||||
{ telemetry, oneshotKind: "eval_completion" },
|
||||
),
|
||||
);
|
||||
|
||||
if (response.stopReason === "error") {
|
||||
throw new ToolError(response.errorMessage ?? "llm() request failed.");
|
||||
throw new ToolError(response.errorMessage ?? "completion() request failed.");
|
||||
}
|
||||
if (response.stopReason === "aborted") {
|
||||
throw new ToolError("llm() request aborted.");
|
||||
throw new ToolError("completion() request aborted.");
|
||||
}
|
||||
|
||||
let resultText: string;
|
||||
@@ -172,20 +186,20 @@ export async function runEvalLlm(args: unknown, options: EvalLlmBridgeOptions):
|
||||
value = call.arguments;
|
||||
} else {
|
||||
const text = extractTextContent(response);
|
||||
if (!text) throw new ToolError("llm() returned no structured response.");
|
||||
if (!text) throw new ToolError("completion() returned no structured response.");
|
||||
try {
|
||||
value = parseJsonPayload(text);
|
||||
} catch {
|
||||
throw new ToolError("llm() did not return a structured response matching the schema.");
|
||||
throw new ToolError("completion() did not return a structured response matching the schema.");
|
||||
}
|
||||
}
|
||||
resultText = JSON.stringify(value);
|
||||
} else {
|
||||
resultText = extractTextContent(response);
|
||||
if (!resultText) throw new ToolError("llm() returned no text output.");
|
||||
if (!resultText) throw new ToolError("completion() returned no text output.");
|
||||
}
|
||||
|
||||
options.emitStatus?.({ op: "llm", model: formatModelString(model), tier, chars: resultText.length });
|
||||
options.emitStatus?.({ op: "completion", model: formatModelString(model), tier, chars: resultText.length });
|
||||
|
||||
return { text: resultText, details: { model: formatModelString(model), tier, structured: Boolean(schema) } };
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* A cell's `timeout` bounds time while the Python kernel or JS VM is in control.
|
||||
* Host-side bridge calls can {@link pause} the watchdog so delegated
|
||||
* `agent()`/`parallel()`/`llm()` work is ignored completely, then {@link resume}
|
||||
* `agent()`/`parallel()`/`completion()` work is ignored completely, then {@link resume}
|
||||
* starts a fresh timeout window once the runtime gets control back.
|
||||
*
|
||||
* The active timer self-reschedules instead of being torn down on every
|
||||
|
||||
@@ -30,6 +30,7 @@ interface WorkerHandle {
|
||||
mode: "worker" | "inline";
|
||||
send(msg: WorkerInbound): void;
|
||||
onMessage(handler: (msg: WorkerOutbound) => void): () => void;
|
||||
close(): Promise<boolean>;
|
||||
terminate(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -52,7 +53,7 @@ interface JsSession {
|
||||
|
||||
const sessions = new Map<string, JsSession>();
|
||||
const startingSessions = new Map<string, Promise<JsSession>>();
|
||||
const resettingSessions = new Set<string>();
|
||||
const resettingSessions = new Map<string, Promise<void>>();
|
||||
// Worker startup (module-graph import + WorkerCore construction) is infrastructure
|
||||
// cost, not user compute. Floor it independently of Bun's 5s default per-test timeout
|
||||
// so a slow cold-start under load isn't aborted mid-init — terminating a still-
|
||||
@@ -60,6 +61,7 @@ const resettingSessions = new Set<string>();
|
||||
// avoiding `vm.runInContext` (see shared/indirect-eval.ts), here surfacing as a
|
||||
// SIGILL/SIGSEGV. Callers that pass a larger per-cell budget still dominate.
|
||||
const WORKER_INIT_TIMEOUT_MS = 15_000;
|
||||
const WORKER_CLOSE_TIMEOUT_MS = 1_000;
|
||||
|
||||
export async function executeInVmContext(options: {
|
||||
sessionKey: string;
|
||||
@@ -73,17 +75,28 @@ export async function executeInVmContext(options: {
|
||||
runState: VmRunState;
|
||||
}): Promise<{ value: unknown }> {
|
||||
if (options.reset) {
|
||||
if (resettingSessions.has(options.sessionKey)) {
|
||||
throw new ToolError("JS context reset already in progress");
|
||||
// Coalesce concurrent resets: an existing in-flight reset already
|
||||
// produces a fresh context, so a follow-up `reset: true` cell should
|
||||
// just wait for it rather than failing the user-visible call.
|
||||
const inFlight = resettingSessions.get(options.sessionKey);
|
||||
if (inFlight) await inFlight.catch(() => undefined);
|
||||
else {
|
||||
const resetPromise = resetVmContext(options.sessionKey);
|
||||
resettingSessions.set(
|
||||
options.sessionKey,
|
||||
resetPromise.then(() => undefined),
|
||||
);
|
||||
try {
|
||||
await resetPromise;
|
||||
} finally {
|
||||
resettingSessions.delete(options.sessionKey);
|
||||
}
|
||||
}
|
||||
resettingSessions.add(options.sessionKey);
|
||||
try {
|
||||
await resetVmContext(options.sessionKey);
|
||||
} finally {
|
||||
resettingSessions.delete(options.sessionKey);
|
||||
}
|
||||
} else if (resettingSessions.has(options.sessionKey)) {
|
||||
throw new ToolError("JS context reset in progress");
|
||||
} else {
|
||||
// Internal coordination: wait for any in-flight reset to settle and
|
||||
// then run on the freshly-rebuilt context.
|
||||
const inFlight = resettingSessions.get(options.sessionKey);
|
||||
if (inFlight) await inFlight.catch(() => undefined);
|
||||
}
|
||||
const session = await acquireSession(
|
||||
options.sessionKey,
|
||||
@@ -97,7 +110,7 @@ export async function resetVmContext(sessionKey: string): Promise<void> {
|
||||
const session = sessions.get(sessionKey) ?? (await startingSessions.get(sessionKey)?.catch(() => undefined));
|
||||
if (!session) return;
|
||||
sessions.delete(sessionKey);
|
||||
await killSession(session, new ToolError("JS context reset"));
|
||||
await killSession(session, new ToolError("JS context reset"), { force: false });
|
||||
}
|
||||
|
||||
export async function disposeAllVmContexts(): Promise<void> {
|
||||
@@ -110,7 +123,7 @@ export async function disposeAllVmContexts(): Promise<void> {
|
||||
if (!all.includes(result.value)) all.push(result.value);
|
||||
}
|
||||
sessions.clear();
|
||||
await Promise.all(all.map(session => killSession(session, new ToolError("JS context disposed"))));
|
||||
await Promise.all(all.map(session => killSession(session, new ToolError("JS context disposed"), { force: false })));
|
||||
}
|
||||
|
||||
async function runOnce(
|
||||
@@ -143,7 +156,7 @@ async function runOnce(
|
||||
// Cancel any in-flight tool calls first.
|
||||
for (const ctrl of pending.toolCalls.values()) ctrl.abort(abortError);
|
||||
// Hard-kill the worker — only way to interrupt synchronous user code.
|
||||
void killSessionFor(session, abortError);
|
||||
void killSessionFor(session, abortError, { force: true });
|
||||
};
|
||||
|
||||
if (options.runState.signal?.aborted) {
|
||||
@@ -283,14 +296,14 @@ function settlePending(session: JsSession, msg: Extract<WorkerOutbound, { type:
|
||||
pending.reject(errorFromPayload(msg.error));
|
||||
}
|
||||
|
||||
async function killSessionFor(session: JsSession, error: Error): Promise<void> {
|
||||
async function killSessionFor(session: JsSession, error: Error, options: { force: boolean }): Promise<void> {
|
||||
if (sessions.get(session.sessionKey) === session) {
|
||||
sessions.delete(session.sessionKey);
|
||||
}
|
||||
await killSession(session, error);
|
||||
await killSession(session, error, options);
|
||||
}
|
||||
|
||||
async function killSession(session: JsSession, error: Error): Promise<void> {
|
||||
async function killSession(session: JsSession, error: Error, options: { force: boolean }): Promise<void> {
|
||||
if (session.state === "dead") return;
|
||||
session.state = "dead";
|
||||
for (const pending of session.pending.values()) {
|
||||
@@ -300,6 +313,11 @@ async function killSession(session: JsSession, error: Error): Promise<void> {
|
||||
pending.reject(error);
|
||||
}
|
||||
session.pending.clear();
|
||||
if (options.force) {
|
||||
await session.worker.terminate().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
if (await session.worker.close().catch(() => false)) return;
|
||||
await session.worker.terminate().catch(() => undefined);
|
||||
}
|
||||
|
||||
@@ -387,6 +405,38 @@ function wrapBunWorker(worker: Worker): WorkerHandle {
|
||||
worker.addEventListener("message", wrap);
|
||||
return () => worker.removeEventListener("message", wrap);
|
||||
},
|
||||
async close() {
|
||||
const { promise: closed, resolve } = Promise.withResolvers<boolean>();
|
||||
let settled = false;
|
||||
let sawClosedAck = false;
|
||||
let sawWorkerExit = false;
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
let unsubscribe = (): void => {};
|
||||
const finish = (value: boolean): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
worker.removeEventListener("close", onClose);
|
||||
resolve(value);
|
||||
};
|
||||
const finishIfClosed = (): void => {
|
||||
if (sawClosedAck && sawWorkerExit) finish(true);
|
||||
};
|
||||
const onClose = (): void => {
|
||||
sawWorkerExit = true;
|
||||
finishIfClosed();
|
||||
};
|
||||
unsubscribe = this.onMessage(msg => {
|
||||
if (msg.type !== "closed") return;
|
||||
sawClosedAck = true;
|
||||
finishIfClosed();
|
||||
});
|
||||
worker.addEventListener("close", onClose);
|
||||
timeout = setTimeout(() => finish(false), WORKER_CLOSE_TIMEOUT_MS);
|
||||
worker.postMessage({ type: "close" } satisfies WorkerInbound);
|
||||
return await closed;
|
||||
},
|
||||
async terminate() {
|
||||
worker.terminate();
|
||||
},
|
||||
@@ -423,6 +473,27 @@ function spawnInlineWorker(): WorkerHandle {
|
||||
hostListeners.add(handler);
|
||||
return () => hostListeners.delete(handler);
|
||||
},
|
||||
async close() {
|
||||
const { promise: closed, resolve } = Promise.withResolvers<boolean>();
|
||||
let settled = false;
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
let unsubscribe = (): void => {};
|
||||
const finish = (value: boolean): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
hostListeners.clear();
|
||||
workerListeners.clear();
|
||||
resolve(value);
|
||||
};
|
||||
unsubscribe = this.onMessage(msg => {
|
||||
if (msg.type === "closed") finish(true);
|
||||
});
|
||||
this.send({ type: "close" });
|
||||
timeout = setTimeout(() => finish(false), WORKER_CLOSE_TIMEOUT_MS);
|
||||
return await closed;
|
||||
},
|
||||
async terminate() {
|
||||
hostListeners.clear();
|
||||
workerListeners.clear();
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
if (!globalThis.__omp_js_prelude_loaded__) {
|
||||
globalThis.__omp_js_prelude_loaded__ = true;
|
||||
|
||||
const toOptions = value => (value && typeof value === "object" && !Array.isArray(value) ? value : {});
|
||||
const isPlainObject = value => value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
const optionsArg = (name, value, rest, example) => {
|
||||
if (rest.length > 0) {
|
||||
throw new TypeError(
|
||||
`${name}() takes options as a single trailing object literal, not positional arguments (got ${rest.length + 1} extra args). Pass them as ${name}(..., ${example}).`,
|
||||
);
|
||||
}
|
||||
if (value === undefined || value === null) return {};
|
||||
if (!isPlainObject(value)) {
|
||||
const kind = Array.isArray(value) ? "an array" : typeof value;
|
||||
throw new TypeError(
|
||||
`${name}() options must be a trailing object literal like ${example}, not ${kind}. JS helpers never take positional options.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const callHelper = (name, ...args) => globalThis.__omp_helpers__[name](...args);
|
||||
|
||||
const read = (path, opts = {}) => callHelper("read", path, toOptions(opts));
|
||||
const read = (path, opts, ...rest) => callHelper("read", path, optionsArg("read", opts, rest, "{ offset, limit }"));
|
||||
const write = async (path, data) => callHelper("writeFile", path, data);
|
||||
const append = (path, content) => callHelper("append", path, content);
|
||||
const sort = (text, opts = {}) => callHelper("sortText", text, toOptions(opts));
|
||||
const uniq = (text, opts = {}) => callHelper("uniqText", text, toOptions(opts));
|
||||
const counter = (items, opts = {}) => callHelper("counter", items, toOptions(opts));
|
||||
const sort = (text, opts, ...rest) => callHelper("sortText", text, optionsArg("sort", opts, rest, "{ reverse, unique }"));
|
||||
const uniq = (text, opts, ...rest) => callHelper("uniqText", text, optionsArg("uniq", opts, rest, "{ count }"));
|
||||
const counter = (items, opts, ...rest) =>
|
||||
callHelper("counter", items, optionsArg("counter", opts, rest, "{ limit, reverse }"));
|
||||
const diff = (a, b) => callHelper("diff", a, b);
|
||||
const tree = (path = ".", opts = {}) => callHelper("tree", path, toOptions(opts));
|
||||
const tree = (path = ".", opts, ...rest) => callHelper("tree", path, optionsArg("tree", opts, rest, "{ maxDepth, showHidden }"));
|
||||
const env = (key, value) => callHelper("env", key, value);
|
||||
|
||||
const tool = new Proxy(
|
||||
@@ -41,15 +57,15 @@ if (!globalThis.__omp_js_prelude_loaded__) {
|
||||
|
||||
const hasOwn = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
|
||||
|
||||
const llm = async (prompt, opts = {}) => {
|
||||
const o = toOptions(opts);
|
||||
const res = await globalThis.__omp_call_tool__("__llm__", { prompt, ...o });
|
||||
const completion = async (prompt, opts, ...rest) => {
|
||||
const o = optionsArg("completion", opts, rest, "{ model, system, schema }");
|
||||
const res = await globalThis.__omp_call_tool__("__completion__", { prompt, ...o });
|
||||
const text = res && typeof res === "object" ? res.text : res;
|
||||
return hasOwn(o, "schema") ? JSON.parse(text) : text;
|
||||
};
|
||||
|
||||
const agent = async (prompt, opts = {}) => {
|
||||
const o = toOptions(opts);
|
||||
const agent = async (prompt, opts, ...rest) => {
|
||||
const o = optionsArg("agent", opts, rest, "{ agentType, model, context, label, schema }");
|
||||
const res = await globalThis.__omp_call_tool__("__agent__", { prompt, ...o });
|
||||
const text = res && typeof res === "object" ? res.text : res;
|
||||
return hasOwn(o, "schema") ? JSON.parse(text) : text;
|
||||
@@ -148,7 +164,7 @@ if (!globalThis.__omp_js_prelude_loaded__) {
|
||||
globalThis.print = consoleBridge.log;
|
||||
globalThis.display = display;
|
||||
globalThis.tool = tool;
|
||||
globalThis.llm = llm;
|
||||
globalThis.completion = completion;
|
||||
globalThis.output = output;
|
||||
globalThis.agent = agent;
|
||||
globalThis.parallel = parallel;
|
||||
|
||||
@@ -3,8 +3,8 @@ import type { ToolSession } from "../../tools";
|
||||
import { ToolError } from "../../tools/tool-errors";
|
||||
import { EVAL_AGENT_BRIDGE_NAME, runEvalAgent } from "../agent-bridge";
|
||||
import { EVAL_BUDGET_BRIDGE_NAME, type EvalBudgetResult, runEvalBudget } from "../budget-bridge";
|
||||
import { EVAL_COMPLETION_BRIDGE_NAME, runEvalCompletion } from "../completion-bridge";
|
||||
import { EVAL_CONCURRENCY_BRIDGE_NAME, type EvalConcurrencyResult, runEvalConcurrency } from "../concurrency-bridge";
|
||||
import { EVAL_LLM_BRIDGE_NAME, runEvalLlm } from "../llm-bridge";
|
||||
import type { JsStatusEvent } from "./shared/types";
|
||||
|
||||
export type { JsStatusEvent } from "./shared/types";
|
||||
@@ -107,8 +107,8 @@ function summarizeToolResult(
|
||||
}
|
||||
|
||||
export async function callSessionTool(name: string, args: unknown, options: ToolBridgeOptions): Promise<ToolValue> {
|
||||
if (name === EVAL_LLM_BRIDGE_NAME) {
|
||||
return await runEvalLlm(args, options);
|
||||
if (name === EVAL_COMPLETION_BRIDGE_NAME) {
|
||||
return await runEvalCompletion(args, options);
|
||||
}
|
||||
if (name === EVAL_AGENT_BRIDGE_NAME) {
|
||||
return await runEvalAgent(args, options);
|
||||
|
||||
@@ -18,6 +18,12 @@ const transport: Transport = {
|
||||
} catch {
|
||||
// Already closed.
|
||||
}
|
||||
|
||||
// `parentPort.close()` only disconnects the channel in Bun; it does not
|
||||
// make the Worker emit `close` or reap ref'ed user handles. Exit from
|
||||
// inside the worker after `WorkerCore` has sent the `closed` ack so the
|
||||
// host can observe real worker exit without calling `Worker.terminate()`.
|
||||
setTimeout(() => process.exit(0), 0);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { PYTHON_PRELUDE } from "../prelude";
|
||||
|
||||
describe("python prelude", () => {
|
||||
it("exposes read(path, offset?, limit?) with positional optional args", () => {
|
||||
// The eval docs advertise `read(path, offset?=1, limit?=None)`. A
|
||||
// keyword-only signature (`def read(path, *, offset=1, limit=None)`)
|
||||
// makes `read("file", 10)` raise `TypeError: read() takes 1 positional
|
||||
// argument but 2 were given`, which agents in the wild repeatedly hit.
|
||||
// Lock the contract so the helper accepts both positional and keyword
|
||||
// forms.
|
||||
const match = PYTHON_PRELUDE.match(/def\s+read\(([^)]+)\)/);
|
||||
expect(match).not.toBeNull();
|
||||
const signature = match?.[1] ?? "";
|
||||
expect(signature).not.toContain("*,");
|
||||
expect(signature).toContain("offset");
|
||||
expect(signature).toContain("limit");
|
||||
});
|
||||
});
|
||||
@@ -126,7 +126,7 @@ interface PythonSession {
|
||||
|
||||
const sessions = new Map<string, PythonSession>();
|
||||
const startingSessions = new Map<string, Promise<PythonSession>>();
|
||||
const resettingSessions = new Set<string>();
|
||||
const resettingSessions = new Map<string, Promise<void>>();
|
||||
|
||||
function normalizeSessionCwd(cwd: string): string {
|
||||
return path.resolve(cwd);
|
||||
@@ -611,17 +611,29 @@ async function executeOnSession(code: string, cwd: string, options: PythonExecut
|
||||
options.bridgeSessionId = sessionId;
|
||||
}
|
||||
if (options.reset) {
|
||||
if (resettingSessions.has(sessionKey)) {
|
||||
throw new Error("Python kernel reset already in progress");
|
||||
// Coalesce concurrent resets: if another reset is in flight for this
|
||||
// session, await it instead of throwing — the caller's intent ("start
|
||||
// from a clean kernel") is satisfied once that reset settles.
|
||||
const inFlight = resettingSessions.get(sessionKey);
|
||||
if (inFlight) await inFlight.catch(() => undefined);
|
||||
else {
|
||||
const resetPromise = resetSession(sessionKey);
|
||||
resettingSessions.set(
|
||||
sessionKey,
|
||||
resetPromise.then(() => undefined),
|
||||
);
|
||||
try {
|
||||
await resetPromise;
|
||||
} finally {
|
||||
resettingSessions.delete(sessionKey);
|
||||
}
|
||||
}
|
||||
resettingSessions.add(sessionKey);
|
||||
try {
|
||||
await resetSession(sessionKey);
|
||||
} finally {
|
||||
resettingSessions.delete(sessionKey);
|
||||
}
|
||||
} else if (resettingSessions.has(sessionKey)) {
|
||||
throw new Error("Python kernel reset in progress");
|
||||
} else {
|
||||
// A reset already in progress is an internal coordination state, not a
|
||||
// user-visible failure. Wait for it to clear, then proceed with the
|
||||
// requested execution on the freshly-restarted kernel.
|
||||
const inFlight = resettingSessions.get(sessionKey);
|
||||
if (inFlight) await inFlight.catch(() => undefined);
|
||||
}
|
||||
const session = await acquireSession(sessionKey, sessionId, cwd, options);
|
||||
if (options.signal?.aborted) {
|
||||
|
||||
@@ -53,7 +53,7 @@ if "__omp_prelude_loaded__" not in globals():
|
||||
_emit_status("env", key=key, value=val, action="get")
|
||||
return val
|
||||
|
||||
def read(path: str | Path, *, offset: int = 1, limit: int | None = None) -> str:
|
||||
def read(path: str | Path, offset: int = 1, limit: int | None = None) -> str:
|
||||
"""Read file contents. offset/limit are 1-indexed line numbers."""
|
||||
p = Path(path)
|
||||
data = p.read_text(encoding="utf-8")
|
||||
@@ -463,8 +463,8 @@ if "__omp_prelude_loaded__" not in globals():
|
||||
|
||||
tool = _ToolProxy()
|
||||
|
||||
def llm(prompt, *, model="default", system=None, schema=None):
|
||||
"""Oneshot, stateless LLM call against a model tier.
|
||||
def completion(prompt, *, model="default", system=None, schema=None):
|
||||
"""Oneshot, stateless completion against a model tier.
|
||||
|
||||
`model` selects a tier: "smol", "default" (the session's active model),
|
||||
or "slow". Pass `system` for a system prompt. Pass a JSON-Schema dict
|
||||
@@ -476,7 +476,7 @@ if "__omp_prelude_loaded__" not in globals():
|
||||
args["system"] = system
|
||||
if schema is not None:
|
||||
args["schema"] = schema
|
||||
res = _bridge_call("__llm__", args)
|
||||
res = _bridge_call("__completion__", args)
|
||||
text = res.get("text") if isinstance(res, dict) else res
|
||||
return json.loads(text) if schema is not None else text
|
||||
|
||||
|
||||
@@ -946,18 +946,28 @@ export async function shutdownClient(key: string): Promise<void> {
|
||||
// LSP Protocol Methods
|
||||
// =============================================================================
|
||||
|
||||
/** Default timeout for LSP requests (30 seconds) */
|
||||
/** Default timeout for LSP requests when no abort signal is provided (30 seconds) */
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
|
||||
|
||||
/**
|
||||
* Send an LSP request and wait for response.
|
||||
*
|
||||
* Timeout policy:
|
||||
* - If `timeoutMs` is explicitly provided, that value is used.
|
||||
* - Else, if `signal` is provided, no internal timer is installed (the caller
|
||||
* owns the deadline via the signal — typically a wall-clock `AbortSignal.timeout`
|
||||
* from the LSP tool). Installing a second hard-coded 30s timer here used to
|
||||
* cause "timed out after 30000ms" errors even when the caller had requested
|
||||
* `timeout: 60`.
|
||||
* - Else (no signal, no explicit timeout), fall back to `DEFAULT_REQUEST_TIMEOUT_MS`
|
||||
* to avoid leaking pending requests forever.
|
||||
*/
|
||||
export async function sendRequest(
|
||||
client: LspClient,
|
||||
method: string,
|
||||
params: unknown,
|
||||
signal?: AbortSignal,
|
||||
timeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
timeoutMs?: number,
|
||||
): Promise<unknown> {
|
||||
// Atomically increment and capture request ID
|
||||
const id = ++client.requestId;
|
||||
@@ -993,15 +1003,17 @@ export async function sendRequest(
|
||||
reject(reason);
|
||||
};
|
||||
|
||||
// Set timeout
|
||||
timeout = setTimeout(() => {
|
||||
if (client.pendingRequests.has(id)) {
|
||||
client.pendingRequests.delete(id);
|
||||
const err = new Error(`LSP request ${method} timed out after ${timeoutMs}ms`);
|
||||
cleanup();
|
||||
reject(err);
|
||||
}
|
||||
}, timeoutMs);
|
||||
const effectiveTimeoutMs = timeoutMs ?? (signal ? undefined : DEFAULT_REQUEST_TIMEOUT_MS);
|
||||
if (effectiveTimeoutMs !== undefined) {
|
||||
timeout = setTimeout(() => {
|
||||
if (client.pendingRequests.has(id)) {
|
||||
client.pendingRequests.delete(id);
|
||||
const err = new Error(`LSP request ${method} timed out after ${effectiveTimeoutMs}ms`);
|
||||
cleanup();
|
||||
reject(err);
|
||||
}
|
||||
}, effectiveTimeoutMs);
|
||||
}
|
||||
if (signal) {
|
||||
signal.addEventListener("abort", abortHandler, { once: true });
|
||||
if (signal.aborted) {
|
||||
|
||||
@@ -450,13 +450,23 @@ export function loadConfig(cwd: string): LspConfig {
|
||||
*/
|
||||
export function getServersForFile(config: LspConfig, filePath: string): Array<[string, ServerConfig]> {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const extNoDot = ext.startsWith(".") ? ext.slice(1) : ext;
|
||||
const fileName = path.basename(filePath).toLowerCase();
|
||||
const matches: Array<[string, ServerConfig]> = [];
|
||||
|
||||
for (const [name, serverConfig] of Object.entries(config.servers)) {
|
||||
const supportsFile = serverConfig.fileTypes.some(fileType => {
|
||||
// Accept both `.ts` and `ts` forms in user config / fixtures so a
|
||||
// missing dot in `fileTypes` doesn't silently exclude the server
|
||||
// from extension-based routing (e.g. rename_file's relevance filter).
|
||||
const normalized = fileType.toLowerCase();
|
||||
return normalized === ext || normalized === fileName;
|
||||
const normalizedNoDot = normalized.startsWith(".") ? normalized.slice(1) : normalized;
|
||||
return (
|
||||
normalized === ext ||
|
||||
normalized === fileName ||
|
||||
normalizedNoDot === extNoDot ||
|
||||
normalizedNoDot === fileName
|
||||
);
|
||||
});
|
||||
|
||||
if (supportsFile) {
|
||||
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
rangesOverlap,
|
||||
} from "./edits";
|
||||
import { detectLspmux } from "./lspmux";
|
||||
import { renderCall, renderResult } from "./render";
|
||||
import {
|
||||
type CodeAction,
|
||||
type CodeActionContext,
|
||||
@@ -302,6 +301,22 @@ function isProjectAwareLspServer(serverConfig: ServerConfig): boolean {
|
||||
const DIAGNOSTIC_MESSAGE_LIMIT = 50;
|
||||
const SINGLE_DIAGNOSTICS_WAIT_TIMEOUT_MS = 3000;
|
||||
const BATCH_DIAGNOSTICS_WAIT_TIMEOUT_MS = 400;
|
||||
const DIAGNOSTICS_POLL_MS = 100;
|
||||
const DIAGNOSTICS_SETTLE_MS = 250;
|
||||
/**
|
||||
* How long the edit/write writethrough blocks inline waiting for fresh
|
||||
* diagnostics before handing slow servers off to the deferred late-injection
|
||||
* channel. Keeps the common fast-server case inline while letting an edit
|
||||
* return promptly when a server (e.g. a large-monorepo tsserver) is slow to
|
||||
* publish fresh diagnostics.
|
||||
*/
|
||||
const INLINE_DIAGNOSTICS_WAIT_TIMEOUT_MS = 500;
|
||||
/**
|
||||
* Inner per-server diagnostics wait budget for the background/deferred fetch.
|
||||
* Longer than the inline cap (and the old 3s default) so a slow server still
|
||||
* delivers late instead of giving up before it ever publishes.
|
||||
*/
|
||||
const DEFERRED_DIAGNOSTICS_WAIT_TIMEOUT_MS = 12_000;
|
||||
const MAX_GLOB_DIAGNOSTIC_TARGETS = 20;
|
||||
const WORKSPACE_SYMBOL_LIMIT = 200;
|
||||
const PROJECT_INDEXED_ACTIONS: ReadonlySet<string> = new Set([
|
||||
@@ -461,27 +476,15 @@ interface WaitForDiagnosticsOptions {
|
||||
signal?: AbortSignal;
|
||||
minVersion?: number;
|
||||
expectedDocumentVersion?: number;
|
||||
allowUnversioned?: boolean;
|
||||
}
|
||||
|
||||
function getAcceptedDiagnostics(
|
||||
publishedDiagnostics: PublishedDiagnostics | undefined,
|
||||
expectedDocumentVersion?: number,
|
||||
allowUnversioned = true,
|
||||
): Diagnostic[] | undefined {
|
||||
if (!publishedDiagnostics) {
|
||||
return undefined;
|
||||
}
|
||||
if (expectedDocumentVersion === undefined) {
|
||||
return publishedDiagnostics.diagnostics;
|
||||
}
|
||||
if (publishedDiagnostics.version === expectedDocumentVersion) {
|
||||
return publishedDiagnostics.diagnostics;
|
||||
}
|
||||
if (allowUnversioned && publishedDiagnostics.version == null) {
|
||||
return publishedDiagnostics.diagnostics;
|
||||
}
|
||||
return undefined;
|
||||
/**
|
||||
* Quiescence window (ms). typescript-language-server never echoes the document
|
||||
* version (issue #983) and emits diagnostics from several sources at different
|
||||
* times, so there is no single "complete, version-matched" publish to gate on.
|
||||
* When the server does not exact-version-match, accept the latest publish only
|
||||
* after no newer one has arrived for this long, letting an in-flight pre-edit
|
||||
* publish be superseded by the fresh one.
|
||||
*/
|
||||
settleMs?: number;
|
||||
}
|
||||
|
||||
async function waitForDiagnostics(
|
||||
@@ -489,26 +492,35 @@ async function waitForDiagnostics(
|
||||
uri: string,
|
||||
options: WaitForDiagnosticsOptions = {},
|
||||
): Promise<Diagnostic[]> {
|
||||
const { timeoutMs = 3000, signal, minVersion, expectedDocumentVersion, allowUnversioned = true } = options;
|
||||
const { timeoutMs = 3000, signal, minVersion, expectedDocumentVersion, settleMs = DIAGNOSTICS_SETTLE_MS } = options;
|
||||
const start = Date.now();
|
||||
let settledRef: PublishedDiagnostics | undefined;
|
||||
let settledAt = 0;
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
throwIfAborted(signal);
|
||||
const versionOk = minVersion === undefined || client.diagnosticsVersion > minVersion;
|
||||
const diagnostics = getAcceptedDiagnostics(
|
||||
client.diagnostics.get(uri),
|
||||
expectedDocumentVersion,
|
||||
allowUnversioned,
|
||||
);
|
||||
if (diagnostics !== undefined && versionOk) {
|
||||
return diagnostics;
|
||||
const published = client.diagnostics.get(uri);
|
||||
if (published && versionOk) {
|
||||
// Server honored our exact document version → authoritative, accept now.
|
||||
if (expectedDocumentVersion !== undefined && published.version === expectedDocumentVersion) {
|
||||
return published.diagnostics;
|
||||
}
|
||||
// Unversioned/mismatched publish: wait for the stream to go quiet so an
|
||||
// in-flight publish for the pre-edit content is superseded by the fresh one.
|
||||
if (published !== settledRef) {
|
||||
settledRef = published;
|
||||
settledAt = Date.now();
|
||||
} else if (Date.now() - settledAt >= settleMs) {
|
||||
return published.diagnostics;
|
||||
}
|
||||
}
|
||||
await Bun.sleep(100);
|
||||
await Bun.sleep(DIAGNOSTICS_POLL_MS);
|
||||
}
|
||||
const versionOk = minVersion === undefined || client.diagnosticsVersion > minVersion;
|
||||
if (!versionOk) {
|
||||
return [];
|
||||
}
|
||||
return getAcceptedDiagnostics(client.diagnostics.get(uri), expectedDocumentVersion, allowUnversioned) ?? [];
|
||||
return client.diagnostics.get(uri)?.diagnostics ?? [];
|
||||
}
|
||||
|
||||
/** Project type detection result */
|
||||
@@ -613,7 +625,8 @@ interface GetDiagnosticsForFileOptions {
|
||||
signal?: AbortSignal;
|
||||
minVersions?: ServerVersionMap;
|
||||
expectedDocumentVersions?: ServerVersionMap;
|
||||
allowUnversionedLspDiagnostics?: boolean;
|
||||
/** Per-server wait budget (ms). Defaults to {@link SINGLE_DIAGNOSTICS_WAIT_TIMEOUT_MS}. */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -669,7 +682,7 @@ async function getDiagnosticsForFile(
|
||||
servers: Array<[string, ServerConfig]>,
|
||||
options: GetDiagnosticsForFileOptions = {},
|
||||
): Promise<FileDiagnosticsResult | undefined> {
|
||||
const { signal, minVersions, expectedDocumentVersions, allowUnversionedLspDiagnostics = true } = options;
|
||||
const { signal, minVersions, expectedDocumentVersions, timeoutMs } = options;
|
||||
if (servers.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -701,11 +714,10 @@ async function getDiagnosticsForFile(
|
||||
const minVersion = minVersions?.get(serverName);
|
||||
const expectedDocumentVersion = expectedDocumentVersions?.get(serverName);
|
||||
const diagnostics = await waitForDiagnostics(client, uri, {
|
||||
timeoutMs: 3000,
|
||||
timeoutMs: timeoutMs ?? SINGLE_DIAGNOSTICS_WAIT_TIMEOUT_MS,
|
||||
signal,
|
||||
minVersion,
|
||||
expectedDocumentVersion,
|
||||
allowUnversioned: allowUnversionedLspDiagnostics,
|
||||
});
|
||||
return { serverName, diagnostics };
|
||||
}),
|
||||
@@ -1007,6 +1019,7 @@ async function scheduleDeferredDiagnosticsFetch(args: {
|
||||
signal: combined,
|
||||
minVersions: args.minVersions,
|
||||
expectedDocumentVersions: args.expectedDocumentVersions,
|
||||
timeoutMs: DEFERRED_DIAGNOSTICS_WAIT_TIMEOUT_MS,
|
||||
});
|
||||
if (args.signal.aborted || diagnostics === undefined) return;
|
||||
args.callback(diagnostics);
|
||||
@@ -1015,6 +1028,70 @@ async function scheduleDeferredDiagnosticsFetch(args: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch post-write diagnostics without making the edit/write block on a slow
|
||||
* language server.
|
||||
*
|
||||
* Blocks inline only briefly ({@link INLINE_DIAGNOSTICS_WAIT_TIMEOUT_MS}) for a
|
||||
* fresh result. Freshness is enforced by the pre-edit `minVersions` baseline:
|
||||
* exact document-version matches return immediately, and unversioned/mismatched
|
||||
* publishes must settle with no newer publish before inline acceptance. If
|
||||
* nothing fresh arrives in the inline window and a deferred
|
||||
* channel is available, the in-flight fetch is handed off to deliver late via
|
||||
* `onDeferredDiagnostics`, and this returns `undefined` so the tool result
|
||||
* lands immediately. Without a deferred channel (direct/CI callers) it blocks
|
||||
* for the standard budget so the result is still returned inline.
|
||||
*/
|
||||
async function fetchDiagnosticsWithDeferral(args: {
|
||||
dst: string;
|
||||
cwd: string;
|
||||
servers: Array<[string, ServerConfig]>;
|
||||
minVersions: ServerVersionMap | undefined;
|
||||
expectedDocumentVersions: ServerVersionMap | undefined;
|
||||
transformDiagnostics?: ResolvedWritethroughOptions["transformDiagnostics"];
|
||||
deferred?: { onDeferredDiagnostics: (diagnostics: FileDiagnosticsResult) => void; signal: AbortSignal };
|
||||
signal?: AbortSignal;
|
||||
}): Promise<FileDiagnosticsResult | undefined> {
|
||||
const { dst, cwd, servers, minVersions, expectedDocumentVersions, transformDiagnostics, deferred, signal } = args;
|
||||
const apply = (d: FileDiagnosticsResult | undefined) =>
|
||||
d && transformDiagnostics ? transformDiagnostics(dst, d) : d;
|
||||
|
||||
if (!deferred) {
|
||||
// No late-injection channel: block for the standard budget and return inline.
|
||||
return apply(
|
||||
await getDiagnosticsForFile(dst, cwd, servers, {
|
||||
signal,
|
||||
minVersions,
|
||||
expectedDocumentVersions,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// One background fetch with a generous inner budget; await it only briefly inline.
|
||||
const fetchPromise = getDiagnosticsForFile(dst, cwd, servers, {
|
||||
signal: deferred.signal,
|
||||
minVersions,
|
||||
expectedDocumentVersions,
|
||||
timeoutMs: DEFERRED_DIAGNOSTICS_WAIT_TIMEOUT_MS,
|
||||
});
|
||||
const INLINE_TIMEOUT = Symbol("inline-diagnostics-timeout");
|
||||
const raced = await Promise.race([
|
||||
fetchPromise,
|
||||
Bun.sleep(INLINE_DIAGNOSTICS_WAIT_TIMEOUT_MS).then(() => INLINE_TIMEOUT),
|
||||
]);
|
||||
if (raced !== INLINE_TIMEOUT) {
|
||||
return apply(raced as FileDiagnosticsResult | undefined);
|
||||
}
|
||||
// Slow server: deliver late via the deferred channel; nothing inline. The
|
||||
// deferred sink (edit tool) applies its own dedup, so pass the raw result.
|
||||
void fetchPromise
|
||||
.then(diagnostics => {
|
||||
if (diagnostics && !deferred.signal.aborted) deferred.onDeferredDiagnostics(diagnostics);
|
||||
})
|
||||
.catch(() => {});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function runLspWritethrough(
|
||||
dst: string,
|
||||
content: string,
|
||||
@@ -1047,6 +1124,7 @@ async function runLspWritethrough(
|
||||
let formatter: FileFormatResult | undefined;
|
||||
let diagnostics: FileDiagnosticsResult | undefined;
|
||||
let timedOut = false;
|
||||
let synced = false;
|
||||
try {
|
||||
const timeoutSignal = AbortSignal.timeout(5_000);
|
||||
timeoutSignal.addEventListener(
|
||||
@@ -1090,19 +1168,8 @@ async function runLspWritethrough(
|
||||
|
||||
// 5. Notify saved to LSP servers
|
||||
await notifyFileSaved(dst, cwd, lspServers, operationSignal);
|
||||
|
||||
// 6. Get diagnostics from all servers (wait for fresh results)
|
||||
if (enableDiagnostics) {
|
||||
const fetched = await getDiagnosticsForFile(dst, cwd, servers, {
|
||||
signal: operationSignal,
|
||||
minVersions,
|
||||
expectedDocumentVersions,
|
||||
allowUnversionedLspDiagnostics: false,
|
||||
});
|
||||
diagnostics =
|
||||
fetched && options.transformDiagnostics ? options.transformDiagnostics(dst, fetched) : fetched;
|
||||
}
|
||||
});
|
||||
synced = true;
|
||||
} catch {
|
||||
if (timedOut) {
|
||||
formatter = undefined;
|
||||
@@ -1123,6 +1190,19 @@ async function runLspWritethrough(
|
||||
await getWritePromise();
|
||||
}
|
||||
|
||||
if (synced && enableDiagnostics) {
|
||||
diagnostics = await fetchDiagnosticsWithDeferral({
|
||||
dst,
|
||||
cwd,
|
||||
servers,
|
||||
minVersions,
|
||||
expectedDocumentVersions,
|
||||
transformDiagnostics: options.transformDiagnostics,
|
||||
deferred,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
if (formatter !== undefined) {
|
||||
diagnostics ??= {
|
||||
server: servers.map(([name]) => name).join(", "),
|
||||
@@ -1229,10 +1309,6 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
|
||||
readonly summary = "Query LSP (language server) for diagnostics, hover info, and references";
|
||||
readonly description: string;
|
||||
readonly parameters = lspSchema;
|
||||
readonly renderCall = renderCall;
|
||||
readonly renderResult = renderResult;
|
||||
readonly mergeCallAndResult = true;
|
||||
readonly inline = true;
|
||||
readonly strict = true;
|
||||
|
||||
constructor(private readonly session: ToolSession) {
|
||||
@@ -1261,7 +1337,7 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
|
||||
|
||||
// Status action doesn't need a file
|
||||
if (action === "status") {
|
||||
const servers = Object.keys(config.servers);
|
||||
const configuredNames = Object.keys(config.servers);
|
||||
const lspmuxState = await detectLspmux();
|
||||
const lspmuxStatus = lspmuxState.available
|
||||
? lspmuxState.running
|
||||
@@ -1269,14 +1345,40 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
|
||||
: "lspmux: installed but server not running"
|
||||
: "";
|
||||
|
||||
const serverStatus =
|
||||
servers.length > 0
|
||||
? `Active language servers: ${servers.join(", ")}`
|
||||
: "No language servers configured for this project";
|
||||
// `Object.keys(config.servers)` reflects what is *configured & resolvable
|
||||
// on PATH* — it does NOT prove the server actually starts. A wrapper
|
||||
// binary that exits immediately (e.g. rustup without the rust-analyzer
|
||||
// component) still appears here. Distinguish "configured" from
|
||||
// "started" (have a live in-process client) so callers cannot mistake
|
||||
// presence-on-PATH for a working server.
|
||||
const startedClients = getActiveClients();
|
||||
const startedByConfigName = new Map<string, LspServerStatus>();
|
||||
// getActiveClients() reports `name = client.config.command` (the
|
||||
// unresolved binary name from defaults.json), so match against
|
||||
// `serverConfig.command`, not the resolved path.
|
||||
for (const [name, serverConfig] of Object.entries(config.servers)) {
|
||||
const matched = startedClients.find(c => c.name === serverConfig.command);
|
||||
if (matched) startedByConfigName.set(name, matched);
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
if (configuredNames.length === 0) {
|
||||
lines.push("No language servers configured for this project");
|
||||
} else {
|
||||
const labelled = configuredNames.map(name => {
|
||||
const started = startedByConfigName.get(name);
|
||||
if (!started) return `${name} (configured, not started)`;
|
||||
return `${name} (${started.status})`;
|
||||
});
|
||||
lines.push(`Language servers: ${labelled.join(", ")}`);
|
||||
lines.push(
|
||||
" note: 'configured, not started' means the binary resolves on PATH but no request has spawned it yet; 'ready' means a client process is live for this cwd.",
|
||||
);
|
||||
}
|
||||
if (lspmuxStatus) lines.push(lspmuxStatus);
|
||||
|
||||
const output = lspmuxStatus ? `${serverStatus}\n${lspmuxStatus}` : serverStatus;
|
||||
return {
|
||||
content: [{ type: "text", text: output }],
|
||||
content: [{ type: "text", text: lines.join("\n") }],
|
||||
details: { action, success: true, request: params },
|
||||
};
|
||||
}
|
||||
@@ -1505,7 +1607,26 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
|
||||
}
|
||||
|
||||
const lspParams = { files: pairs };
|
||||
const servers = getLspServers(config);
|
||||
// Filter to servers whose fileTypes match either the source or any
|
||||
// destination path. Asking every configured server about a .md/.sql/.txt
|
||||
// rename used to stack up willRenameFiles requests against irrelevant
|
||||
// language servers and hit the wall-clock timeout. A server only has
|
||||
// something useful to say about a rename if it understands one of the
|
||||
// affected file extensions.
|
||||
const allLspServers = getLspServers(config);
|
||||
const relevantNames = new Set<string>();
|
||||
const collectRelevant = (filePath: string) => {
|
||||
for (const [name] of getLspServersForFile(config, filePath)) {
|
||||
relevantNames.add(name);
|
||||
}
|
||||
};
|
||||
collectRelevant(source);
|
||||
collectRelevant(dest);
|
||||
for (const pair of pairs) {
|
||||
collectRelevant(uriToFile(pair.oldUri));
|
||||
collectRelevant(uriToFile(pair.newUri));
|
||||
}
|
||||
const servers = allLspServers.filter(([name]) => relevantNames.has(name));
|
||||
const respondingServers = new Set<string>();
|
||||
const perServerEdits: Array<{ serverName: string; edit: WorkspaceEdit }> = [];
|
||||
const serverNotes: string[] = [];
|
||||
@@ -1829,8 +1950,15 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
|
||||
throw new ToolAbortError();
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
// Echo a (truncated) preview of the params we sent so the caller can
|
||||
// tell parse / shape errors (e.g. nested args dropped, missing field)
|
||||
// apart from genuine server errors without spinning up another debug call.
|
||||
const previewRaw = JSON.stringify(requestParams ?? null);
|
||||
const preview = previewRaw.length > 400 ? `${previewRaw.slice(0, 397)}...` : previewRaw;
|
||||
return {
|
||||
content: [{ type: "text", text: `LSP error from ${chosenName} on ${method}: ${msg}` }],
|
||||
content: [
|
||||
{ type: "text", text: `LSP error from ${chosenName} on ${method}: ${msg}\n params: ${preview}` },
|
||||
],
|
||||
details: { action, serverName: chosenName, success: false, request: params },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,7 +29,13 @@ import { selectSession } from "./cli/session-picker";
|
||||
import { applyStartupCwd } from "./cli/startup-cwd";
|
||||
import { findConfigFile } from "./config";
|
||||
import { ModelRegistry, ModelsConfigFile } from "./config/model-registry";
|
||||
import { resolveCliModel, resolveModelRoleValue, resolveModelScope, type ScopedModel } from "./config/model-resolver";
|
||||
import {
|
||||
getModelMatchPreferences,
|
||||
resolveCliModel,
|
||||
resolveModelRoleValue,
|
||||
resolveModelScope,
|
||||
type ScopedModel,
|
||||
} from "./config/model-resolver";
|
||||
import { getDefault, type SettingPath, Settings, settings } from "./config/settings";
|
||||
import { initializeWithSettings } from "./discovery";
|
||||
import {
|
||||
@@ -165,7 +171,8 @@ export async function submitInteractiveInput(
|
||||
|
||||
try {
|
||||
using _keepalive = new EventLoopKeepalive();
|
||||
// Continue shortcuts submit an already-started empty prompt with no optimistic user message.
|
||||
// Continue shortcuts submit an already-started synthetic developer prompt with
|
||||
// no optimistic user message.
|
||||
if (!input.started && !mode.markPendingSubmissionStarted(input)) {
|
||||
return;
|
||||
}
|
||||
@@ -176,6 +183,8 @@ export async function submitInteractiveInput(
|
||||
display: input.display ?? false,
|
||||
attribution: "agent",
|
||||
});
|
||||
} else if (input.synthetic) {
|
||||
await session.prompt(input.text, { synthetic: true, expandPromptTemplates: false });
|
||||
} else {
|
||||
await session.prompt(input.text, { images: input.images });
|
||||
}
|
||||
@@ -375,6 +384,54 @@ async function promptMoveSession(session: SessionInfo): Promise<SessionPromptRes
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Friendly CLI failure raised by {@link createSessionManager} when the user's
|
||||
* session-resolution flags (`--resume`/`--fork`/cross-project prompts) cannot
|
||||
* be satisfied. {@link runRootCommand} catches it and prints a clean stderr
|
||||
* message instead of letting it surface as `[Uncaught Exception]`
|
||||
* (see issue #2084).
|
||||
*/
|
||||
export class SessionResolutionError extends Error {
|
||||
readonly hint?: string;
|
||||
constructor(message: string, hint?: string) {
|
||||
super(message);
|
||||
this.name = "SessionResolutionError";
|
||||
this.hint = hint;
|
||||
}
|
||||
}
|
||||
|
||||
type MissingCwdMoveResult =
|
||||
| { status: "not-needed" }
|
||||
| { status: "declined" }
|
||||
| { status: "moved"; manager: SessionManager };
|
||||
|
||||
async function moveMissingCwdSessionIfNeeded(
|
||||
sessionArg: string,
|
||||
session: SessionInfo,
|
||||
cwd: string,
|
||||
sessionDir: string | undefined,
|
||||
askToMoveSession: SessionPrompt,
|
||||
): Promise<MissingCwdMoveResult> {
|
||||
const sourceCwd = session.cwd;
|
||||
if (!sourceCwd || fsSync.existsSync(sourceCwd)) {
|
||||
return { status: "not-needed" };
|
||||
}
|
||||
|
||||
const movePromptResult = await askToMoveSession(session);
|
||||
if (movePromptResult === "unavailable") {
|
||||
throw new SessionResolutionError(
|
||||
`Session "${sessionArg}" belongs to a directory that no longer exists (${sourceCwd}); run interactively to move it into the current project.`,
|
||||
);
|
||||
}
|
||||
if (movePromptResult === "declined") {
|
||||
return { status: "declined" };
|
||||
}
|
||||
|
||||
const manager = await SessionManager.open(session.path, sessionDir);
|
||||
await manager.moveTo(cwd, sessionDir);
|
||||
return { status: "moved", manager };
|
||||
}
|
||||
|
||||
async function getChangelogForDisplay(parsed: Args): Promise<string | undefined> {
|
||||
if (parsed.continue || parsed.resume) {
|
||||
return undefined;
|
||||
@@ -425,7 +482,7 @@ export async function createSessionManager(
|
||||
): Promise<SessionManager | undefined> {
|
||||
if (parsed.fork) {
|
||||
if (parsed.noSession) {
|
||||
throw new Error("--fork requires session persistence");
|
||||
throw new SessionResolutionError("--fork requires session persistence");
|
||||
}
|
||||
const forkSource = parsed.fork;
|
||||
if (forkSource.includes("/") || forkSource.includes("\\") || forkSource.endsWith(".jsonl")) {
|
||||
@@ -433,7 +490,10 @@ export async function createSessionManager(
|
||||
}
|
||||
const match = await resolveResumableSession(forkSource, cwd, parsed.sessionDir);
|
||||
if (!match) {
|
||||
throw new Error(`Session "${forkSource}" not found.`);
|
||||
throw new SessionResolutionError(
|
||||
`Session "${forkSource}" not found.`,
|
||||
"Run `omp --resume` without an argument to pick from recent sessions, or `omp` to start a new one.",
|
||||
);
|
||||
}
|
||||
return await SessionManager.forkFrom(match.session.path, cwd, parsed.sessionDir);
|
||||
}
|
||||
@@ -448,33 +508,46 @@ export async function createSessionManager(
|
||||
}
|
||||
const match = await resolveResumableSession(sessionArg, cwd, parsed.sessionDir);
|
||||
if (!match) {
|
||||
throw new Error(`Session "${sessionArg}" not found.`);
|
||||
throw new SessionResolutionError(
|
||||
`Session "${sessionArg}" not found.`,
|
||||
"Run `omp --resume` without an argument to pick from recent sessions, or `omp` to start a new one.",
|
||||
);
|
||||
}
|
||||
if (match.scope === "local") {
|
||||
const moveResult = await moveMissingCwdSessionIfNeeded(
|
||||
sessionArg,
|
||||
match.session,
|
||||
cwd,
|
||||
parsed.sessionDir,
|
||||
askToMoveSession,
|
||||
);
|
||||
if (moveResult.status === "moved") {
|
||||
return moveResult.manager;
|
||||
}
|
||||
if (moveResult.status === "declined") {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
if (match.scope === "global") {
|
||||
const normalizedCwd = normalizePathForComparison(cwd);
|
||||
const normalizedMatchCwd = normalizePathForComparison(match.session.cwd || cwd);
|
||||
if (normalizedCwd !== normalizedMatchCwd) {
|
||||
// If the session's recorded directory no longer exists, it was almost
|
||||
// certainly moved/renamed (e.g. `git worktree move`). Re-root the existing
|
||||
// session here instead of forking a duplicate copy.
|
||||
const sourceCwd = match.session.cwd;
|
||||
if (sourceCwd && !fsSync.existsSync(sourceCwd)) {
|
||||
const movePromptResult = await askToMoveSession(match.session);
|
||||
if (movePromptResult === "unavailable") {
|
||||
throw new Error(
|
||||
`Session "${sessionArg}" belongs to a directory that no longer exists (${sourceCwd}); run interactively to move it into the current project.`,
|
||||
);
|
||||
}
|
||||
if (movePromptResult === "declined") {
|
||||
return undefined;
|
||||
}
|
||||
const manager = await SessionManager.open(match.session.path, parsed.sessionDir);
|
||||
await manager.moveTo(cwd, parsed.sessionDir);
|
||||
return manager;
|
||||
const moveResult = await moveMissingCwdSessionIfNeeded(
|
||||
sessionArg,
|
||||
match.session,
|
||||
cwd,
|
||||
parsed.sessionDir,
|
||||
askToMoveSession,
|
||||
);
|
||||
if (moveResult.status === "moved") {
|
||||
return moveResult.manager;
|
||||
}
|
||||
if (moveResult.status === "declined") {
|
||||
return undefined;
|
||||
}
|
||||
const forkPromptResult = await askToForkSession(match.session);
|
||||
if (forkPromptResult === "unavailable") {
|
||||
throw new Error(
|
||||
throw new SessionResolutionError(
|
||||
`Session "${sessionArg}" is in another project (${match.session.cwd}); run interactively to fork it into the current project.`,
|
||||
);
|
||||
}
|
||||
@@ -568,9 +641,7 @@ async function buildSessionOptions(
|
||||
// Model from CLI
|
||||
// - supports --provider <name> --model <pattern>
|
||||
// - supports --model <provider>/<pattern>
|
||||
const modelMatchPreferences = {
|
||||
usageOrder: activeSettings.getStorage()?.getModelUsageOrder(),
|
||||
};
|
||||
const modelMatchPreferences = getModelMatchPreferences(activeSettings);
|
||||
if (parsed.model) {
|
||||
const resolved = resolveCliModel({
|
||||
cliProvider: parsed.provider,
|
||||
@@ -862,9 +933,7 @@ export async function runRootCommand(
|
||||
|
||||
let scopedModels: ScopedModel[] = [];
|
||||
const modelPatterns = parsedArgs.models ?? settingsInstance.get("enabledModels");
|
||||
const modelMatchPreferences = {
|
||||
usageOrder: settingsInstance.getStorage()?.getModelUsageOrder(),
|
||||
};
|
||||
const modelMatchPreferences = getModelMatchPreferences(settingsInstance);
|
||||
if (modelPatterns && modelPatterns.length > 0) {
|
||||
scopedModels = await logger.time(
|
||||
"resolveModelScope",
|
||||
@@ -875,14 +944,29 @@ export async function runRootCommand(
|
||||
);
|
||||
}
|
||||
|
||||
// Create session manager based on CLI flags
|
||||
let sessionManager = await logger.time(
|
||||
"createSessionManager",
|
||||
createSessionManager,
|
||||
parsedArgs,
|
||||
cwd,
|
||||
settingsInstance,
|
||||
);
|
||||
// Create session manager based on CLI flags. SessionResolutionError signals a
|
||||
// user-facing failure (unknown --resume/--fork id, non-interactive fork
|
||||
// prompt, --fork with --no-session): print + exit cleanly instead of letting
|
||||
// it surface as `[Uncaught Exception]` (see issue #2084).
|
||||
let sessionManager: SessionManager | undefined;
|
||||
try {
|
||||
sessionManager = await logger.time(
|
||||
"createSessionManager",
|
||||
createSessionManager,
|
||||
parsedArgs,
|
||||
cwd,
|
||||
settingsInstance,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof SessionResolutionError) {
|
||||
process.stderr.write(`${chalk.red(`Error: ${error.message}`)}\n`);
|
||||
if (error.hint) {
|
||||
process.stderr.write(`${chalk.dim(error.hint)}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// User declined the cross-project fork prompt — exit cleanly with a friendly
|
||||
// message rather than letting the decline bubble up as an uncaught exception
|
||||
|
||||
@@ -7,7 +7,7 @@ import { type ApiKey, clampThinkingLevelForModel, completeSimple, Effort, type M
|
||||
import { getAgentDbPath, getMemoriesDir, logger, parseJsonlLenient, prompt } from "@oh-my-pi/pi-utils";
|
||||
|
||||
import type { ModelRegistry } from "../config/model-registry";
|
||||
import { resolveModelRoleValue } from "../config/model-resolver";
|
||||
import { getModelMatchPreferences, resolveModelRoleValue } from "../config/model-resolver";
|
||||
import type { Settings } from "../config/settings";
|
||||
import consolidationTemplate from "../prompts/memories/consolidation.md" with { type: "text" };
|
||||
import readPathTemplate from "../prompts/memories/read-path.md" with { type: "text" };
|
||||
@@ -1088,7 +1088,7 @@ async function resolveMemoryModel(options: {
|
||||
if (requestedModel) {
|
||||
const resolved = resolveModelRoleValue(requestedModel, modelRegistry.getAll(), {
|
||||
settings: session.settings,
|
||||
matchPreferences: { usageOrder: session.settings.getStorage()?.getModelUsageOrder() },
|
||||
matchPreferences: getModelMatchPreferences(session.settings),
|
||||
modelRegistry,
|
||||
});
|
||||
if (resolved.model) return resolved.model;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { formatNumber } from "@oh-my-pi/pi-utils";
|
||||
import { settings } from "../../config/settings";
|
||||
import type { AssistantThinkingRenderer } from "../../extensibility/extensions/types";
|
||||
import { getMarkdownTheme, theme } from "../../modes/theme/theme";
|
||||
import { isSilentAbort, resolveAbortLabel } from "../../session/messages";
|
||||
import { resolveAbortLabel, shouldRenderAbortReason } from "../../session/messages";
|
||||
import { resolveImageOptions } from "../../tools/render-utils";
|
||||
|
||||
/**
|
||||
@@ -74,18 +74,6 @@ export class AssistantMessageComponent extends Container {
|
||||
return this.#transcriptBlockFinalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assistant text/thinking streams in append-only: earlier rendered rows never
|
||||
* re-layout, new content only grows the block at the bottom. The transcript
|
||||
* reports this so the renderer may commit scrolled-off head rows of a long
|
||||
* streamed reply to native scrollback instead of dropping them (see
|
||||
* `NativeScrollbackLiveRegion#getNativeScrollbackCommitSafeEnd`). Volatile
|
||||
* blocks (tool previews that collapse) intentionally do not implement this.
|
||||
*/
|
||||
isTranscriptBlockAppendOnly(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
markTranscriptBlockFinalized(): void {
|
||||
this.#transcriptBlockFinalized = true;
|
||||
}
|
||||
@@ -252,7 +240,7 @@ export class AssistantMessageComponent extends Container {
|
||||
// But only if there are no tool calls (tool execution components will show the error)
|
||||
const hasToolCalls = message.content.some(c => c.type === "toolCall");
|
||||
if (!hasToolCalls) {
|
||||
if (message.stopReason === "aborted" && !isSilentAbort(message.errorMessage)) {
|
||||
if (message.stopReason === "aborted" && shouldRenderAbortReason(message.errorMessage)) {
|
||||
const abortMessage = resolveAbortLabel(message.errorMessage);
|
||||
if (hasVisibleContent) {
|
||||
this.#contentContainer.addChild(new Spacer(1));
|
||||
@@ -268,7 +256,7 @@ export class AssistantMessageComponent extends Container {
|
||||
}
|
||||
if (
|
||||
message.errorMessage &&
|
||||
!isSilentAbort(message.errorMessage) &&
|
||||
shouldRenderAbortReason(message.errorMessage) &&
|
||||
message.stopReason !== "aborted" &&
|
||||
message.stopReason !== "error"
|
||||
) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Editor, type KeyId, matchesKey, parseKittySequence } from "@oh-my-pi/pi-tui";
|
||||
import { addKeyAliases, canonicalKeyId, Editor, type KeyId, parseKey, parseKittySequence } from "@oh-my-pi/pi-tui";
|
||||
import type { AppKeybinding } from "../../config/keybindings";
|
||||
import { imageReferenceHyperlink, renderImageReferences } from "../image-references";
|
||||
import { highlightMagicKeywords } from "../magic-keywords";
|
||||
@@ -47,6 +47,14 @@ const DEFAULT_ACTION_KEYS: Record<ConfigurableEditorAction, KeyId[]> = {
|
||||
"app.clipboard.copyPrompt": ["alt+shift+c"],
|
||||
};
|
||||
|
||||
function buildMatchKeys(keys: readonly KeyId[]): Set<string> {
|
||||
const matchKeys = new Set<string>();
|
||||
for (const key of keys) {
|
||||
addKeyAliases(matchKeys, key);
|
||||
}
|
||||
return matchKeys;
|
||||
}
|
||||
|
||||
const BRACKETED_PASTE_START = "\x1b[200~";
|
||||
const BRACKETED_PASTE_END = "\x1b[201~";
|
||||
const BRACKETED_IMAGE_PATH_REGEX = /\.(?:png|jpe?g|gif|webp)$/i;
|
||||
@@ -68,7 +76,7 @@ export function extractBracketedImagePastePath(data: string): string | undefined
|
||||
export class CustomEditor extends Editor {
|
||||
imageLinks?: readonly (string | undefined)[];
|
||||
|
||||
/** Gradient-highlight the "ultrathink" / "orchestrate" / "workflow" keywords as the user types
|
||||
/** Gradient-highlight the "ultrathink" / "orchestrate" / "workflowz" keywords as the user types
|
||||
* them, skipping any occurrence inside code spans, fenced blocks, or XML sections. Also make
|
||||
* pasted image placeholders visually distinct and hyperlink them once their blob file exists. */
|
||||
decorateText = (text: string): string =>
|
||||
@@ -108,21 +116,38 @@ export class CustomEditor extends Editor {
|
||||
|
||||
/** Custom key handlers from extensions and non-built-in app actions. */
|
||||
#customKeyHandlers = new Map<KeyId, () => void>();
|
||||
#customMatchKeys = new Map<string, () => void>();
|
||||
#actionKeys = new Map<ConfigurableEditorAction, KeyId[]>(
|
||||
Object.entries(DEFAULT_ACTION_KEYS).map(([action, keys]) => [action as ConfigurableEditorAction, [...keys]]),
|
||||
);
|
||||
#actionMatchKeys = new Map<ConfigurableEditorAction, Set<string>>(
|
||||
Object.entries(DEFAULT_ACTION_KEYS).map(([action, keys]) => [
|
||||
action as ConfigurableEditorAction,
|
||||
buildMatchKeys(keys),
|
||||
]),
|
||||
);
|
||||
|
||||
setActionKeys(action: ConfigurableEditorAction, keys: KeyId[]): void {
|
||||
this.#actionKeys.set(action, [...keys]);
|
||||
this.#rebuildActionMatchKeys(action);
|
||||
}
|
||||
|
||||
#matchesAction(data: string, action: ConfigurableEditorAction): boolean {
|
||||
const keys = this.#actionKeys.get(action);
|
||||
if (!keys) return false;
|
||||
for (const key of keys) {
|
||||
if (matchesKey(data, key)) return true;
|
||||
#rebuildActionMatchKeys(action: ConfigurableEditorAction): void {
|
||||
this.#actionMatchKeys.set(action, buildMatchKeys(this.#actionKeys.get(action) ?? []));
|
||||
}
|
||||
|
||||
#rebuildCustomMatchKeys(): void {
|
||||
this.#customMatchKeys.clear();
|
||||
for (const [keyId, handler] of this.#customKeyHandlers) {
|
||||
for (const alias of buildMatchKeys([keyId])) {
|
||||
// Preserve current iteration behavior: the first registered handler for colliding aliases wins.
|
||||
if (!this.#customMatchKeys.has(alias)) this.#customMatchKeys.set(alias, handler);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
#matchesAction(canonical: string | undefined, action: ConfigurableEditorAction): boolean {
|
||||
return canonical !== undefined && (this.#actionMatchKeys.get(action)?.has(canonical) ?? false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,6 +155,7 @@ export class CustomEditor extends Editor {
|
||||
*/
|
||||
setCustomKeyHandler(key: KeyId, handler: () => void): void {
|
||||
this.#customKeyHandlers.set(key, handler);
|
||||
this.#rebuildCustomMatchKeys();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,6 +163,7 @@ export class CustomEditor extends Editor {
|
||||
*/
|
||||
removeCustomKeyHandler(key: KeyId): void {
|
||||
this.#customKeyHandlers.delete(key);
|
||||
this.#rebuildCustomMatchKeys();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,11 +171,12 @@ export class CustomEditor extends Editor {
|
||||
*/
|
||||
clearCustomKeyHandlers(): void {
|
||||
this.#customKeyHandlers.clear();
|
||||
this.#rebuildCustomMatchKeys();
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
const parsed = parseKittySequence(data);
|
||||
if (parsed && (parsed.modifier & 64) !== 0 && this.onCapsLock) {
|
||||
const kittyParsed = parseKittySequence(data);
|
||||
if (kittyParsed && (kittyParsed.modifier & 64) !== 0 && this.onCapsLock) {
|
||||
// Caps Lock is modifier bit 64
|
||||
this.onCapsLock();
|
||||
return;
|
||||
@@ -160,125 +188,129 @@ export class CustomEditor extends Editor {
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured image paste (async - fires and handles result)
|
||||
if (this.#matchesAction(data, "app.clipboard.pasteImage") && this.onPasteImage) {
|
||||
void this.onPasteImage();
|
||||
return;
|
||||
}
|
||||
const parsedKey = parseKey(data);
|
||||
const canonical = parsedKey !== undefined ? canonicalKeyId(parsedKey) : undefined;
|
||||
|
||||
// Intercept configured raw text paste (fires and handles result)
|
||||
if (this.#matchesAction(data, "app.clipboard.pasteTextRaw") && this.onPasteTextRaw) {
|
||||
this.onPasteTextRaw();
|
||||
return;
|
||||
}
|
||||
if (canonical !== undefined) {
|
||||
// Intercept configured image paste (async - fires and handles result)
|
||||
if (this.#matchesAction(canonical, "app.clipboard.pasteImage") && this.onPasteImage) {
|
||||
void this.onPasteImage();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured external editor shortcut
|
||||
if (this.#matchesAction(data, "app.editor.external") && this.onExternalEditor) {
|
||||
this.onExternalEditor();
|
||||
return;
|
||||
}
|
||||
// Intercept configured raw text paste (fires and handles result)
|
||||
if (this.#matchesAction(canonical, "app.clipboard.pasteTextRaw") && this.onPasteTextRaw) {
|
||||
this.onPasteTextRaw();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured temporary model selector shortcut
|
||||
if (this.#matchesAction(data, "app.model.selectTemporary") && this.onSelectModelTemporary) {
|
||||
this.onSelectModelTemporary();
|
||||
return;
|
||||
}
|
||||
// Intercept configured external editor shortcut
|
||||
if (this.#matchesAction(canonical, "app.editor.external") && this.onExternalEditor) {
|
||||
this.onExternalEditor();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured display reset shortcut
|
||||
if (this.#matchesAction(data, "app.display.reset") && this.onDisplayReset) {
|
||||
this.onDisplayReset();
|
||||
return;
|
||||
}
|
||||
// Intercept configured temporary model selector shortcut
|
||||
if (this.#matchesAction(canonical, "app.model.selectTemporary") && this.onSelectModelTemporary) {
|
||||
this.onSelectModelTemporary();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured suspend shortcut
|
||||
if (this.#matchesAction(data, "app.suspend") && this.onSuspend) {
|
||||
this.onSuspend();
|
||||
return;
|
||||
}
|
||||
// Intercept configured display reset shortcut
|
||||
if (this.#matchesAction(canonical, "app.display.reset") && this.onDisplayReset) {
|
||||
this.onDisplayReset();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured thinking block visibility toggle
|
||||
if (this.#matchesAction(data, "app.thinking.toggle") && this.onToggleThinking) {
|
||||
this.onToggleThinking();
|
||||
return;
|
||||
}
|
||||
// Intercept configured suspend shortcut
|
||||
if (this.#matchesAction(canonical, "app.suspend") && this.onSuspend) {
|
||||
this.onSuspend();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured model selector shortcut
|
||||
if (this.#matchesAction(data, "app.model.select") && this.onSelectModel) {
|
||||
this.onSelectModel();
|
||||
return;
|
||||
}
|
||||
// Intercept configured thinking block visibility toggle
|
||||
if (this.#matchesAction(canonical, "app.thinking.toggle") && this.onToggleThinking) {
|
||||
this.onToggleThinking();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured history search shortcut
|
||||
if (this.#matchesAction(data, "app.history.search") && this.onHistorySearch) {
|
||||
this.onHistorySearch();
|
||||
return;
|
||||
}
|
||||
// Intercept configured model selector shortcut
|
||||
if (this.#matchesAction(canonical, "app.model.select") && this.onSelectModel) {
|
||||
this.onSelectModel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured tool output expansion shortcut
|
||||
if (this.#matchesAction(data, "app.tools.expand") && this.onExpandTools) {
|
||||
this.onExpandTools();
|
||||
return;
|
||||
}
|
||||
// Intercept configured history search shortcut
|
||||
if (this.#matchesAction(canonical, "app.history.search") && this.onHistorySearch) {
|
||||
this.onHistorySearch();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured backward model cycling (check before forward cycling)
|
||||
if (this.#matchesAction(data, "app.model.cycleBackward") && this.onCycleModelBackward) {
|
||||
this.onCycleModelBackward();
|
||||
return;
|
||||
}
|
||||
// Intercept configured tool output expansion shortcut
|
||||
if (this.#matchesAction(canonical, "app.tools.expand") && this.onExpandTools) {
|
||||
this.onExpandTools();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured forward model cycling
|
||||
if (this.#matchesAction(data, "app.model.cycleForward") && this.onCycleModelForward) {
|
||||
this.onCycleModelForward();
|
||||
return;
|
||||
}
|
||||
// Intercept configured backward model cycling (check before forward cycling)
|
||||
if (this.#matchesAction(canonical, "app.model.cycleBackward") && this.onCycleModelBackward) {
|
||||
this.onCycleModelBackward();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured thinking level cycling
|
||||
if (this.#matchesAction(data, "app.thinking.cycle") && this.onCycleThinkingLevel) {
|
||||
this.onCycleThinkingLevel();
|
||||
return;
|
||||
}
|
||||
// Intercept configured forward model cycling
|
||||
if (this.#matchesAction(canonical, "app.model.cycleForward") && this.onCycleModelForward) {
|
||||
this.onCycleModelForward();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured interrupt shortcut.
|
||||
// When the autocomplete popup is visible, ESC's first job is to dismiss
|
||||
// the popup — let super.handleInput() route it to #cancelAutocomplete().
|
||||
// The user can press ESC again afterward to fire the global interrupt
|
||||
// handler. This matches the standard TUI/IDE pattern and prevents a
|
||||
// single ESC from both closing an @ completion and aborting an active
|
||||
// agent run (#1655).
|
||||
if (this.#matchesAction(data, "app.interrupt") && this.onEscape && !this.isShowingAutocomplete()) {
|
||||
this.onEscape();
|
||||
return;
|
||||
}
|
||||
// Intercept configured thinking level cycling
|
||||
if (this.#matchesAction(canonical, "app.thinking.cycle") && this.onCycleThinkingLevel) {
|
||||
this.onCycleThinkingLevel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured clear shortcut
|
||||
if (this.#matchesAction(data, "app.clear") && this.onClear) {
|
||||
this.onClear();
|
||||
return;
|
||||
}
|
||||
// Intercept configured interrupt shortcut.
|
||||
// When the autocomplete popup is visible, ESC's first job is to dismiss
|
||||
// the popup — let super.handleInput() route it to #cancelAutocomplete().
|
||||
// The user can press ESC again afterward to fire the global interrupt
|
||||
// handler. This matches the standard TUI/IDE pattern and prevents a
|
||||
// single ESC from both closing an @ completion and aborting an active
|
||||
// agent run (#1655).
|
||||
if (this.#matchesAction(canonical, "app.interrupt") && this.onEscape && !this.isShowingAutocomplete()) {
|
||||
this.onEscape();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured exit shortcut. Always consume the shortcut so it
|
||||
// never reaches the parent handler; firing onExit is the controller's
|
||||
// chance to snapshot the current text as a draft before shutting down.
|
||||
if (this.#matchesAction(data, "app.exit")) {
|
||||
this.onExit?.();
|
||||
return;
|
||||
}
|
||||
// Intercept configured clear shortcut
|
||||
if (this.#matchesAction(canonical, "app.clear") && this.onClear) {
|
||||
this.onClear();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured dequeue shortcut (restore queued message to editor)
|
||||
if (this.#matchesAction(data, "app.message.dequeue") && this.onDequeue) {
|
||||
this.onDequeue();
|
||||
return;
|
||||
}
|
||||
// Intercept configured exit shortcut. Always consume the shortcut so it
|
||||
// never reaches the parent handler; firing onExit is the controller's
|
||||
// chance to snapshot the current text as a draft before shutting down.
|
||||
if (this.#matchesAction(canonical, "app.exit")) {
|
||||
this.onExit?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured copy-prompt shortcut
|
||||
if (this.#matchesAction(data, "app.clipboard.copyPrompt") && this.onCopyPrompt) {
|
||||
this.onCopyPrompt();
|
||||
return;
|
||||
}
|
||||
// Intercept configured dequeue shortcut (restore queued message to editor)
|
||||
if (this.#matchesAction(canonical, "app.message.dequeue") && this.onDequeue) {
|
||||
this.onDequeue();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check custom key handlers (extensions)
|
||||
for (const [keyId, handler] of this.#customKeyHandlers) {
|
||||
if (matchesKey(data, keyId)) {
|
||||
// Intercept configured copy-prompt shortcut
|
||||
if (this.#matchesAction(canonical, "app.clipboard.copyPrompt") && this.onCopyPrompt) {
|
||||
this.onCopyPrompt();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check custom key handlers (extensions)
|
||||
const handler = this.#customMatchKeys.get(canonical);
|
||||
if (handler) {
|
||||
handler();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Container, Text } from "@oh-my-pi/pi-tui";
|
||||
import { formatDiagnostics } from "../../tools/render-utils";
|
||||
import { getLanguageFromPath, theme } from "../theme/theme";
|
||||
|
||||
/** One file's worth of late LSP diagnostics, as carried on the transcript message. */
|
||||
export interface LateDiagnosticsFile {
|
||||
path?: string;
|
||||
summary?: string;
|
||||
errored?: boolean;
|
||||
messages?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders late LSP diagnostics (arrived after edit/write returned) in the
|
||||
* transcript, reusing the same tree renderer the edit/write tools use so the
|
||||
* styling stays consistent. Supports the global tool-output expand toggle.
|
||||
*/
|
||||
export class LateDiagnosticsMessageComponent extends Container {
|
||||
#expanded = false;
|
||||
|
||||
constructor(private readonly files: LateDiagnosticsFile[]) {
|
||||
super();
|
||||
this.#rebuild();
|
||||
}
|
||||
|
||||
setExpanded(expanded: boolean): void {
|
||||
if (this.#expanded === expanded) return;
|
||||
this.#expanded = expanded;
|
||||
this.#rebuild();
|
||||
}
|
||||
|
||||
override invalidate(): void {
|
||||
super.invalidate();
|
||||
this.#rebuild();
|
||||
}
|
||||
|
||||
#rebuild(): void {
|
||||
this.clear();
|
||||
|
||||
const messages: string[] = [];
|
||||
const summaries: string[] = [];
|
||||
let errored = false;
|
||||
for (const file of this.files) {
|
||||
if (file.messages?.length) messages.push(...file.messages);
|
||||
if (file.summary) summaries.push(file.summary);
|
||||
if (file.errored) errored = true;
|
||||
}
|
||||
if (messages.length === 0) return;
|
||||
|
||||
const text = formatDiagnostics(
|
||||
{ errored, summary: summaries.join(", "), messages },
|
||||
this.#expanded,
|
||||
theme,
|
||||
fp => theme.getLangIcon(getLanguageFromPath(fp)),
|
||||
{ title: "Late diagnostics" },
|
||||
);
|
||||
const body = text.replace(/^\n+/, "");
|
||||
if (body) this.addChild(new Text(body, 1, 0));
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { formatNumber } from "@oh-my-pi/pi-utils";
|
||||
import type { ModelRegistry } from "../../config/model-registry";
|
||||
import { getKnownRoleIds, getRoleInfo, MODEL_ROLE_IDS, MODEL_ROLES } from "../../config/model-registry";
|
||||
import { resolveModelRoleValue } from "../../config/model-resolver";
|
||||
import { getModelMatchPreferences, resolveModelRoleValue } from "../../config/model-resolver";
|
||||
import type { Settings } from "../../config/settings";
|
||||
import { type ThemeColor, theme } from "../../modes/theme/theme";
|
||||
import { matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers";
|
||||
@@ -31,6 +31,25 @@ function makeInvertedBadge(label: string, color: ThemeColor): string {
|
||||
return `${bgAnsi}\x1b[30m ${label} \x1b[39m\x1b[49m`;
|
||||
}
|
||||
|
||||
function makeAutoSelectedBadge(label: string, color: ThemeColor): string {
|
||||
return `${theme.fg("dim", "[")}${theme.fg(color, label)}${theme.fg("dim", " auto]")}`;
|
||||
}
|
||||
|
||||
function makeRoleBadgeToken(label: string, color: ThemeColor, assigned: RoleAssignment): string {
|
||||
if (assigned.autoSelected) {
|
||||
const badge = makeAutoSelectedBadge(label, color);
|
||||
if (assigned.thinkingLevel === ThinkingLevel.Inherit) {
|
||||
return badge;
|
||||
}
|
||||
const thinkingLabel = getConfiguredThinkingLevelMetadata(assigned.thinkingLevel).label;
|
||||
return `${badge} ${theme.fg("dim", `(${thinkingLabel})`)}`;
|
||||
}
|
||||
|
||||
const badge = makeInvertedBadge(label, color);
|
||||
const thinkingLabel = getConfiguredThinkingLevelMetadata(assigned.thinkingLevel).label;
|
||||
return `${badge} ${theme.fg("dim", `(${thinkingLabel})`)}`;
|
||||
}
|
||||
|
||||
function normalizeSearchText(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
@@ -86,6 +105,7 @@ interface ScopedModelItem {
|
||||
interface RoleAssignment {
|
||||
model: Model;
|
||||
thinkingLevel: ConfiguredThinkingLevel;
|
||||
autoSelected: boolean;
|
||||
}
|
||||
|
||||
type RoleSelectCallback = (
|
||||
@@ -271,12 +291,17 @@ export class ModelSelectorComponent extends Container {
|
||||
});
|
||||
}
|
||||
|
||||
#loadRoleModels(): void {
|
||||
#loadRoleModels(autoCandidateModels?: ReadonlyArray<Model>): void {
|
||||
const nextRoles = {} as Record<string, RoleAssignment | undefined>;
|
||||
const allModels = this.#modelRegistry.getAll();
|
||||
const matchPreferences = { usageOrder: this.#settings.getStorage()?.getModelUsageOrder() };
|
||||
for (const role of getKnownRoleIds(this.#settings)) {
|
||||
const matchPreferences = getModelMatchPreferences(this.#settings);
|
||||
const knownRoles = getKnownRoleIds(this.#settings);
|
||||
const configuredRoles = new Set<string>();
|
||||
|
||||
for (const role of knownRoles) {
|
||||
const roleValue = this.#settings.getModelRole(role);
|
||||
if (!roleValue) continue;
|
||||
configuredRoles.add(role);
|
||||
|
||||
const resolved = resolveModelRoleValue(roleValue, allModels, {
|
||||
settings: this.#settings,
|
||||
@@ -284,15 +309,39 @@ export class ModelSelectorComponent extends Container {
|
||||
modelRegistry: this.#modelRegistry,
|
||||
});
|
||||
if (resolved.model) {
|
||||
this.#roles[role] = {
|
||||
nextRoles[role] = {
|
||||
model: resolved.model,
|
||||
thinkingLevel:
|
||||
resolved.explicitThinkingLevel && resolved.thinkingLevel !== undefined
|
||||
? resolved.thinkingLevel
|
||||
: ThinkingLevel.Inherit,
|
||||
autoSelected: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (autoCandidateModels && autoCandidateModels.length > 0) {
|
||||
const candidates = [...autoCandidateModels];
|
||||
for (const role of knownRoles) {
|
||||
if (configuredRoles.has(role)) continue;
|
||||
const resolved = resolveModelRoleValue(`pi/${role}`, candidates, {
|
||||
settings: this.#settings,
|
||||
matchPreferences,
|
||||
modelRegistry: this.#modelRegistry,
|
||||
});
|
||||
if (!resolved.model) continue;
|
||||
nextRoles[role] = {
|
||||
model: resolved.model,
|
||||
thinkingLevel:
|
||||
resolved.explicitThinkingLevel && resolved.thinkingLevel !== undefined
|
||||
? resolved.thinkingLevel
|
||||
: ThinkingLevel.Inherit,
|
||||
autoSelected: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.#roles = nextRoles;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -427,6 +476,7 @@ export class ModelSelectorComponent extends Container {
|
||||
}
|
||||
|
||||
const candidates = models.map(item => item.model);
|
||||
this.#loadRoleModels(candidates);
|
||||
const canonicalRecords = this.#modelRegistry.getCanonicalModels({
|
||||
availableOnly: this.#scopedModels.length === 0,
|
||||
candidates,
|
||||
@@ -871,25 +921,21 @@ export class ModelSelectorComponent extends Container {
|
||||
const isDisabled = this.#isItemDisabled(item);
|
||||
const disabledSuffix = this.#formatContextLimitSuffix(item.model);
|
||||
|
||||
// Build role badges (inverted: color as background, black text)
|
||||
// Build role badges. Solid badges are configured; outlined badges are auto-selected defaults.
|
||||
const roleBadgeTokens: string[] = [];
|
||||
for (const role of MODEL_ROLE_IDS) {
|
||||
const { tag, color } = getRoleInfo(role, this.#settings);
|
||||
const assigned = this.#roles[role];
|
||||
if (!tag || !assigned || !modelsAreEqual(assigned.model, item.model)) continue;
|
||||
|
||||
const badge = makeInvertedBadge(tag, color ?? "success");
|
||||
const thinkingLabel = getConfiguredThinkingLevelMetadata(assigned.thinkingLevel).label;
|
||||
roleBadgeTokens.push(`${badge} ${theme.fg("dim", `(${thinkingLabel})`)}`);
|
||||
roleBadgeTokens.push(makeRoleBadgeToken(tag, color ?? "success", assigned));
|
||||
}
|
||||
// Custom role badges
|
||||
for (const [role, assigned] of Object.entries(this.#roles)) {
|
||||
if (role in MODEL_ROLES || !assigned || !modelsAreEqual(assigned.model, item.model)) continue;
|
||||
const roleInfo = getRoleInfo(role, this.#settings);
|
||||
const badgeLabel = roleInfo.tag ?? roleInfo.name;
|
||||
const badge = makeInvertedBadge(badgeLabel, roleInfo.color ?? "muted");
|
||||
const thinkingLabel = getConfiguredThinkingLevelMetadata(assigned.thinkingLevel).label;
|
||||
roleBadgeTokens.push(`${badge} ${theme.fg("dim", `(${thinkingLabel})`)}`);
|
||||
roleBadgeTokens.push(makeRoleBadgeToken(badgeLabel, roleInfo.color ?? "muted", assigned));
|
||||
}
|
||||
const badgeText = roleBadgeTokens.length > 0 ? ` ${roleBadgeTokens.join(" ")}` : "";
|
||||
|
||||
@@ -1184,7 +1230,7 @@ export class ModelSelectorComponent extends Container {
|
||||
const selectedThinkingLevel = thinkingLevel ?? this.#getCurrentRoleThinkingLevel(role);
|
||||
|
||||
// Update local state for UI
|
||||
this.#roles[role] = { model: item.model, thinkingLevel: selectedThinkingLevel };
|
||||
this.#roles[role] = { model: item.model, thinkingLevel: selectedThinkingLevel, autoSelected: false };
|
||||
|
||||
// Notify caller (for updating agent state if needed)
|
||||
this.#onSelectCallback(item.model, role, selectedThinkingLevel, item.selector);
|
||||
|
||||
@@ -11,10 +11,20 @@ import {
|
||||
} from "@oh-my-pi/pi-tui";
|
||||
import { theme } from "../../modes/theme/theme";
|
||||
import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers";
|
||||
import type { AuthStorage } from "../../session/auth-storage";
|
||||
import type { AuthStorage, CredentialOriginKind } from "../../session/auth-storage";
|
||||
import { DynamicBorder } from "./dynamic-border";
|
||||
|
||||
const OAUTH_SELECTOR_MAX_VISIBLE = 10;
|
||||
|
||||
/** Compact, human-readable tag for each credential-origin leg. */
|
||||
const ORIGIN_LABELS: Record<CredentialOriginKind, string> = {
|
||||
runtime: "--api-key",
|
||||
config: "config",
|
||||
oauth: "login",
|
||||
api_key: "api key",
|
||||
env: "env",
|
||||
fallback: "custom provider",
|
||||
};
|
||||
/**
|
||||
* Component that renders an OAuth provider selector.
|
||||
*/
|
||||
@@ -146,20 +156,34 @@ export class OAuthSelectorComponent extends Container {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Muted provenance suffix (" (env: COPILOT_GITHUB_TOKEN)", " (login)", …) so
|
||||
* the list distinguishes a real login from an env var aliasing the provider.
|
||||
*/
|
||||
#getSourceLabel(providerId: string): string {
|
||||
const origin = this.#authStorage.getCredentialOrigin(providerId);
|
||||
if (!origin) return "";
|
||||
const detail = origin.kind === "env" && origin.envVar ? `env: ${origin.envVar}` : ORIGIN_LABELS[origin.kind];
|
||||
return theme.fg("muted", ` (${detail})`);
|
||||
}
|
||||
|
||||
#getStatusIndicator(providerId: string): string {
|
||||
const state = this.#authState.get(providerId);
|
||||
const source = this.#getSourceLabel(providerId);
|
||||
if (state === "checking") {
|
||||
const frameCount = theme.spinnerFrames.length;
|
||||
const spinner = frameCount > 0 ? theme.spinnerFrames[this.#spinnerFrame % frameCount] : theme.status.pending;
|
||||
return theme.fg("warning", ` ${spinner} checking`);
|
||||
return theme.fg("warning", ` ${spinner} checking`) + source;
|
||||
}
|
||||
if (state === "invalid") {
|
||||
return theme.fg("error", ` ${theme.status.error} invalid`);
|
||||
return theme.fg("error", ` ${theme.status.error} invalid`) + source;
|
||||
}
|
||||
if (state === "valid") {
|
||||
return theme.fg("success", ` ${theme.status.success} logged in`);
|
||||
return theme.fg("success", ` ${theme.status.success} logged in`) + source;
|
||||
}
|
||||
return this.#hasSelectableAuth(providerId) ? theme.fg("success", ` ${theme.status.success} logged in`) : "";
|
||||
return this.#hasSelectableAuth(providerId)
|
||||
? theme.fg("success", ` ${theme.status.success} logged in`) + source
|
||||
: "";
|
||||
}
|
||||
|
||||
#isSearchEnabled(): boolean {
|
||||
@@ -178,8 +202,10 @@ export class OAuthSelectorComponent extends Container {
|
||||
|
||||
#getProviderSearchText(provider: OAuthProviderInfo): string {
|
||||
let text = `${provider.name} ${provider.id}`;
|
||||
if (this.#hasSelectableAuth(provider.id)) {
|
||||
text += " logged in authenticated";
|
||||
const origin = this.#authStorage.getCredentialOrigin(provider.id);
|
||||
if (origin) {
|
||||
text += ` logged in authenticated ${ORIGIN_LABELS[origin.kind]}`;
|
||||
if (origin.envVar) text += ` ${origin.envVar}`;
|
||||
}
|
||||
if (!provider.available) {
|
||||
text += " unavailable";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user