mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
Merge remote-tracking branch 'origin/main' into replication
# Conflicts: # packages/durable/README.md # packages/durable/src/index.ts # packages/durable/src/storage/indexeddb.ts # packages/durable/src/storage/sqlite/storage.ts
This commit is contained in:
commit
0a2a3cbce5
199 changed files with 5481 additions and 2370 deletions
2
.github/APPROVED_CONTRIBUTORS
vendored
2
.github/APPROVED_CONTRIBUTORS
vendored
|
|
@ -405,3 +405,5 @@ btkostner pr
|
|||
Jay-Madden issue
|
||||
|
||||
lalyos pr
|
||||
|
||||
Lincoln504 pr
|
||||
|
|
|
|||
17
.github/workflows/ci.yml
vendored
17
.github/workflows/ci.yml
vendored
|
|
@ -35,6 +35,23 @@ jobs:
|
|||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
# Previews are published for main and PRs from branches of this repo only (see preview-packages.yml).
|
||||
- name: Pack preview packages
|
||||
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
env:
|
||||
# Pull request runs check out a merge commit; tarballs are keyed by the PR head commit.
|
||||
SHA: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
run: node scripts/preview-packages.mjs pack --sha "$SHA" --out "$RUNNER_TEMP/preview-packages"
|
||||
|
||||
- name: Upload preview packages
|
||||
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: preview-packages
|
||||
path: ${{ runner.temp }}/preview-packages
|
||||
retention-days: 3
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Check
|
||||
run: npm run check
|
||||
|
||||
|
|
|
|||
2
.github/workflows/pr-gate.yml
vendored
2
.github/workflows/pr-gate.yml
vendored
|
|
@ -18,7 +18,7 @@ jobs:
|
|||
script: |
|
||||
const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS';
|
||||
const VALID_CAPABILITIES = new Set(['issue', 'pr']);
|
||||
const TRUSTED_BOT_AUTHORS = new Set(['dependabot[bot]', 'sentry[bot]', 'claude[bot]']);
|
||||
const TRUSTED_BOT_AUTHORS = new Set(['dependabot[bot]', 'sentry[bot]', 'claude[bot]', 'robopipi[bot]']);
|
||||
const prAuthor = context.payload.pull_request.user.login;
|
||||
const defaultBranch = context.payload.repository.default_branch;
|
||||
const isBotAuthor = prAuthor.endsWith('[bot]');
|
||||
|
|
|
|||
117
.github/workflows/preview-packages.yml
vendored
Normal file
117
.github/workflows/preview-packages.yml
vendored
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
# Publishes the preview packages that CI packed for main and for PRs from branches of this repo to pkg.earendil.com,
|
||||
# moving the `main` or `pr-<number>` ref, and deletes a PR's ref when the PR closes. It reads the CI artifact as data
|
||||
# and never checks out or runs PR code.
|
||||
#
|
||||
# Uploads are only accepted if pkg.earendil.com allows this repository, this workflow on main, and every package it
|
||||
# publishes.
|
||||
|
||||
name: Preview packages
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
types: [completed]
|
||||
pull_request_target:
|
||||
types: [closed]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
# Only CI runs for pushes to main and for PRs from branches of this repo. A branch can make its own copy of CI run
|
||||
# on push; such runs are not reviewed and must not be published as push builds.
|
||||
if: >-
|
||||
github.event_name == 'workflow_run' && github.event.workflow_run.conclusion != 'cancelled' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository &&
|
||||
((github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main') ||
|
||||
github.event.workflow_run.event == 'pull_request')
|
||||
# Publish and cleanup runs for the same branch execute one at a time, in order. The group is set per job, so runs
|
||||
# whose job is skipped (forks) never wait in it. `queue: max` keeps every waiting run instead of replacing one.
|
||||
concurrency:
|
||||
group: preview-packages-${{ github.event.workflow_run.head_branch }}
|
||||
queue: max
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read # download the artifact from the CI run
|
||||
contents: read # check out scripts/
|
||||
id-token: write # authenticate to pkg.earendil.com
|
||||
pull-requests: read # check that the PR is still open
|
||||
steps:
|
||||
- name: Find packed packages
|
||||
id: find
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
run: |
|
||||
count=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts" \
|
||||
--jq '[.artifacts[] | select(.name == "preview-packages")] | length')
|
||||
if [ "$count" = 0 ]; then echo "::notice::CI produced no preview packages (it failed before packing)"; fi
|
||||
echo "found=$([ "$count" != 0 ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download packed packages
|
||||
if: steps.find.outputs.found == 'true'
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: preview-packages
|
||||
path: ${{ runner.temp }}/preview-packages
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Check out scripts
|
||||
if: steps.find.outputs.found == 'true'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
sparse-checkout: scripts
|
||||
persist-credentials: false
|
||||
|
||||
- name: Publish
|
||||
if: steps.find.outputs.found == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
# Event data is passed through env, never interpolated into the script: branch names are PR-controlled.
|
||||
EVENT: ${{ github.event.workflow_run.event }}
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
PULL_REQUESTS: ${{ toJSON(github.event.workflow_run.pull_requests) }}
|
||||
RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
DIR: ${{ runner.temp }}/preview-packages
|
||||
run: |
|
||||
args=(--dir "$DIR" --sha "$HEAD_SHA" --run "$RUN_ID" --event "$EVENT")
|
||||
if [ "$EVENT" = push ] && [ "$HEAD_BRANCH" = main ]; then
|
||||
args+=(--ref main)
|
||||
else
|
||||
# The branch's PR into main; a branch can also have PRs into other branches. It may have closed or received
|
||||
# new commits since CI ran, so the ref moves only while the PR is open with this commit as its head.
|
||||
pr=$(jq -r 'map(select(.base.ref == "main")) | .[0].number // empty' <<< "$PULL_REQUESTS")
|
||||
state=$([ -z "$pr" ] || gh api "repos/$GITHUB_REPOSITORY/pulls/$pr" --jq '"\(.state) \(.base.ref) \(.head.sha)"')
|
||||
if [ "$state" = "open main $HEAD_SHA" ]; then
|
||||
args+=(--ref "pr-$pr")
|
||||
else
|
||||
echo "::notice::No open PR into main has head $HEAD_SHA; publishing without a pr-<number> ref"
|
||||
fi
|
||||
fi
|
||||
node scripts/preview-packages.mjs publish "${args[@]}"
|
||||
|
||||
cleanup:
|
||||
# Only PRs into main: pull_request_target runs this file from the PR's base branch, and only main is allowed.
|
||||
if: >-
|
||||
github.event_name == 'pull_request_target' && github.event.pull_request.base.ref == github.event.repository.default_branch &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository
|
||||
concurrency:
|
||||
group: preview-packages-${{ github.event.pull_request.head.ref }}
|
||||
queue: max
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # check out scripts/ from the base branch, not PR code
|
||||
id-token: write # authenticate to pkg.earendil.com
|
||||
steps:
|
||||
- name: Check out scripts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
sparse-checkout: scripts
|
||||
persist-credentials: false
|
||||
|
||||
- name: Delete PR ref
|
||||
env:
|
||||
PR: ${{ github.event.pull_request.number }}
|
||||
run: node scripts/preview-packages.mjs delete-ref --ref "pr-$PR"
|
||||
|
|
@ -1,16 +1,18 @@
|
|||
/**
|
||||
* Import a pi session shared as a gist by the issue-analysis CI workflow
|
||||
* (.github/workflows/issue-analysis.yml) and switch to it.
|
||||
* (.github/workflows/issue-analysis.yml), or a .jsonl session file served at a URL,
|
||||
* and switch to it.
|
||||
*
|
||||
* The CI job runs in a high-entropy checkout directory; this command rewrites
|
||||
* the recorded cwd to the local checkout, installs the session file into the
|
||||
* current session directory, and switches to it.
|
||||
* The session ran in another checkout (the CI job's high-entropy directory, or a
|
||||
* research VM); this command rewrites the recorded cwd to the local checkout,
|
||||
* installs the session file into the current session directory, and switches to it.
|
||||
*
|
||||
* Usage:
|
||||
* /ir b4d100022aefb12f25dd2d8485e0a82a
|
||||
* /ir https://gist.github.com/mitsuhiko/b4d100022aefb12f25dd2d8485e0a82a
|
||||
* /ir https://pi.dev/session/#b4d100022aefb12f25dd2d8485e0a82a
|
||||
* /ir https://github.com/earendil-works/pi/issues/123
|
||||
* /ir https://example.com/path/session.jsonl
|
||||
*
|
||||
* pi "/ir <gist-id>"
|
||||
*/
|
||||
|
|
@ -58,11 +60,11 @@ interface IssueComment {
|
|||
function parseRef(
|
||||
ref: string,
|
||||
cwd: string,
|
||||
): { type: "gist"; id: string } | { type: "file"; path: string } | { type: "issue"; owner: string; repo: string; issue: string } {
|
||||
if (ref.endsWith(".html") || ref.endsWith(".jsonl")) {
|
||||
return { type: "file", path: isAbsolute(ref) ? ref : resolve(cwd, ref) };
|
||||
}
|
||||
|
||||
):
|
||||
| { type: "gist"; id: string }
|
||||
| { type: "file"; path: string }
|
||||
| { type: "issue"; owner: string; repo: string; issue: string }
|
||||
| { type: "url"; url: URL } {
|
||||
const shareMatch = ref.match(SHARE_URL_RE);
|
||||
if (shareMatch) return { type: "gist", id: shareMatch[1] };
|
||||
|
||||
|
|
@ -72,9 +74,17 @@ function parseRef(
|
|||
const issueMatch = ref.match(ISSUE_URL_RE);
|
||||
if (issueMatch) return { type: "issue", owner: issueMatch[1], repo: issueMatch[2], issue: issueMatch[3] };
|
||||
|
||||
if (/^https?:\/\/.*\.jsonl$/.test(ref)) return { type: "url", url: new URL(ref) };
|
||||
|
||||
if (ref.endsWith(".html") || ref.endsWith(".jsonl")) {
|
||||
return { type: "file", path: isAbsolute(ref) ? ref : resolve(cwd, ref) };
|
||||
}
|
||||
|
||||
if (GIST_ID_RE.test(ref)) return { type: "gist", id: ref };
|
||||
|
||||
throw new Error(`expected a gist ID, gist URL, pi.dev share URL, issue URL, .html file, or .jsonl file: ${ref}`);
|
||||
throw new Error(
|
||||
`expected a gist ID, gist URL, pi.dev share URL, issue URL, .jsonl URL, .html file, or .jsonl file: ${ref}`,
|
||||
);
|
||||
}
|
||||
|
||||
function parseSessionJsonl(raw: string): { header: SessionHeader; jsonl: string } {
|
||||
|
|
@ -282,11 +292,12 @@ async function fetchGistSession(gistId: string): Promise<{ header: SessionHeader
|
|||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("ir", {
|
||||
description: "Import a CI issue-analysis session from a gist ID, share URL, or issue URL and switch to it",
|
||||
description:
|
||||
"Import a CI issue-analysis session from a gist ID, share URL, issue URL, or .jsonl URL and switch to it",
|
||||
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
||||
const ref = args.trim();
|
||||
if (!ref) {
|
||||
ctx.ui.notify("Usage: /ir <gist-id | gist-url | pi.dev/session URL | issue URL>", "error");
|
||||
ctx.ui.notify("Usage: /ir <gist-id | gist-url | pi.dev/session URL | issue URL | .jsonl URL>", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -306,6 +317,9 @@ export default function (pi: ExtensionAPI) {
|
|||
const gistId = await findIssueGistId(parsedRef.owner, parsedRef.repo, parsedRef.issue);
|
||||
decoded = await fetchGistSession(gistId);
|
||||
sourceName = `${gistId}.jsonl`;
|
||||
} else if (parsedRef.type === "url") {
|
||||
decoded = parseSessionJsonl(await fetchText(parsedRef.url.href));
|
||||
sourceName = basename(parsedRef.url.pathname);
|
||||
} else {
|
||||
if (!existsSync(parsedRef.path)) throw new Error(`session file not found: ${parsedRef.path}`);
|
||||
const raw = readFileSync(parsedRef.path, "utf8");
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"revision": "sha256-ffa3e98626230f378f80200debb736c4a2238b5f2ba246a92cfc7d8e19604678"
|
||||
"revision": "sha256-7fe1b41feaf43a7aad4bc832e6b306c7ced56e6d41bbc724b74e91eeacff6edf"
|
||||
}
|
||||
|
|
|
|||
1013
package-lock.json
generated
1013
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.4.0] - 2026-10-21
|
||||
|
||||
### Added
|
||||
|
||||
- Added `AgentOptions.identity` and `Agent.identity` (a pi-ai `ClientIdentity`) to set the `User-Agent` and provider attribution of agent loop requests.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@earendil-works/pi-agent-core",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-ai": "1.3.0",
|
||||
"@earendil-works/pi-ai": "1.4.0",
|
||||
"typebox": "1.3.27"
|
||||
},
|
||||
"keywords": [
|
||||
|
|
@ -45,6 +45,6 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.19.19",
|
||||
"vitest": "4.1.11"
|
||||
"vitest": "4.1.12"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Claude models that accept mid-conversation system messages rejecting a session with a 400 (`role 'system' must precede an 'assistant' message`) when a system update was followed by an assistant reply with nothing to send to Anthropic, such as an empty reply or another model's reasoning-only reply ([#11091](https://github.com/earendil-works/pi/issues/11091))
|
||||
|
||||
## [1.4.0] - 2026-10-21
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Renamed provider request option `telemetryContext` to `telemetry?: TelemetrySpan`, without a compatibility alias. Pass a parent span, not an adapter.
|
||||
|
|
@ -17,6 +23,9 @@
|
|||
- Failed model request spans report `provider_error` status with up to 500 error-message characters; cancelled requests keep `ok` status with `stop: "aborted"`.
|
||||
- Added `MiniMax-M3.1-Flash` to the `minimax` and `minimax-cn` providers, with thinking levels low through max sent as `output_config.effort`. Thinking cannot be turned off for this model. New MiniMax models from models.dev now appear without a manual allowlist entry; only the older M2, M2.1, M2.5, and M2.5-highspeed models stay hidden ([#11121](https://github.com/earendil-works/pi/issues/11121))
|
||||
- Sign in with ChatGPT: ChatGPT Business workspace usage-limit errors are not retried and tell the user to ask their workspace admin to increase the limit.
|
||||
- Added image input to System One classifiers: `ClassifierContext.images` now works with Jev on `typesafe` (experimental there, opted in with the `TypeSafe-Danger-Zone-Images` header) and `cloudflare-workers-ai`, with Clef on `cloudflare-workers-ai`, and with OpenRouter decision models whose catalog lists image input. Each service gets its own wire form: TypeSafe `assets`, Clef `images` data URLs, or OpenRouter `image_url` parts in the state.
|
||||
- Added `compat` to System One classifier models (`SystemOneCompat`, with `imageInput` and `sendImageOptInHeader`). Built-in catalog entries set it explicitly; models without it detect the form from the provider, `baseUrl`, and model ID.
|
||||
- Added the `@cf/cloudflare/clef-omni` and `typesafe/jev-1.14.0` classifier models to `cloudflare-workers-ai`.
|
||||
|
||||
### Changed
|
||||
|
||||
|
|
@ -24,11 +33,15 @@
|
|||
|
||||
### Fixed
|
||||
|
||||
- Fixed Cloudflare Workers AI classifier catalog data: Clef Flash now costs $0.038 per million input tokens with a 24,576-token context window, and Jev costs $0.042 per million input tokens instead of being listed as free.
|
||||
- Fixed whitespace-only tool output (for example a bare `\r\n` from bash) being sent as-is to OpenAI Completions, OpenAI Responses, and Google providers; it now uses the same placeholder as empty output, since some providers reject whitespace-only tool content with a 400 ([#8720](https://github.com/earendil-works/pi/issues/8720))
|
||||
- Fixed automatic retry not triggering for gateway upstream failures such as `upstream_error: Upstream request failed`, `upstream_stream_read_error`, and `Upstream HTTP/2 stream failed` ([#10920](https://github.com/earendil-works/pi/issues/10920))
|
||||
- Fixed automatic retry not triggering when the Google Vertex OAuth token refresh fails with a transient network error such as `request to https://oauth2.googleapis.com/token failed, reason:` ([#10996](https://github.com/earendil-works/pi/issues/10996))
|
||||
- Fixed automatic retry not triggering for `stream disconnected before completion` errors relayed by proxies that wrap the Codex CLI ([#11057](https://github.com/earendil-works/pi/issues/11057))
|
||||
- Fixed automatic retry treating digits inside request ids or other numbers (for example `req_011CU503xyz` or `5242880 bytes`) as retryable HTTP status codes ([#11057](https://github.com/earendil-works/pi/issues/11057))
|
||||
- Fixed OpenAI Responses deferred tool loads rewriting the top-level tool list, and losing the prompt cache, on every load after a tool removal. Models with `additional_tools` now declare all tools in `additional_tools` items, starting with one at the head of the input, so removals (announced as text) and redefinitions no longer rewrite the prefix. Tool search and Kimi tool messages rewrite the top-level tools once per removal or redeclaration and keep later loads anchored ([#10940](https://github.com/earendil-works/pi/issues/10940))
|
||||
- Fixed GitHub Copilot requests whose body exceeds Copilot's size limit (HTTP 413 `failed to parse request`, for example sessions with many large images) failing instead of being detected as context overflow. Any HTTP 413 now counts as context overflow, so callers can compact and retry ([#11016](https://github.com/earendil-works/pi/pull/11016) by [@petrroll](https://github.com/petrroll))
|
||||
- Fixed the `openai-codex` provider listing `gpt-5.5` and `gpt-5.3-codex-spark`, which the Codex backend rejects for ChatGPT accounts. Both models are removed ([#11173](https://github.com/earendil-works/pi/pull/11173) by [@lucasmeijer](https://github.com/lucasmeijer))
|
||||
|
||||
## [1.3.0] - 2026-10-15
|
||||
|
||||
|
|
@ -53,7 +66,6 @@
|
|||
- Fixed OpenCode Go MiniMax M2.7 failing with "Model does not support this protocol" by using the Anthropic Messages API
|
||||
- Fixed automatic retry not triggering for gateway `Unable to forward upstream stream` errors ([#10920](https://github.com/earendil-works/pi/issues/10920))
|
||||
- Fixed automatic retry not triggering for `The service is temporarily unavailable` errors, which also made compaction fail on the first attempt ([#10965](https://github.com/earendil-works/pi/issues/10965))
|
||||
- Fixed automatic retry not triggering when the Google Vertex OAuth token refresh fails with a transient network error such as `request to https://oauth2.googleapis.com/token failed, reason:` ([#10996](https://github.com/earendil-works/pi/issues/10996))
|
||||
- Fixed OpenAI-compatible responses showing thinking after the answer when a proxy such as LiteLLM sends reasoning and content in the same stream chunk ([#10926](https://github.com/earendil-works/pi/issues/10926))
|
||||
- Fixed Gemini 4 Argon requests on Google Vertex failing with "Thinking budget is not supported", and its thinking level being ignored on the Gemini API, by sending `thinkingLevel` for Gemini 4 and later models ([#10890](https://github.com/earendil-works/pi/issues/10890))
|
||||
- Fixed Xiaomi MiMo models failing with a 400 at the `minimal` thinking level by sending only the documented thinking on/off toggle and exposing only the `off` and `high` levels ([#10818](https://github.com/earendil-works/pi/issues/10818))
|
||||
|
|
|
|||
|
|
@ -233,8 +233,12 @@ for (const block of response.content) {
|
|||
}
|
||||
```
|
||||
|
||||
`usage.cost.totalFromProvider` is `true` when `usage.cost.total` is the billed cost reported by the provider and `false` when it is estimated from the model's catalog rates. A missing value, as in sessions recorded before this field existed, also means `false`.
|
||||
|
||||
Snippets in the rest of this README assume a `models` collection set up like this (with the relevant providers registered).
|
||||
|
||||
`usage.cost.total` is the best available total cost. For OpenRouter chat responses, it uses the cost reported by OpenRouter, including both the OpenRouter charge and upstream inference cost for BYOK requests. When provider accounting is unavailable or invalid, it falls back to the model's catalog rates. The `input`, `output`, `cacheRead`, and `cacheWrite` cost components remain catalog estimates and may not sum to a provider-reported total.
|
||||
|
||||
## Providers and Models
|
||||
|
||||
A **provider** is the runtime unit: it owns its model catalog, its auth (API key resolution, OAuth flows), and its stream behavior. A `Models` collection holds providers and routes every request to the provider that owns the model.
|
||||
|
|
@ -895,8 +899,8 @@ Classifier models consume structured JSON state, and optionally images, and answ
|
|||
| Provider | Model IDs | Auth |
|
||||
| --- | --- | --- |
|
||||
| `typesafe` | `jev-latest` | `TYPESAFE_API_KEY` |
|
||||
| `openrouter` | `typesafe/jev-1.13`, `~typesafe/jev-latest` | `OPENROUTER_API_KEY` or OpenRouter OAuth |
|
||||
| `cloudflare-workers-ai` | `typesafe/jev`, `@cf/cloudflare/clef`, `@cf/cloudflare/clef-flash` | `CLOUDFLARE_API_KEY` and `CLOUDFLARE_ACCOUNT_ID` |
|
||||
| `openrouter` | `typesafe/jev-1.13`, `typesafe/jev-1.14`, `~typesafe/jev-latest`, `cloudflare/clef`, `cloudflare/clef-flash`, `cloudflare/clef-omni` | `OPENROUTER_API_KEY` or OpenRouter OAuth |
|
||||
| `cloudflare-workers-ai` | `typesafe/jev`, `typesafe/jev-1.14.0`, `@cf/cloudflare/clef`, `@cf/cloudflare/clef-flash`, `@cf/cloudflare/clef-omni` | `CLOUDFLARE_API_KEY` and `CLOUDFLARE_ACCOUNT_ID` |
|
||||
| `vercel-ai-gateway` | `typesafe-ai/jev` | `AI_GATEWAY_API_KEY` |
|
||||
| `opencode` | `jev-1.13`, `jev-1.13-free` | `OPENCODE_API_KEY` |
|
||||
| `openai` | `gpt-6-luna` | `OPENAI_API_KEY` |
|
||||
|
|
@ -941,6 +945,18 @@ When the service reports token counts, `result.usage` carries them with their co
|
|||
|
||||
`ClassifierContext.images` holds image blocks (`{ type: "image", data, mimeType }`) judged together with the state. Only models whose `input` includes `"image"` accept them; `classify()` returns an error result for other models.
|
||||
|
||||
### System One images
|
||||
|
||||
System One services take images in three forms. The model's `compat.imageInput` chooses the form (`"assets"`, `"images"`, or `"state-image-urls"`), and `compat.sendImageOptInHeader` controls TypeSafe's opt-in header:
|
||||
|
||||
- **TypeSafe** (`typesafe`, and Jev on Workers AI) takes PNG and JPEG images as `assets`, `{ "<image-1>": { type: "image", format: "png", data_base64 } }`. Every asset must be referenced from the request, so the state is sent as `["<image-1>", ..., state]`. Images are experimental on TypeSafe's own API and need the `TypeSafe-Danger-Zone-Images: true` header, which the API sends with requests that carry images.
|
||||
- **Clef on Workers AI** takes up to four PNG, JPEG, or WebP images as an `images` list of data URLs, next to the unchanged state.
|
||||
- **OpenRouter** takes images as `{ type: "image_url", image_url: { url } }` parts before the state: `[part, ..., state]`. It answers requests with `assets` but drops the images, without an error.
|
||||
|
||||
Built-in catalog entries set `compat` explicitly, so they keep their form when `baseUrl` points at a proxy. For models without it, the APIs detect the form: OpenRouter by provider `openrouter` or a `baseUrl` containing `openrouter.ai`, Clef by a model ID starting with `@cf/cloudflare/clef`, and TypeSafe's form with the opt-in header otherwise. A custom provider that reaches OpenRouter through a proxy matches neither check and must set `compat.imageInput: "state-image-urls"` and `compat.sendImageOptInHeader: false`; otherwise OpenRouter drops its images.
|
||||
|
||||
Vercel AI Gateway and OpenCode list their Jev models with text input only.
|
||||
|
||||
### OpenAI Decisions
|
||||
|
||||
The `openai-decisions` API calls OpenAI's [Decisions API](https://developers.openai.com/api/docs/guides/decisions) (`POST /v1/decisions`). GPT-6 Luna is its only model. The state is sent as JSON text; with images, the input is one user message with the state followed by the images as data URLs (at most 128). `choice` and `score` questions map to the Decisions types of the same name. `bool` questions become `predicate` questions; predicates have no criteria field, so the meanings of true and false are appended to the instructions. If OpenAI refuses to answer a question, the whole result is an error. Usage reports input tokens only, priced at the catalog input rate with OpenAI's long-context multiplier.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@earendil-works/pi-ai",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
|
@ -69,7 +69,7 @@
|
|||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.129.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.1127.0",
|
||||
"@earendil-works/pi-telemetry": "1.3.0",
|
||||
"@earendil-works/pi-telemetry": "1.4.0",
|
||||
"@google/genai": "2.21.0",
|
||||
"@smithy/node-http-handler": "4.12.1",
|
||||
"http-proxy-agent": "9.1.0",
|
||||
|
|
@ -101,6 +101,6 @@
|
|||
"devDependencies": {
|
||||
"@types/node": "22.19.19",
|
||||
"canvas": "3.2.3",
|
||||
"vitest": "4.1.11"
|
||||
"vitest": "4.1.12"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2631,11 +2631,15 @@ async function loadModelsDevClassifierModels(): Promise<ClassifierModel<"typesaf
|
|||
api: "typesafe-system-one",
|
||||
provider: "typesafe",
|
||||
baseUrl: "https://api.typesafe.ai/v1/",
|
||||
input: metadata.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
|
||||
// models.dev lists text input only, but the API accepts image assets as an experimental
|
||||
// feature behind an opt-in header, which the typesafe-system-one API sends.
|
||||
// https://github.com/typesafe-ai/typesafe-sdk-js (v0.7.0 changelog)
|
||||
input: ["text", "image"],
|
||||
// The canonical models.dev entry has no direct-provider pricing. System One reports token usage,
|
||||
// so classify() results carry token counts but price them at zero.
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: metadata.limit?.context || 64000,
|
||||
compat: { imageInput: "assets", sendImageOptInHeader: true },
|
||||
},
|
||||
];
|
||||
} catch (error) {
|
||||
|
|
@ -2645,9 +2649,6 @@ async function loadModelsDevClassifierModels(): Promise<ClassifierModel<"typesaf
|
|||
}
|
||||
}
|
||||
|
||||
// Workers AI has no unauthenticated catalog and models.dev does not list its
|
||||
// System One models yet. Cloudflare publishes pricing only in the dashboard.
|
||||
// https://developers.cloudflare.com/ai/models/typesafe/jev/
|
||||
// OpenCode Zen serves Jev through its TypeSafe-compatible System One endpoint.
|
||||
// Neither its /inference/v1/models listing nor models.dev carries metadata for it.
|
||||
// https://opencode.ai/docs/zen
|
||||
|
|
@ -2676,11 +2677,14 @@ const OPENCODE_CLASSIFIER_MODELS: ClassifierModel<"typesafe-system-one">[] = [
|
|||
},
|
||||
];
|
||||
|
||||
// Workers AI has no unauthenticated catalog, so its System One models are listed here from
|
||||
// Cloudflare's model pages.
|
||||
const CLOUDFLARE_WORKERS_AI_CLASSIFIER_MODELS: ClassifierModel<"cloudflare-workers-ai-system-one">[] = [
|
||||
// Cloudflare-hosted Clef decision models. They accept images, but classifier
|
||||
// contexts carry text or JSON state only, so the catalog advertises text.
|
||||
// Pricing: https://developers.cloudflare.com/workers-ai/models/clef/
|
||||
// and https://developers.cloudflare.com/workers-ai/models/clef-flash/
|
||||
// Cloudflare-hosted Clef decision models. They take up to four PNG, JPEG, or WebP images as data URLs.
|
||||
// Clef Omni also takes audio and video, which classifier contexts do not carry.
|
||||
// Pricing and context: https://developers.cloudflare.com/workers-ai/models/clef/,
|
||||
// https://developers.cloudflare.com/workers-ai/models/clef-flash/,
|
||||
// and https://developers.cloudflare.com/workers-ai/models/clef-omni/
|
||||
{
|
||||
type: "classifier",
|
||||
id: "@cf/cloudflare/clef",
|
||||
|
|
@ -2688,9 +2692,10 @@ const CLOUDFLARE_WORKERS_AI_CLASSIFIER_MODELS: ClassifierModel<"cloudflare-worke
|
|||
api: "cloudflare-workers-ai-system-one",
|
||||
provider: "cloudflare-workers-ai",
|
||||
baseUrl: CLOUDFLARE_WORKERS_AI_REST_BASE_URL,
|
||||
input: ["text"],
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0.24, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 65536,
|
||||
compat: { imageInput: "images" },
|
||||
},
|
||||
{
|
||||
type: "classifier",
|
||||
|
|
@ -2699,10 +2704,26 @@ const CLOUDFLARE_WORKERS_AI_CLASSIFIER_MODELS: ClassifierModel<"cloudflare-worke
|
|||
api: "cloudflare-workers-ai-system-one",
|
||||
provider: "cloudflare-workers-ai",
|
||||
baseUrl: CLOUDFLARE_WORKERS_AI_REST_BASE_URL,
|
||||
input: ["text"],
|
||||
cost: { input: 0.09, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 65536,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0.038, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 24576,
|
||||
compat: { imageInput: "images" },
|
||||
},
|
||||
{
|
||||
type: "classifier",
|
||||
id: "@cf/cloudflare/clef-omni",
|
||||
name: "Clef Omni",
|
||||
api: "cloudflare-workers-ai-system-one",
|
||||
provider: "cloudflare-workers-ai",
|
||||
baseUrl: CLOUDFLARE_WORKERS_AI_REST_BASE_URL,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0.15, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 64000,
|
||||
compat: { imageInput: "images" },
|
||||
},
|
||||
// TypeSafe's Jev models take PNG and JPEG images as System One assets.
|
||||
// https://developers.cloudflare.com/ai/models/typesafe/jev/ (jev-latest)
|
||||
// and https://developers.cloudflare.com/ai/models/typesafe/jev-1.14.0/
|
||||
{
|
||||
type: "classifier",
|
||||
id: "typesafe/jev",
|
||||
|
|
@ -2710,9 +2731,22 @@ const CLOUDFLARE_WORKERS_AI_CLASSIFIER_MODELS: ClassifierModel<"cloudflare-worke
|
|||
api: "cloudflare-workers-ai-system-one",
|
||||
provider: "cloudflare-workers-ai",
|
||||
baseUrl: CLOUDFLARE_WORKERS_AI_REST_BASE_URL,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0.042, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 32000,
|
||||
compat: { imageInput: "assets" },
|
||||
},
|
||||
{
|
||||
type: "classifier",
|
||||
id: "typesafe/jev-1.14.0",
|
||||
name: "Jev 1.14",
|
||||
api: "cloudflare-workers-ai-system-one",
|
||||
provider: "cloudflare-workers-ai",
|
||||
baseUrl: CLOUDFLARE_WORKERS_AI_REST_BASE_URL,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0.042, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 32000,
|
||||
compat: { imageInput: "assets" },
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -98,6 +98,8 @@ export function buildOpenRouterCatalog(
|
|||
input: input.length > 0 ? input : ["text"],
|
||||
cost: openRouterCost(model.pricing),
|
||||
contextWindow: model.top_provider?.context_length || model.context_length || 4096,
|
||||
// OpenRouter answers TypeSafe `assets` with 200 but drops the images.
|
||||
compat: { imageInput: "state-image-urls", sendImageOptInHeader: false },
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,11 @@ import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
|||
import { headersToRecord } from "../utils/headers.ts";
|
||||
import { type ClientIdentity, getIdentityHeaders } from "../utils/identity.ts";
|
||||
import { parseJsonWithRepair, parseStreamingJson, parseToolCallArguments } from "../utils/json-parse.ts";
|
||||
import {
|
||||
applyOpenRouterReportedCost,
|
||||
isOpenRouterModel,
|
||||
type OpenRouterUsageAccounting,
|
||||
} from "../utils/openrouter-cost.ts";
|
||||
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
||||
import { retryProviderRequest } from "../utils/provider-retry.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
|
|
@ -888,6 +893,14 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
|
|||
output.usage.totalTokens =
|
||||
output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
|
||||
calculateCost(usageModel, output.usage);
|
||||
if (isOpenRouterModel(model) && event.usage) {
|
||||
// OpenRouter adds billing fields to streamed message_delta usage at runtime,
|
||||
// although its OpenAPI schema and the Anthropic SDK do not type those fields.
|
||||
applyOpenRouterReportedCost(
|
||||
output.usage,
|
||||
event.usage as typeof event.usage & OpenRouterUsageAccounting,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1362,7 +1375,9 @@ function convertMessages(
|
|||
const params: MessageParam[] = [];
|
||||
const assistantLevels = new Map<number, AnthropicEffort>();
|
||||
// Later system messages are held back and emitted directly before the next assistant
|
||||
// message (or at the end of the transcript). Anthropic requires `tool_result` blocks to
|
||||
// message that is sent (or at the end of the transcript). Assistant messages that convert to
|
||||
// no blocks are dropped, so holding past them keeps an update from landing before a user
|
||||
// message (#11091). Anthropic requires `tool_result` blocks to
|
||||
// immediately follow their `tool_use`, so a system message between them is rejected; this
|
||||
// also mirrors where the managed-effort system messages are inserted. As a result an
|
||||
// update placed before a user message in the transcript lands after it on the wire.
|
||||
|
|
@ -1429,7 +1444,6 @@ function convertMessages(
|
|||
});
|
||||
}
|
||||
} else if (msg.role === "assistant") {
|
||||
flushPendingSystemMessages();
|
||||
const blocks: ContentBlockParam[] = [];
|
||||
|
||||
for (const block of msg.content) {
|
||||
|
|
@ -1484,6 +1498,7 @@ function convertMessages(
|
|||
}
|
||||
}
|
||||
if (blocks.length === 0) continue;
|
||||
flushPendingSystemMessages();
|
||||
const messageIndex = params.length;
|
||||
params.push({
|
||||
role: "assistant",
|
||||
|
|
|
|||
|
|
@ -23,10 +23,18 @@ function cloudflareErrorMessage(errors: unknown): string {
|
|||
* Cloudflare-hosted models such as `@cf/cloudflare/clef` return the output directly:
|
||||
* `{ success, result: { model, answers, usage } }`.
|
||||
* https://developers.cloudflare.com/workers-ai/models/clef/
|
||||
*
|
||||
* Clef models take images as an `images` list of data URLs. TypeSafe's models take them as
|
||||
* System One `assets`, without TypeSafe's opt-in header. The catalog sets `compat.imageInput`;
|
||||
* the model ID check only covers models without it.
|
||||
*/
|
||||
const transport: SystemOneTransport = {
|
||||
api: "cloudflare-workers-ai-system-one",
|
||||
label: LABEL,
|
||||
detectCompat: (model) => ({
|
||||
imageInput: model.id.startsWith("@cf/cloudflare/clef") ? "images" : "assets",
|
||||
sendImageOptInHeader: false,
|
||||
}),
|
||||
url: (model) => new URL("run", `${model.baseUrl.replace(/\/+$/u, "")}/`),
|
||||
payload: (model, request) => ({ model: model.id, input: request }),
|
||||
output: (body) => {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,11 @@ import { shortHash } from "../utils/hash.ts";
|
|||
import { headersToRecord } from "../utils/headers.ts";
|
||||
import { type ClientIdentity, getIdentityHeaders } from "../utils/identity.ts";
|
||||
import { parseStreamingJson, parseToolCallArguments } from "../utils/json-parse.ts";
|
||||
import {
|
||||
applyOpenRouterReportedCost,
|
||||
isOpenRouterModel,
|
||||
type OpenRouterUsageAccounting,
|
||||
} from "../utils/openrouter-cost.ts";
|
||||
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
||||
import { retryProviderRequest } from "../utils/provider-retry.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
|
|
@ -1527,6 +1532,8 @@ function convertTools(
|
|||
}
|
||||
|
||||
function parseChunkUsage(
|
||||
// OpenRouter adds billing fields to streamed usage at runtime,
|
||||
// although the OpenAI SDK does not type those fields.
|
||||
rawUsage: {
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
|
|
@ -1566,6 +1573,9 @@ function parseChunkUsage(
|
|||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
calculateCost(model, usage);
|
||||
if (isOpenRouterModel(model)) {
|
||||
applyOpenRouterReportedCost(usage, rawUsage as typeof rawUsage & OpenRouterUsageAccounting);
|
||||
}
|
||||
return usage;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -193,6 +193,7 @@ function parseUsage(
|
|||
cacheRead: (model.cost.cacheRead / 1000000) * cacheReadTokens,
|
||||
cacheWrite: (model.cost.cacheWrite / 1000000) * cacheWriteTokens,
|
||||
total: 0,
|
||||
totalFromProvider: false,
|
||||
},
|
||||
};
|
||||
usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite;
|
||||
|
|
|
|||
|
|
@ -5,22 +5,62 @@ import type {
|
|||
ClassifierModel,
|
||||
ClassifierOptions,
|
||||
ClassifierResult,
|
||||
ImageContent,
|
||||
SystemOneCompat,
|
||||
} from "../types.ts";
|
||||
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
|
||||
import { isRecord, parseClassifierUsage, postClassifierRequest, requiredNumber } from "./classifier-shared.ts";
|
||||
|
||||
/** A System One image asset: base64 file bytes, referenced by its name from the state. */
|
||||
interface SystemOneImageAsset {
|
||||
type: "image";
|
||||
format: "png" | "jpeg";
|
||||
data_base64: string;
|
||||
}
|
||||
|
||||
/** An image in the state, OpenRouter's form. */
|
||||
interface SystemOneImageUrlPart {
|
||||
type: "image_url";
|
||||
image_url: { url: string };
|
||||
}
|
||||
|
||||
/** TypeSafe System One request body without the transport-specific envelope. */
|
||||
export interface SystemOneWireRequest {
|
||||
state: ClassifierContext["state"];
|
||||
/** The context state, or with images a list of the images or their asset names followed by the state. */
|
||||
state: unknown;
|
||||
questions: Record<string, unknown>;
|
||||
/** Images as System One assets, keyed by the names the state references. */
|
||||
assets?: Record<string, SystemOneImageAsset>;
|
||||
/** Images as data URLs, Clef's extension to System One. */
|
||||
images?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* How a service takes images (`SystemOneCompat.imageInput`):
|
||||
* - `assets`: TypeSafe's `assets` map, `{ "<image-1>": { type: "image", format, data_base64 } }`,
|
||||
* with the names placed before the state: `state: ["<image-1>", state]`. PNG and JPEG only.
|
||||
* - `images`: Clef's `images` list of data URLs, which the model reads before the state.
|
||||
* PNG, JPEG, and WebP, at most four.
|
||||
* - `state-image-urls`: OpenRouter's form, data URL parts placed before the state:
|
||||
* `state: [{ type: "image_url", image_url: { url } }, state]`. OpenRouter accepts this for every
|
||||
* model, and drops `assets` silently.
|
||||
*/
|
||||
export type SystemOneImageInput = NonNullable<SystemOneCompat["imageInput"]>;
|
||||
|
||||
/** System One compatibility settings with every field resolved. */
|
||||
export type ResolvedSystemOneCompat = Required<SystemOneCompat>;
|
||||
|
||||
/** TypeSafe's opt-in header for its experimental image input. */
|
||||
const IMAGE_OPT_IN_HEADERS = { "TypeSafe-Danger-Zone-Images": "true" };
|
||||
|
||||
/** Differences between services that serve System One models. */
|
||||
export interface SystemOneTransport {
|
||||
/** Classifier API implemented by this transport. */
|
||||
api: ClassifierApi;
|
||||
/** Service name used in error messages. */
|
||||
label: string;
|
||||
/** Compatibility settings for models that do not set them in `compat`. */
|
||||
detectCompat(model: ClassifierModel<ClassifierApi>): ResolvedSystemOneCompat;
|
||||
/** Absolute request URL. */
|
||||
url(model: ClassifierModel<ClassifierApi>): URL;
|
||||
/** Wraps the System One request in the service's request envelope. */
|
||||
|
|
@ -82,6 +122,56 @@ function parseAnswers(label: string, value: unknown, context: ClassifierContext)
|
|||
return Object.fromEntries(answers);
|
||||
}
|
||||
|
||||
const ASSET_FORMATS: Record<string, SystemOneImageAsset["format"]> = { "image/png": "png", "image/jpeg": "jpeg" };
|
||||
const DATA_URL_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]);
|
||||
/** Clef accepts at most this many images per request. */
|
||||
const MAX_DATA_URL_IMAGES = 4;
|
||||
|
||||
/** Adds the context images to `request` in the service's form. */
|
||||
function addImages(
|
||||
label: string,
|
||||
model: ClassifierModel<ClassifierApi>,
|
||||
input: SystemOneImageInput,
|
||||
images: ImageContent[],
|
||||
request: SystemOneWireRequest,
|
||||
): void {
|
||||
if (input === "images") {
|
||||
if (images.length > MAX_DATA_URL_IMAGES) {
|
||||
throw new Error(
|
||||
`${label} accepts at most ${MAX_DATA_URL_IMAGES} images for ${model.id}, got ${images.length}`,
|
||||
);
|
||||
}
|
||||
request.images = images.map((image) => {
|
||||
if (!DATA_URL_TYPES.has(image.mimeType)) {
|
||||
throw new Error(`${label} accepts PNG, JPEG, and WebP images for ${model.id}, got ${image.mimeType}`);
|
||||
}
|
||||
return `data:${image.mimeType};base64,${image.data}`;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (input === "state-image-urls") {
|
||||
// OpenRouter validates the images against the model that serves the request.
|
||||
const parts = images.map(
|
||||
(image): SystemOneImageUrlPart => ({
|
||||
type: "image_url",
|
||||
image_url: { url: `data:${image.mimeType};base64,${image.data}` },
|
||||
}),
|
||||
);
|
||||
request.state = [...parts, request.state];
|
||||
return;
|
||||
}
|
||||
const assets: Record<string, SystemOneImageAsset> = {};
|
||||
images.forEach((image, index) => {
|
||||
const format = ASSET_FORMATS[image.mimeType];
|
||||
if (!format) throw new Error(`${label} accepts PNG and JPEG images for ${model.id}, got ${image.mimeType}`);
|
||||
assets[`<image-${index + 1}>`] = { type: "image", format, data_base64: image.data };
|
||||
});
|
||||
request.assets = assets;
|
||||
// Every asset must be referenced from the request. The names go before the state, where Clef
|
||||
// also places its images.
|
||||
request.state = [...Object.keys(assets), request.state];
|
||||
}
|
||||
|
||||
/** Maps public `bool` questions to TypeSafe's wire-level `noul` type. */
|
||||
function wireRequest(context: ClassifierContext): SystemOneWireRequest {
|
||||
return {
|
||||
|
|
@ -113,13 +203,25 @@ export async function classifySystemOne(
|
|||
|
||||
try {
|
||||
if (model.api !== transport.api) throw new Error(`Unsupported classifier API: ${model.api}`);
|
||||
if (context.images?.length) throw new Error(`${transport.label} does not support image input`);
|
||||
const request = wireRequest(context);
|
||||
let requestOptions = options;
|
||||
if (context.images?.length) {
|
||||
if (!model.input.includes("image")) {
|
||||
throw new Error(`${transport.label} does not support image input for ${model.id}`);
|
||||
}
|
||||
const detected = transport.detectCompat(model);
|
||||
const imageInput = model.compat?.imageInput ?? detected.imageInput;
|
||||
addImages(transport.label, model, imageInput, context.images, request);
|
||||
if (model.compat?.sendImageOptInHeader ?? detected.sendImageOptInHeader) {
|
||||
requestOptions = { ...options, headers: { ...IMAGE_OPT_IN_HEADERS, ...options?.headers } };
|
||||
}
|
||||
}
|
||||
const body = await postClassifierRequest(
|
||||
transport.label,
|
||||
transport.url(model),
|
||||
model,
|
||||
transport.payload(model, wireRequest(context)),
|
||||
options,
|
||||
transport.payload(model, request),
|
||||
requestOptions,
|
||||
);
|
||||
const result = transport.output(body);
|
||||
// Set before parsing answers: a request with malformed answers was still billed.
|
||||
|
|
|
|||
|
|
@ -5,10 +5,21 @@ import { classifySystemOne, type SystemOneTransport } from "./system-one-shared.
|
|||
/**
|
||||
* TypeSafe's native System One protocol. OpenRouter serves the same protocol,
|
||||
* so both providers use this API with different base URLs.
|
||||
*
|
||||
* TypeSafe takes images as `assets`. They are experimental and need the
|
||||
* `TypeSafe-Danger-Zone-Images` opt-in header; without it the API answers 400.
|
||||
* OpenRouter drops `assets` silently and takes images as `image_url` parts in the state instead.
|
||||
* Models behind a proxy that hides both the provider ID and the OpenRouter host must set
|
||||
* `compat.imageInput`.
|
||||
*/
|
||||
const transport: SystemOneTransport = {
|
||||
api: "typesafe-system-one",
|
||||
label: "System One API",
|
||||
// OpenRouter by provider ID, which survives baseUrl overrides, or by host, which covers regional endpoints.
|
||||
detectCompat: (model) =>
|
||||
model.provider === "openrouter" || model.baseUrl.includes("openrouter.ai")
|
||||
? { imageInput: "state-image-urls", sendImageOptInHeader: false }
|
||||
: { imageInput: "assets", sendImageOptInHeader: true },
|
||||
url: (model) => new URL("systemone", `${model.baseUrl.replace(/\/+$/u, "")}/`),
|
||||
payload: (model, request) => ({ model: model.id, ...request }),
|
||||
output: (body) => {
|
||||
|
|
|
|||
|
|
@ -141,6 +141,10 @@ export type AuthEvent =
|
|||
type: "device_code";
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
/** Offer best-effort browser opening on user confirmation. Defaults to true; clients may ignore it. */
|
||||
openBrowserOnConfirm?: boolean;
|
||||
/** Offer copying of userCode on user confirmation, replacing the clipboard. Defaults to true. */
|
||||
copyCodeOnConfirm?: boolean;
|
||||
intervalSeconds?: number;
|
||||
expiresInSeconds?: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ export interface OAuthAuthInfo {
|
|||
export interface OAuthDeviceCodeInfo {
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
/** Offer best-effort browser opening on user confirmation. Defaults to true; clients may ignore it. */
|
||||
openBrowserOnConfirm?: boolean;
|
||||
/** Offer copying of userCode on user confirmation, replacing the clipboard. Defaults to true. */
|
||||
copyCodeOnConfirm?: boolean;
|
||||
intervalSeconds?: number;
|
||||
expiresInSeconds?: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1252,6 +1252,7 @@ export function calculateCost(model: AnyModel, usage: Usage): Usage["cost"] {
|
|||
usage.cost.cacheRead = (rates.cacheRead / 1000000) * usage.cacheRead;
|
||||
usage.cost.cacheWrite = (rates.cacheWrite * shortWrite + rates.input * 2 * longWrite) / 1000000;
|
||||
usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite;
|
||||
usage.cost.totalFromProvider = false;
|
||||
return usage.cost;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -521,6 +521,22 @@ export const MistralConversationsCompatSchema = Type.Object(
|
|||
{ description: "Compatibility settings for the Mistral chat API.", additionalProperties: true },
|
||||
);
|
||||
|
||||
export const SystemOneCompatSchema = Type.Object(
|
||||
{
|
||||
imageInput: Type.Optional(
|
||||
Type.Union([Type.Literal("assets"), Type.Literal("images"), Type.Literal("state-image-urls")], {
|
||||
description:
|
||||
'How the service takes classifier images. "assets" sends TypeSafe\'s assets map (PNG and JPEG) and references the images from the state, as TypeSafe and Jev on Workers AI expect. "images" sends Clef\'s images list of data URLs (PNG, JPEG, and WebP, at most four), as Clef on Workers AI expects. "state-image-urls" places image_url parts before the state, as OpenRouter expects; OpenRouter drops assets without an error. Auto-detected from the provider, baseUrl, and model ID. Classifier models read it from the provider-level compat; models and modelOverrides entries apply to chat models only.',
|
||||
}),
|
||||
),
|
||||
sendImageOptInHeader: optionalCompatBoolean({
|
||||
description:
|
||||
"Whether requests with images send the TypeSafe-Danger-Zone-Images: true header, which TypeSafe's own API requires for its experimental image input. Defaults to true for the typesafe-system-one API unless the model is detected as OpenRouter.",
|
||||
}),
|
||||
},
|
||||
{ description: "Compatibility settings for System One classifier APIs.", additionalProperties: true },
|
||||
);
|
||||
|
||||
const ProviderCompatPropertyOverrides = {
|
||||
supportsDeveloperRole: optionalCompatBoolean({
|
||||
description: "Whether the provider supports the developer role instead of system. Defaults are API-specific.",
|
||||
|
|
@ -609,6 +625,8 @@ export const CompatSchemasByApi = {
|
|||
"anthropic-messages": AnthropicMessagesCompatSchema,
|
||||
"bedrock-converse-stream": BedrockCompatSchema,
|
||||
"mistral-conversations": MistralConversationsCompatSchema,
|
||||
"typesafe-system-one": SystemOneCompatSchema,
|
||||
"cloudflare-workers-ai-system-one": SystemOneCompatSchema,
|
||||
} as const;
|
||||
|
||||
const ProviderCompatProperties = mergeCompatProperties(
|
||||
|
|
@ -642,3 +660,5 @@ export interface AnthropicMessagesCompat extends Static<typeof AnthropicMessages
|
|||
export interface BedrockCompat extends Static<typeof BedrockCompatSchema> {}
|
||||
/** Compatibility settings for the Mistral chat API. */
|
||||
export interface MistralConversationsCompat extends Static<typeof MistralConversationsCompatSchema> {}
|
||||
/** Compatibility settings for System One classifier APIs. */
|
||||
export interface SystemOneCompat extends Static<typeof SystemOneCompatSchema> {}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import type {
|
|||
MistralConversationsCompat,
|
||||
OpenAICompletionsCompat,
|
||||
OpenAIResponsesCompat,
|
||||
SystemOneCompat,
|
||||
} from "./providers/compat-schema.ts";
|
||||
import type {
|
||||
CacheRetention,
|
||||
|
|
@ -40,6 +41,7 @@ export type {
|
|||
OpenAIResponsesCompat,
|
||||
OpenRouterRouting,
|
||||
SessionAffinityFormat,
|
||||
SystemOneCompat,
|
||||
ThinkingTokenBudgetField,
|
||||
VercelGatewayRouting,
|
||||
} from "./providers/compat-schema.ts";
|
||||
|
|
@ -469,11 +471,18 @@ export interface Usage {
|
|||
reasoning?: number;
|
||||
totalTokens: number;
|
||||
cost: {
|
||||
/** Catalog-derived input cost. */
|
||||
input: number;
|
||||
/** Catalog-derived output cost. */
|
||||
output: number;
|
||||
/** Catalog-derived cache-read cost. */
|
||||
cacheRead: number;
|
||||
/** Catalog-derived cache-write cost. */
|
||||
cacheWrite: number;
|
||||
/** Best available total: provider-reported billed cost when supported, otherwise the catalog estimate. */
|
||||
total: number;
|
||||
/** True when `total` is the billed cost reported by the provider. False when it is a catalog estimate; absent also means false. */
|
||||
totalFromProvider?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -888,6 +897,8 @@ export interface ImageModel<TApi extends ImageApi> extends BaseModel<TApi> {
|
|||
export interface ClassifierModel<TApi extends ClassifierApi> extends BaseModel<TApi> {
|
||||
type: "classifier";
|
||||
contextWindow: number;
|
||||
/** Compatibility overrides for System One APIs. If not set, auto-detected from provider, baseUrl, and id. */
|
||||
compat?: TApi extends "typesafe-system-one" | "cloudflare-workers-ai-system-one" ? SystemOneCompat : never;
|
||||
}
|
||||
|
||||
/** Model shape for each model type. */
|
||||
|
|
|
|||
55
packages/ai/src/utils/openrouter-cost.ts
Normal file
55
packages/ai/src/utils/openrouter-cost.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import type { Usage } from "../types.ts";
|
||||
|
||||
export interface OpenRouterUsageAccounting {
|
||||
/** Amount charged to the user's OpenRouter account. */
|
||||
cost: number | null;
|
||||
/** Whether OpenRouter used the user's upstream provider key. */
|
||||
is_byok: boolean;
|
||||
cost_details: {
|
||||
/** Total inference cost billed through the user's upstream provider account, when available. */
|
||||
upstream_inference_cost: number | null;
|
||||
/** Upstream cost for prompt processing, including any provider-side cache pricing. */
|
||||
upstream_inference_prompt_cost: number;
|
||||
/** Upstream cost for completion generation. */
|
||||
upstream_inference_completions_cost: number;
|
||||
/** Metered server-tool cost already included in OpenRouter's charged `cost`. */
|
||||
server_tool_cost?: number | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
function isValidCost(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
||||
}
|
||||
|
||||
export function isOpenRouterModel(model: { provider: string; baseUrl: string }): boolean {
|
||||
return model.provider === "openrouter" || model.baseUrl.includes("openrouter.ai");
|
||||
}
|
||||
|
||||
/** Replace the catalog total with OpenRouter's billed cost when the response contains complete, valid accounting. */
|
||||
export function applyOpenRouterReportedCost(usage: Usage, reported: OpenRouterUsageAccounting): void {
|
||||
if (!isValidCost(reported.cost)) return;
|
||||
|
||||
if (reported.is_byok === false) {
|
||||
usage.cost.total = reported.cost;
|
||||
usage.cost.totalFromProvider = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (reported.is_byok === true) {
|
||||
const details = reported.cost_details;
|
||||
if (!details) return;
|
||||
let upstreamCost: unknown = details.upstream_inference_cost;
|
||||
if (upstreamCost === null) {
|
||||
if (
|
||||
!isValidCost(details.upstream_inference_prompt_cost) ||
|
||||
!isValidCost(details.upstream_inference_completions_cost)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
upstreamCost = details.upstream_inference_prompt_cost + details.upstream_inference_completions_cost;
|
||||
}
|
||||
if (!isValidCost(upstreamCost)) return;
|
||||
usage.cost.total = reported.cost + upstreamCost;
|
||||
usage.cost.totalFromProvider = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +1,2 @@
|
|||
// The @earendil-works/pi-ai package version. scripts/sync-versions.js keeps it in sync with package.json.
|
||||
export const PI_AI_VERSION = "1.3.0";
|
||||
export const PI_AI_VERSION = "1.4.0";
|
||||
|
|
|
|||
|
|
@ -139,6 +139,42 @@ describe("Anthropic raw SSE parsing", () => {
|
|||
expect(eventModels).toEqual([model, model, model, model, model, model]);
|
||||
});
|
||||
|
||||
// Regression test for #9980.
|
||||
it("uses the total reported by OpenRouter's Anthropic Messages endpoint", async () => {
|
||||
const events = minimalAnthropicEvents.map((event) => ({ ...event }));
|
||||
const delta = JSON.parse(events[4].data) as {
|
||||
usage: Record<string, unknown>;
|
||||
};
|
||||
// These fields were captured from a production OpenRouter stream even though its
|
||||
// MessagesDeltaEvent OpenAPI schema and the Anthropic SDK omit them.
|
||||
delta.usage = {
|
||||
...delta.usage,
|
||||
cost: 0.004,
|
||||
is_byok: false,
|
||||
cost_details: { upstream_inference_cost: 0.003 },
|
||||
};
|
||||
events[4].data = JSON.stringify(delta);
|
||||
|
||||
const model = {
|
||||
...getModel("openrouter", "anthropic/claude-haiku-4.5"),
|
||||
provider: "openrouter-work",
|
||||
};
|
||||
const result = await streamAnthropic(
|
||||
model,
|
||||
normalizeContext({ messages: [{ role: "user", content: "Hello", timestamp: 1 }] }),
|
||||
{ client: createFakeAnthropicClient(createSseResponse(events)) },
|
||||
).result();
|
||||
|
||||
expect(result.usage.cost.total).toBe(0.004);
|
||||
expect(result.usage.cost.totalFromProvider).toBe(true);
|
||||
expect(
|
||||
result.usage.cost.input +
|
||||
result.usage.cost.output +
|
||||
result.usage.cost.cacheRead +
|
||||
result.usage.cost.cacheWrite,
|
||||
).not.toBe(result.usage.cost.total);
|
||||
});
|
||||
|
||||
it("keeps signed thinking replayable when a proxy relabels the model", async () => {
|
||||
// Regression test for earendil-works/pi#9188.
|
||||
const model = getModel("anthropic", "claude-opus-5");
|
||||
|
|
|
|||
86
packages/ai/test/classifier-images-live.test.ts
Normal file
86
packages/ai/test/classifier-images-live.test.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* Live image classification through System One services. Each provider's block runs only when its
|
||||
* credentials are set. Each case sends a solid red and a solid blue PNG in separate requests, so a
|
||||
* service that drops the image cannot pass by chance.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createModels, type ModelsClassifierOptions } from "../src/models.ts";
|
||||
import { cloudflareWorkersAIProvider } from "../src/providers/cloudflare-workers-ai.ts";
|
||||
import { openrouterProvider } from "../src/providers/openrouter.ts";
|
||||
import { typesafeProvider } from "../src/providers/typesafe.ts";
|
||||
import type { ClassifierContext } from "../src/types.ts";
|
||||
import { hasCloudflareWorkersAICredentials } from "./cloudflare-utils.ts";
|
||||
|
||||
// 512x512 solid PNGs from the TypeSafe SDK's live image tests.
|
||||
const RED =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAgAAAAIAAQMAAADOtka5AAAABlBMVEX/AAAAAABBoxIDAAAANklEQVR42u3BAQEAAACCIP+vbkhAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB8G4IAAAFjdVCkAAAAAElFTkSuQmCC";
|
||||
const BLUE =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAgAAAAIAAQMAAADOtka5AAAABlBMVEUAAP8AAAB7Yr8+AAAANklEQVR42u3BAQEAAACCIP+vbkhAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB8G4IAAAFjdVCkAAAAAElFTkSuQmCC";
|
||||
|
||||
function colorContext(data: string): ClassifierContext {
|
||||
return {
|
||||
state: { task: "Name the color of the attached image" },
|
||||
images: [{ type: "image", data, mimeType: "image/png" }],
|
||||
questions: {
|
||||
color: {
|
||||
type: "choice",
|
||||
instructions: "What color is the attached image?",
|
||||
criteria: { red: "Red", blue: "Blue", green: "Green" },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const models = createModels();
|
||||
models.setProvider(typesafeProvider());
|
||||
models.setProvider(cloudflareWorkersAIProvider());
|
||||
models.setProvider(openrouterProvider());
|
||||
|
||||
async function expectColors(provider: string, id: string, options: ModelsClassifierOptions): Promise<void> {
|
||||
const model = models.getModelOfType("classifier", provider, id);
|
||||
if (!model) throw new Error(`missing ${provider}/${id}`);
|
||||
expect(model.input).toContain("image");
|
||||
for (const [data, color] of [
|
||||
[RED, "red"],
|
||||
[BLUE, "blue"],
|
||||
] as const) {
|
||||
const result = await models.classify(model, colorContext(data), options);
|
||||
expect(result.errorMessage).toBeUndefined();
|
||||
expect(result.answers.color).toMatchObject({ type: "choice", choice: color });
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(!process.env.TYPESAFE_API_KEY)("TypeSafe image classification", () => {
|
||||
it("classifies images with jev-latest", async () => {
|
||||
await expectColors("typesafe", "jev-latest", { apiKey: process.env.TYPESAFE_API_KEY });
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe.skipIf(!hasCloudflareWorkersAICredentials())("Cloudflare Workers AI image classification", () => {
|
||||
it.each([
|
||||
"@cf/cloudflare/clef",
|
||||
"@cf/cloudflare/clef-flash",
|
||||
"@cf/cloudflare/clef-omni",
|
||||
"typesafe/jev",
|
||||
"typesafe/jev-1.14.0",
|
||||
])(
|
||||
"classifies images with %s",
|
||||
async (id) => {
|
||||
await expectColors("cloudflare-workers-ai", id, {
|
||||
apiKey: process.env.CLOUDFLARE_API_KEY,
|
||||
env: { CLOUDFLARE_ACCOUNT_ID: process.env.CLOUDFLARE_ACCOUNT_ID! },
|
||||
});
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
});
|
||||
|
||||
describe.skipIf(!process.env.OPENROUTER_API_KEY)("OpenRouter image classification", () => {
|
||||
it.each(["typesafe/jev-1.14", "cloudflare/clef-flash"])(
|
||||
"classifies images with %s",
|
||||
async (id) => {
|
||||
await expectColors("openrouter", id, { apiKey: process.env.OPENROUTER_API_KEY });
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
});
|
||||
|
|
@ -248,7 +248,12 @@ describe("Models with classifier models", () => {
|
|||
it("routes OpenRouter classifier models through the System One API", () => {
|
||||
const models = builtinModels();
|
||||
for (const model of getBuiltinClassifierModels("openrouter")) {
|
||||
expect(model).toMatchObject({ api: "typesafe-system-one", baseUrl: "https://openrouter.ai/api/v1" });
|
||||
expect(model).toMatchObject({
|
||||
api: "typesafe-system-one",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
// Explicit, so images keep OpenRouter's form when the baseUrl is overridden.
|
||||
compat: { imageInput: "state-image-urls", sendImageOptInHeader: false },
|
||||
});
|
||||
expect(models.getModel("openrouter", model.id)).toBeUndefined();
|
||||
expect(models.getModelOfType("classifier", "openrouter", model.id)).toEqual(model);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,7 +105,8 @@ describe("Cloudflare Workers AI System One", () => {
|
|||
|
||||
it.each([
|
||||
["@cf/cloudflare/clef", 0.24],
|
||||
["@cf/cloudflare/clef-flash", 0.09],
|
||||
["@cf/cloudflare/clef-flash", 0.038],
|
||||
["@cf/cloudflare/clef-omni", 0.15],
|
||||
])("runs %s through /ai/run and parses its direct output", async (id, inputPrice) => {
|
||||
const { models } = setup();
|
||||
const clef = models.getModelOfType("classifier", "cloudflare-workers-ai", id);
|
||||
|
|
@ -131,6 +132,104 @@ describe("Cloudflare Workers AI System One", () => {
|
|||
expect(result.usage?.cost.input).toBeCloseTo((222 * inputPrice) / 1_000_000);
|
||||
});
|
||||
|
||||
it("sends images to Clef as data URLs", async () => {
|
||||
const { models } = setup();
|
||||
const clef = models.getModelOfType("classifier", "cloudflare-workers-ai", "@cf/cloudflare/clef");
|
||||
if (!clef) throw new Error("missing Cloudflare Clef model");
|
||||
const bodies: Array<{ model: string; input: Record<string, unknown> }> = [];
|
||||
const fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
|
||||
bodies.push(JSON.parse(String(init?.body)) as { model: string; input: Record<string, unknown> });
|
||||
return Response.json({ result: clefOutput, success: true, errors: [], messages: [] });
|
||||
});
|
||||
|
||||
const result = await models.classify(
|
||||
clef,
|
||||
{
|
||||
...context,
|
||||
images: [
|
||||
{ type: "image", data: "cG5n", mimeType: "image/png" },
|
||||
{ type: "image", data: "d2VicA==", mimeType: "image/webp" },
|
||||
],
|
||||
},
|
||||
{ ...auth, fetch },
|
||||
);
|
||||
|
||||
expect(result.stopReason).toBe("stop");
|
||||
// Clef rejects `assets` with a 422, so the state stays as is.
|
||||
expect(bodies[0]?.input).toEqual({
|
||||
state: context.state,
|
||||
questions: expect.any(Object),
|
||||
images: ["data:image/png;base64,cG5n", "data:image/webp;base64,d2VicA=="],
|
||||
});
|
||||
expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).has("typesafe-danger-zone-images")).toBe(false);
|
||||
});
|
||||
|
||||
it("reads the image form from compat before the model ID", async () => {
|
||||
const { models, jev } = setup();
|
||||
const clef = models.getModelOfType("classifier", "cloudflare-workers-ai", "@cf/cloudflare/clef");
|
||||
expect(clef?.compat).toEqual({ imageInput: "images" });
|
||||
expect(jev.compat).toEqual({ imageInput: "assets" });
|
||||
const bodies: Array<{ input: Record<string, unknown> }> = [];
|
||||
const fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
|
||||
bodies.push(JSON.parse(String(init?.body)) as { input: Record<string, unknown> });
|
||||
return Response.json({ result: clefOutput, success: true, errors: [], messages: [] });
|
||||
});
|
||||
|
||||
// A Clef deployment under an ID the prefix check does not recognize.
|
||||
await models.classify(
|
||||
{ ...jev, id: "@cf/acme/clef-finetune", input: ["text", "image"], compat: { imageInput: "images" } },
|
||||
{ ...context, images: [{ type: "image", data: "cG5n", mimeType: "image/png" }] },
|
||||
{ ...auth, fetch },
|
||||
);
|
||||
|
||||
expect(bodies[0]?.input.images).toEqual(["data:image/png;base64,cG5n"]);
|
||||
expect(bodies[0]?.input).not.toHaveProperty("assets");
|
||||
});
|
||||
|
||||
it("rejects more than four images for Clef before sending", async () => {
|
||||
const { models } = setup();
|
||||
const clef = models.getModelOfType("classifier", "cloudflare-workers-ai", "@cf/cloudflare/clef-flash");
|
||||
if (!clef) throw new Error("missing Cloudflare Clef Flash model");
|
||||
const fetch = vi.fn(async () => Response.json({ result: clefOutput, success: true }));
|
||||
const image = { type: "image" as const, data: "cG5n", mimeType: "image/png" };
|
||||
|
||||
const result = await models.classify(clef, { ...context, images: Array(5).fill(image) }, { ...auth, fetch });
|
||||
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(result.errorMessage).toBe(
|
||||
"Cloudflare Workers AI accepts at most 4 images for @cf/cloudflare/clef-flash, got 5",
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["typesafe/jev", "typesafe/jev-1.14.0"])("sends images to %s as System One assets", async (id) => {
|
||||
const { models } = setup();
|
||||
const jev = models.getModelOfType("classifier", "cloudflare-workers-ai", id);
|
||||
if (!jev) throw new Error(`missing Cloudflare ${id} model`);
|
||||
const bodies: Array<{ model: string; input: Record<string, unknown> }> = [];
|
||||
const fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
|
||||
bodies.push(JSON.parse(String(init?.body)) as { model: string; input: Record<string, unknown> });
|
||||
return Response.json(restResponse("Completed"));
|
||||
});
|
||||
|
||||
const result = await models.classify(
|
||||
jev,
|
||||
{ ...context, images: [{ type: "image", data: "cG5n", mimeType: "image/png" }] },
|
||||
{ ...auth, fetch },
|
||||
);
|
||||
|
||||
expect(result.stopReason).toBe("stop");
|
||||
expect(bodies[0]).toMatchObject({
|
||||
model: id,
|
||||
input: {
|
||||
state: ["<image-1>", context.state],
|
||||
assets: { "<image-1>": { type: "image", format: "png", data_base64: "cG5n" } },
|
||||
},
|
||||
});
|
||||
// Workers AI needs no TypeSafe opt-in header.
|
||||
expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).has("typesafe-danger-zone-images")).toBe(false);
|
||||
});
|
||||
|
||||
it("reports runs that did not complete", async () => {
|
||||
const { models, jev } = setup();
|
||||
const result = await models.classify(jev, context, {
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ describe("OpenRouter catalog parsing", () => {
|
|||
input: ["text"],
|
||||
cost: { input: 0.042, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 32000,
|
||||
compat: { imageInput: "state-image-urls", sendImageOptInHeader: false },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -159,6 +159,19 @@ describe("Models runtime", () => {
|
|||
expect(long.cacheWrite).toBe(0.0000125);
|
||||
});
|
||||
|
||||
it("marks catalog-derived totals as not provider-reported", () => {
|
||||
const usage: Usage = {
|
||||
input: 1_000_000,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 1_000_000,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 9, totalFromProvider: true },
|
||||
};
|
||||
calculateCost(testModel("openai", "gpt-5.6-sol"), usage);
|
||||
expect(usage.cost.totalFromProvider).toBe(false);
|
||||
});
|
||||
|
||||
it("registers, replaces, and deletes providers", () => {
|
||||
const models = createModels();
|
||||
models.setProvider(testProvider({ id: "p1" }));
|
||||
|
|
|
|||
|
|
@ -34,12 +34,12 @@ vi.mock("openai", () => {
|
|||
return { default: FakeOpenAI };
|
||||
});
|
||||
|
||||
function openRouterModel(): Model<"openai-completions"> {
|
||||
function openRouterModel(provider = "openrouter"): Model<"openai-completions"> {
|
||||
return {
|
||||
id: "openrouter/auto",
|
||||
name: "OpenRouter Auto",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
provider,
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
|
|
@ -91,4 +91,65 @@ describe("openai-completions provider stream events", () => {
|
|||
expect(message.content).toEqual([{ type: "text", text: "hello" }]);
|
||||
expect(events).toEqual([firstChunk, finalChunk]);
|
||||
});
|
||||
|
||||
// Regression test for #9980.
|
||||
it("uses OpenRouter's reported total without double-counting non-BYOK upstream cost", async () => {
|
||||
mockState.chunks = [
|
||||
{
|
||||
id: "chatcmpl-cost",
|
||||
model: "openrouter/auto",
|
||||
choices: [{ index: 0, delta: { content: "done" }, finish_reason: "stop" }],
|
||||
// These billing fields were captured from a production OpenRouter stream
|
||||
// even though the OpenAI SDK omits them from CompletionUsage.
|
||||
usage: {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 20,
|
||||
cost: 0.004,
|
||||
is_byok: false,
|
||||
cost_details: { upstream_inference_cost: 0.003 },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const message = await completeSimple(
|
||||
openRouterModel("openrouter-work"),
|
||||
{ messages: [{ role: "user", content: "hi", timestamp: Date.now() }] },
|
||||
{ apiKey: "test" },
|
||||
);
|
||||
|
||||
expect(message.usage.cost).toEqual({
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
total: 0.004,
|
||||
totalFromProvider: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("includes upstream inference cost for OpenRouter BYOK requests", async () => {
|
||||
mockState.chunks = [
|
||||
{
|
||||
id: "chatcmpl-byok",
|
||||
model: "openrouter/auto",
|
||||
choices: [{ index: 0, delta: { content: "done" }, finish_reason: "stop" }],
|
||||
usage: {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 20,
|
||||
cost: 0.0002,
|
||||
is_byok: true,
|
||||
cost_details: { upstream_inference_cost: 0.003 },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const message = await completeSimple(
|
||||
openRouterModel(),
|
||||
{ messages: [{ role: "user", content: "hi", timestamp: Date.now() }] },
|
||||
{ apiKey: "test" },
|
||||
);
|
||||
|
||||
expect(message.usage.cost.total).toBeCloseTo(0.0032, 10);
|
||||
expect(message.usage.cost.totalFromProvider).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
78
packages/ai/test/openrouter-cost.test.ts
Normal file
78
packages/ai/test/openrouter-cost.test.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { Usage } from "../src/types.ts";
|
||||
import { applyOpenRouterReportedCost, isOpenRouterModel } from "../src/utils/openrouter-cost.ts";
|
||||
|
||||
function usage(total = 1): Usage {
|
||||
return {
|
||||
input: 1,
|
||||
output: 1,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 2,
|
||||
cost: { input: 0.25, output: 0.75, cacheRead: 0, cacheWrite: 0, total },
|
||||
};
|
||||
}
|
||||
|
||||
describe("isOpenRouterModel", () => {
|
||||
it("recognizes both the built-in provider and custom providers using OpenRouter's endpoint", () => {
|
||||
expect(isOpenRouterModel({ provider: "openrouter", baseUrl: "https://example.com" })).toBe(true);
|
||||
expect(isOpenRouterModel({ provider: "openrouter-work", baseUrl: "https://openrouter.ai/api/v1" })).toBe(true);
|
||||
expect(isOpenRouterModel({ provider: "custom", baseUrl: "https://example.com/v1" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyOpenRouterReportedCost", () => {
|
||||
it("uses OpenRouter's reported cost for non-BYOK requests", () => {
|
||||
const value = usage();
|
||||
applyOpenRouterReportedCost(value, {
|
||||
cost: 0,
|
||||
is_byok: false,
|
||||
cost_details: {
|
||||
upstream_inference_cost: 0,
|
||||
upstream_inference_prompt_cost: 0,
|
||||
upstream_inference_completions_cost: 0,
|
||||
},
|
||||
});
|
||||
expect(value.cost.total).toBe(0);
|
||||
expect(value.cost.totalFromProvider).toBe(true);
|
||||
});
|
||||
|
||||
it("adds the upstream inference cost for BYOK requests", () => {
|
||||
const value = usage();
|
||||
applyOpenRouterReportedCost(value, {
|
||||
cost: 0.1,
|
||||
is_byok: true,
|
||||
cost_details: {
|
||||
upstream_inference_cost: 0.9,
|
||||
upstream_inference_prompt_cost: 0.6,
|
||||
upstream_inference_completions_cost: 0.3,
|
||||
},
|
||||
});
|
||||
expect(value.cost.total).toBe(1);
|
||||
expect(value.cost.totalFromProvider).toBe(true);
|
||||
});
|
||||
|
||||
it("uses upstream cost components when OpenRouter omits their total", () => {
|
||||
const value = usage();
|
||||
applyOpenRouterReportedCost(value, {
|
||||
cost: 0.1,
|
||||
is_byok: true,
|
||||
cost_details: {
|
||||
upstream_inference_cost: null,
|
||||
upstream_inference_prompt_cost: 0.6,
|
||||
upstream_inference_completions_cost: 0.3,
|
||||
},
|
||||
});
|
||||
expect(value.cost.total).toBeCloseTo(1);
|
||||
expect(value.cost.totalFromProvider).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the catalog total when OpenRouter's accounting is incomplete", () => {
|
||||
const value = usage();
|
||||
value.cost.totalFromProvider = false;
|
||||
applyOpenRouterReportedCost(value, { cost: null, is_byok: false, cost_details: null });
|
||||
applyOpenRouterReportedCost(value, { cost: 0.1, is_byok: true, cost_details: null });
|
||||
expect(value.cost.total).toBe(1);
|
||||
expect(value.cost.totalFromProvider).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { Type } from "typebox";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { getModel, streamSimple } from "../src/compat.ts";
|
||||
import type { Api, Context, Message, Model, SystemMessage, Tool } from "../src/types.ts";
|
||||
import type { Api, AssistantMessage, Context, Message, Model, SystemMessage, Tool } from "../src/types.ts";
|
||||
import { resolveTranscriptTools } from "../src/utils/transcript.ts";
|
||||
|
||||
class PayloadCaptured extends Error {}
|
||||
|
|
@ -244,6 +244,50 @@ describe("transcript system messages", () => {
|
|||
expect(payload.messages.at(-1)?.content.map((block) => block.type)).toEqual(["text"]);
|
||||
});
|
||||
|
||||
// #11091
|
||||
test("holds an Anthropic update past an assistant message that converts to no blocks", async () => {
|
||||
const reply = (text: string, provider: string, api: Api, model: string): AssistantMessage => ({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
api,
|
||||
provider,
|
||||
model,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: 3,
|
||||
});
|
||||
const messages: Message[] = [
|
||||
{ role: "system", content: "base prompt", timestamp: 0 },
|
||||
{ role: "user", content: "hi", timestamp: 1 },
|
||||
{ role: "system", content: "updated guidance", timestamp: 2 },
|
||||
// Another model's empty reply: Anthropic rejects empty text blocks, so it is dropped.
|
||||
reply("", "openai-codex", "openai-codex-responses", "gpt-x"),
|
||||
{ role: "user", content: "again", timestamp: 4 },
|
||||
];
|
||||
const roles = (payload: AnthropicPayload) => payload.messages.map((message) => message.role);
|
||||
|
||||
// No later assistant: the update goes at the end.
|
||||
const atEnd = await capturePayload<AnthropicPayload>(anthropicNativeModel, { messages });
|
||||
expect(roles(atEnd)).toEqual(["user", "user", "system"]);
|
||||
|
||||
// A later assistant that is sent: the update goes directly before it.
|
||||
const beforeAssistant = await capturePayload<AnthropicPayload>(anthropicNativeModel, {
|
||||
messages: [
|
||||
...messages,
|
||||
reply("answer", anthropicNativeModel.provider, anthropicNativeModel.api, anthropicNativeModel.id),
|
||||
{ role: "user", content: "third", timestamp: 5 },
|
||||
],
|
||||
});
|
||||
expect(roles(beforeAssistant)).toEqual(["user", "user", "system", "assistant", "user"]);
|
||||
});
|
||||
|
||||
test("folds Anthropic updates into the system prompt without native support", async () => {
|
||||
const model: Model<"anthropic-messages"> = {
|
||||
...modelBase,
|
||||
|
|
|
|||
|
|
@ -120,17 +120,160 @@ describe("TypeSafe System One", () => {
|
|||
expect(result.errorMessage).toContain("Unsupported classifier API: cloudflare-workers-ai-system-one");
|
||||
});
|
||||
|
||||
it("rejects image input before sending", async () => {
|
||||
it("rejects image input for text-only models before sending", async () => {
|
||||
const fetch = vi.fn(async () => Response.json({ answers: wireAnswers }));
|
||||
const result = await classify(
|
||||
{ ...model, input: ["text", "image"] },
|
||||
model,
|
||||
{ ...context, images: [{ type: "image", data: "aW1hZ2U=", mimeType: "image/png" }] },
|
||||
{ apiKey: "secret", fetch },
|
||||
);
|
||||
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(result.errorMessage).toBe("System One API does not support image input");
|
||||
expect(result.errorMessage).toBe("System One API does not support image input for jev-latest");
|
||||
});
|
||||
|
||||
it("sends images to TypeSafe as assets with the opt-in header", async () => {
|
||||
const requests: Array<{ body: Record<string, unknown>; headers: Headers }> = [];
|
||||
const fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
|
||||
requests.push({
|
||||
body: JSON.parse(String(init?.body)) as Record<string, unknown>,
|
||||
headers: new Headers(init?.headers),
|
||||
});
|
||||
return Response.json({ answers: wireAnswers });
|
||||
});
|
||||
const imageModel = { ...model, input: ["text", "image"] as ("text" | "image")[] };
|
||||
|
||||
const result = await classify(
|
||||
imageModel,
|
||||
{
|
||||
...context,
|
||||
images: [
|
||||
{ type: "image", data: "cG5n", mimeType: "image/png" },
|
||||
{ type: "image", data: "anBlZw==", mimeType: "image/jpeg" },
|
||||
],
|
||||
},
|
||||
{ apiKey: "secret", fetch },
|
||||
);
|
||||
const withoutImages = await classify(imageModel, context, { apiKey: "secret", fetch });
|
||||
|
||||
expect(result.stopReason).toBe("stop");
|
||||
expect(requests[0]?.body).toMatchObject({
|
||||
state: ["<image-1>", "<image-2>", context.state],
|
||||
assets: {
|
||||
"<image-1>": { type: "image", format: "png", data_base64: "cG5n" },
|
||||
"<image-2>": { type: "image", format: "jpeg", data_base64: "anBlZw==" },
|
||||
},
|
||||
});
|
||||
expect(requests[0]?.headers.get("typesafe-danger-zone-images")).toBe("true");
|
||||
// Requests without images keep the plain state and send no opt-in header.
|
||||
expect(withoutImages.stopReason).toBe("stop");
|
||||
expect(requests[1]?.body.state).toEqual(context.state);
|
||||
expect(requests[1]?.body).not.toHaveProperty("assets");
|
||||
expect(requests[1]?.headers.has("typesafe-danger-zone-images")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects image formats TypeSafe does not accept before sending", async () => {
|
||||
const fetch = vi.fn(async () => Response.json({ answers: wireAnswers }));
|
||||
const result = await classify(
|
||||
{ ...model, input: ["text", "image"] },
|
||||
{ ...context, images: [{ type: "image", data: "d2VicA==", mimeType: "image/webp" }] },
|
||||
{ apiKey: "secret", fetch },
|
||||
);
|
||||
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(result.errorMessage).toBe("System One API accepts PNG and JPEG images for jev-latest, got image/webp");
|
||||
});
|
||||
|
||||
it("sends images to OpenRouter as image_url parts in the state", async () => {
|
||||
const requests: Array<{ body: Record<string, unknown>; headers: Headers }> = [];
|
||||
const fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
|
||||
requests.push({
|
||||
body: JSON.parse(String(init?.body)) as Record<string, unknown>,
|
||||
headers: new Headers(init?.headers),
|
||||
});
|
||||
return Response.json({ answers: wireAnswers });
|
||||
});
|
||||
const openRouterModel: ClassifierModel<"typesafe-system-one"> = {
|
||||
...model,
|
||||
id: "typesafe/jev-1.14",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
};
|
||||
|
||||
const result = await classify(
|
||||
openRouterModel,
|
||||
{ ...context, images: [{ type: "image", data: "cG5n", mimeType: "image/png" }] },
|
||||
{ apiKey: "secret", fetch },
|
||||
);
|
||||
|
||||
expect(result.stopReason).toBe("stop");
|
||||
// OpenRouter answers 200 for `assets` but drops them, and rejects a top-level `images` list.
|
||||
expect(requests[0]?.body).toEqual({
|
||||
model: "typesafe/jev-1.14",
|
||||
state: [{ type: "image_url", image_url: { url: "data:image/png;base64,cG5n" } }, context.state],
|
||||
questions: expect.any(Object),
|
||||
});
|
||||
expect(requests[0]?.headers.has("typesafe-danger-zone-images")).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["OpenRouter behind a proxy", { provider: "openrouter", baseUrl: "https://proxy.example.com/v1" }],
|
||||
["a regional OpenRouter endpoint", { provider: "custom", baseUrl: "https://us.openrouter.ai/api/v1" }],
|
||||
[
|
||||
"a proxy declared through compat",
|
||||
{
|
||||
provider: "custom",
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
compat: { imageInput: "state-image-urls" as const, sendImageOptInHeader: false },
|
||||
},
|
||||
],
|
||||
])("sends OpenRouter's image form to %s", async (_name, overrides) => {
|
||||
const requests: Array<{ body: Record<string, unknown>; headers: Headers }> = [];
|
||||
const fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
|
||||
requests.push({
|
||||
body: JSON.parse(String(init?.body)) as Record<string, unknown>,
|
||||
headers: new Headers(init?.headers),
|
||||
});
|
||||
return Response.json({ answers: wireAnswers });
|
||||
});
|
||||
|
||||
const result = await classify(
|
||||
{ ...model, id: "typesafe/jev-1.14", input: ["text", "image"], ...overrides },
|
||||
{ ...context, images: [{ type: "image", data: "cG5n", mimeType: "image/png" }] },
|
||||
{ apiKey: "secret", fetch },
|
||||
);
|
||||
|
||||
expect(result.stopReason).toBe("stop");
|
||||
expect(requests[0]?.body.state).toEqual([
|
||||
{ type: "image_url", image_url: { url: "data:image/png;base64,cG5n" } },
|
||||
context.state,
|
||||
]);
|
||||
expect(requests[0]?.body).not.toHaveProperty("assets");
|
||||
expect(requests[0]?.headers.has("typesafe-danger-zone-images")).toBe(false);
|
||||
});
|
||||
|
||||
it("lets compat override detected image settings per field", async () => {
|
||||
const requests: Array<{ body: Record<string, unknown>; headers: Headers }> = [];
|
||||
const fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
|
||||
requests.push({
|
||||
body: JSON.parse(String(init?.body)) as Record<string, unknown>,
|
||||
headers: new Headers(init?.headers),
|
||||
});
|
||||
return Response.json({ answers: wireAnswers });
|
||||
});
|
||||
|
||||
// Only the header is overridden; the detected `assets` form stays.
|
||||
await classify(
|
||||
{ ...model, input: ["text", "image"], compat: { sendImageOptInHeader: false } },
|
||||
{ ...context, images: [{ type: "image", data: "cG5n", mimeType: "image/png" }] },
|
||||
{ apiKey: "secret", fetch },
|
||||
);
|
||||
|
||||
expect(requests[0]?.body).toHaveProperty("assets");
|
||||
expect(requests[0]?.headers.has("typesafe-danger-zone-images")).toBe(false);
|
||||
});
|
||||
|
||||
it("merges headers case-insensitively and supports null suppression", async () => {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,18 @@
|
|||
|
||||
### Breaking Changes
|
||||
|
||||
- `traceIn(options, fn, context)` takes the context last (was `traceIn(context, options, fn)`), like the rest of the API.
|
||||
|
||||
### Added
|
||||
|
||||
- `WatchHandle<T>` and `WatchEnd`, the one watch API, moved from Durable: a serialized exact-frame observation with `value`, `start(listener)`, `stop()`, and `closed`. `WatchEnd` reasons are `stopped`, `cancelled`, `retired`, `ended`, `listener_error`, and `failed`.
|
||||
- `Watch<T>`, the one watch handle implementation, moved from Durable: a source creates one per watch and feeds it with `advance(value, ops, context)`, `retire(context)` (last frame `[["r", null]]`, then `retired`), `end()` or `end({ error })`, and `observeCancellation(signal)`. More than 100 undelivered frames collapse to one root replacement `[["r", newest]]`; termination drops the listener and every reference into the source.
|
||||
- `NativeWatchHandle<T>` and its implementation `NativeWatch<T>`: the same lifecycle for in-process values, with listener `(value, context)` and no ops; an overflow collapses to the newest value.
|
||||
|
||||
## [1.4.0] - 2026-10-21
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Custom `Context` implementations must provide `telemetry: TelemetrySpan`; use the no-op span `NOOP_TELEMETRY_CONTEXT` when disabled. `withTelemetry()` accepts spans, not adapters.
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ Contracts are runtime values described with [TypeBox](https://github.com/sinclai
|
|||
|
||||
| Import | Contents | Runs in |
|
||||
|--------|----------|---------|
|
||||
| `@earendil-works/chord` | Contracts (`local`, `remote`, `method`, `opaque`), declarations (through the factory's `self`: `state`, `derive`, `resource`, `on`, `task`), hosts (`createHost`, `defineFacet`, `defineRegistry`), links (`memoryLinkPair`, `ipcLink`, `webSocketLink`, `acceptWebSockets`), errors (`ChordError`, `isChordError`, `forbidden`, `invalid`). Also the shared base: `Context`, `JsonValue`, `JsonRepresentation`, `Draft`, `copyJson`, `isJsonValue`, and `replicatedState` with its types. | Node and browsers |
|
||||
| `@earendil-works/chord` | Contracts (`local`, `remote`, `method`, `opaque`), declarations (through the factory's `self`: `state`, `derive`, `resource`, `on`, `task`), hosts (`createHost`, `defineFacet`, `defineRegistry`), links (`memoryLinkPair`, `ipcLink`, `webSocketLink`, `acceptWebSockets`), errors (`ChordError`, `isChordError`, `forbidden`, `invalid`). Also the shared base: `Context`, `JsonValue`, `JsonRepresentation`, `Draft`, `copyJson`, `isJsonValue`, `replicatedState` with its types, and the watch types `WatchHandle`, `NativeWatchHandle`, and `WatchEnd` with their implementations `Watch` and `NativeWatch`. | Node and browsers |
|
||||
| `@earendil-works/chord/node` | Child host processes: `createHostManager`, `forkDriver`, `childControl`, `processLock`; the facet module loader `nodeLoader`. | Node |
|
||||
| `@earendil-works/chord/context` | Explicit cancellation, telemetry, and invocation values: `BACKGROUND_CONTEXT`, `withCancel`, `withAbortSignal`, `withoutAbortSignal`, `withTelemetry`, `traceIn`, `withContextValue`, `awaitWithContext`. | Node and browsers |
|
||||
| `@earendil-works/chord/delta` | The transactional JSON tracker, structural diff, immutable apply, and the wire encoder/decoder for operation batches. | Node and browsers |
|
||||
|
|
@ -81,14 +81,18 @@ import { InMemoryTelemetryContext } from '@earendil-works/pi-telemetry';
|
|||
const telemetry = new InMemoryTelemetryContext();
|
||||
await telemetry.startSpan({ name: 'app.request' }, async (span) => {
|
||||
const context = withTelemetry(span, BACKGROUND_CONTEXT);
|
||||
await traceIn(context, { name: 'app.load' }, async (child) => {
|
||||
child.telemetry.addEvent('cache.miss');
|
||||
return traceIn(child, { name: 'app.read' }, (nested) => readAccount(nested));
|
||||
});
|
||||
await traceIn(
|
||||
{ name: 'app.load' },
|
||||
async (child) => {
|
||||
child.telemetry.addEvent('cache.miss');
|
||||
return traceIn({ name: 'app.read' }, (nested) => readAccount(nested), child);
|
||||
},
|
||||
context,
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
`withTelemetry(span, context)` derives a new context; it never changes its parent, cancellation, or typed values. `traceIn(context, options, fn)` creates a callback-managed child span, and `fn` receives its derived context. Every context already supports `telemetry.addEvent()` and `telemetry.addLink()`. Values, cancellation, callback results, and rejection identities are preserved. Concurrent calls keep independent parents; there is no ambient current span. `value()`, `ContextKey`, and `withContextValue()` remain supported.
|
||||
`withTelemetry(span, context)` derives a new context; it never changes its parent, cancellation, or typed values. `traceIn(options, fn, context)` creates a callback-managed child span, and `fn` receives its derived context. Every context already supports `telemetry.addEvent()` and `telemetry.addLink()`. Values, cancellation, callback results, and rejection identities are preserved. Concurrent calls keep independent parents; there is no ambient current span. `value()`, `ContextKey`, and `withContextValue()` remain supported.
|
||||
|
||||
These helpers operate on the standalone `Context` API. They do not yet instrument Chord hosts, methods, or network frames.
|
||||
|
||||
|
|
@ -152,8 +156,7 @@ their containers with the provider; mutating a consumed value violates the contr
|
|||
and can corrupt authority. Clone or serialize at any mutable trust boundary.
|
||||
|
||||
Replicated state provides the same model through a callback. `replicatedState` is a
|
||||
standalone observable value, independent of hosts; Durable uses it for document,
|
||||
view, and task-graph states:
|
||||
standalone observable value, independent of hosts:
|
||||
|
||||
```ts
|
||||
import { replicatedState } from "@earendil-works/chord";
|
||||
|
|
@ -200,3 +203,30 @@ running callback.
|
|||
|
||||
Cells of Chord objects and their remote readables have their own subscription
|
||||
model; see [State](docs/guide.md#state) in the usage guide.
|
||||
|
||||
### Watches
|
||||
|
||||
`WatchHandle<T>` is the one watch interface, implemented by watch sources such as
|
||||
Durable's documents, conversation views, and task graph. `watch.value` is the value
|
||||
at acquisition until `start(listener)` installs the sole asynchronous listener; each
|
||||
later change arrives as `(value, ops, context)`, one call at a time, and `value`
|
||||
advances to it. A source keeps at most 100 undelivered frames and then replaces them
|
||||
with one root replacement `[["r", newest]]`. `stop()` is idempotent, drops pending
|
||||
frames, and neither aborts nor awaits a running call; it and `closed` give the
|
||||
`WatchEnd`:
|
||||
|
||||
- `stopped`: `stop()` was called;
|
||||
- `cancelled`: the context the watch was acquired with was cancelled;
|
||||
- `retired`: the source was retired, an explicit event after a last frame `[["r", null]]`;
|
||||
- `ended`: the source closed (its owner ended or closed);
|
||||
- `listener_error` with `error`: the listener threw or rejected;
|
||||
- `failed` with `error`: the source failed.
|
||||
|
||||
An ended watch drops every reference into its source, so a stopped watch that is
|
||||
still referenced keeps only its last value alive.
|
||||
|
||||
Sources implement handles with `Watch<T>`, fed through `advance(value, ops, context)`,
|
||||
`retire(context)`, `end()` or `end({ error })`, and `observeCancellation(signal)`.
|
||||
`NativeWatch<T>` implements `NativeWatchHandle<T>`, the same lifecycle for
|
||||
in-process values: the listener gets `(value, context)` without ops, and an
|
||||
overflow collapses to the newest value.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@earendil-works/chord",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"description": "Typed host runtime for plugins, contracts, replicated state, and RPC over JSON links",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
|
@ -85,13 +85,13 @@
|
|||
"node": ">=22.19.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-telemetry": "1.3.0",
|
||||
"@earendil-works/pi-telemetry": "1.4.0",
|
||||
"typebox": "1.3.27"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "8.18.2",
|
||||
"shx": "0.3.4",
|
||||
"vitest": "4.1.11",
|
||||
"vitest": "4.1.12",
|
||||
"ws": "8.21.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,9 +97,9 @@ export function withTelemetry<T extends TelemetrySpan>(
|
|||
|
||||
/** Run a child span with a derived context whose telemetry member is that span. */
|
||||
export function traceIn<T>(
|
||||
context: Context,
|
||||
options: SpanOptions,
|
||||
fn: (context: Context) => T | Promise<T>,
|
||||
context: Context,
|
||||
): Promise<T> {
|
||||
return context.telemetry.startSpan(options, (span) => fn(withTelemetry(span, context)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,5 +78,6 @@ export {
|
|||
replicatedState,
|
||||
} from "./state.ts";
|
||||
export type { Ctx, GetOptions, Readable, RefStatus, Shape, StateEvent } from "./types.ts";
|
||||
export { NativeWatch, type NativeWatchHandle, Watch, type WatchEnd, type WatchHandle } from "./watch.ts";
|
||||
export { acceptWebSockets, type WebSocketAcceptOptions, type WebSocketServerLike } from "./websocket.ts";
|
||||
export { type Frame, PROTOCOL_VERSION } from "./wire.ts";
|
||||
|
|
|
|||
289
packages/chord/src/watch.ts
Normal file
289
packages/chord/src/watch.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
// Watch handles: the watch types and the one implementation of their lifecycle, for JSON and native values.
|
||||
import { type Context, withoutAbortSignal } from "./context/index.ts";
|
||||
import type { Op } from "./delta/index.ts";
|
||||
import type { JsonValue } from "./json.ts";
|
||||
|
||||
/**
|
||||
* Terminal result of one watch.
|
||||
*
|
||||
* - `stopped`: `stop()` was called.
|
||||
* - `cancelled`: the context the watch was acquired with was cancelled.
|
||||
* - `retired`: the source was retired; its last frame was `[["r", null]]`.
|
||||
* - `ended`: the source closed (its owner ended or closed).
|
||||
* - `listener_error`: the listener threw or rejected with `error`.
|
||||
* - `failed`: the source failed with `error`.
|
||||
*/
|
||||
export type WatchEnd =
|
||||
| { readonly reason: "stopped" | "cancelled" | "retired" | "ended" }
|
||||
| { readonly reason: "listener_error"; readonly error: Error }
|
||||
| { readonly reason: "failed"; readonly error: unknown };
|
||||
|
||||
/** Serialized exact-frame observation of an immutable value with bounded pending delivery. */
|
||||
export interface WatchHandle<T> {
|
||||
/** Acquisition revision before start; latest delivered immutable revision afterward. */
|
||||
readonly value: T;
|
||||
/** Install the sole asynchronous listener. Never invokes it inline. */
|
||||
start(listener: (value: T, ops: readonly Op[], context: Context) => Promise<void>): void;
|
||||
/** Idempotently stop future callbacks and return this watch's terminal result. */
|
||||
stop(): Promise<WatchEnd>;
|
||||
/** Settle when the watch terminates; an already-running callback remains caller-owned. */
|
||||
readonly closed: Promise<WatchEnd>;
|
||||
}
|
||||
|
||||
/** Serialized observation of an in-process value: as `WatchHandle`, but a change is a new value without ops. */
|
||||
export interface NativeWatchHandle<T> {
|
||||
/** Acquisition value before start; latest delivered value afterward. */
|
||||
readonly value: T;
|
||||
/** Install the sole asynchronous listener. Never invokes it inline. */
|
||||
start(listener: (value: T, context: Context) => Promise<void>): void;
|
||||
/** Idempotently stop future callbacks and return this watch's terminal result. */
|
||||
stop(): Promise<WatchEnd>;
|
||||
/** Settle when the watch terminates; an already-running callback remains caller-owned. */
|
||||
readonly closed: Promise<WatchEnd>;
|
||||
}
|
||||
|
||||
type WatchListener<T> = (value: T, ops: readonly Op[], context: Context) => Promise<void>;
|
||||
|
||||
type NativeWatchListener<T> = (value: T, context: Context) => Promise<void>;
|
||||
|
||||
type WatchFrame<T> = {
|
||||
readonly value: T;
|
||||
readonly ops: readonly Op[];
|
||||
readonly context: Context;
|
||||
/** The retirement frame: the watch ends `retired` once it is delivered. */
|
||||
readonly retires?: true;
|
||||
};
|
||||
|
||||
/** Maximum frames retained behind one unavailable watch listener. */
|
||||
const MAX_PENDING_WATCH_FRAMES = 100;
|
||||
|
||||
/**
|
||||
* The lifecycle both handles share: pending frames, serialized delivery, and termination. A source feeds it and ends
|
||||
* it explicitly; termination drops every reference the watch holds into its source, so a stopped watch that is still
|
||||
* referenced keeps nothing but its last value alive. Subclasses supply only how a listener is called and the ops of an
|
||||
* overflow frame.
|
||||
*/
|
||||
abstract class WatchLifecycle<T, L> {
|
||||
#detach: (() => void) | undefined;
|
||||
#report: ((error: unknown) => void) | undefined;
|
||||
#replace: (() => T) | undefined;
|
||||
readonly #closed = Promise.withResolvers<WatchEnd>();
|
||||
readonly #pending: WatchFrame<T>[] = [];
|
||||
#value: T;
|
||||
#listener: L | undefined;
|
||||
#started = false;
|
||||
#scheduled = false;
|
||||
#running = false;
|
||||
#retired = false;
|
||||
/** Set once, by `#terminate()`. */
|
||||
#end: WatchEnd | undefined;
|
||||
#cancellationSignal: AbortSignal | undefined;
|
||||
#cancellationListener: (() => void) | undefined;
|
||||
|
||||
constructor(value: T, detach: () => void, report: (error: unknown) => void, replace?: () => T) {
|
||||
this.#value = value;
|
||||
this.#detach = detach;
|
||||
this.#report = report;
|
||||
this.#replace = replace;
|
||||
}
|
||||
|
||||
get value(): T {
|
||||
return this.#value;
|
||||
}
|
||||
|
||||
get closed(): Promise<WatchEnd> {
|
||||
return this.#closed.promise;
|
||||
}
|
||||
|
||||
start(listener: L): void {
|
||||
if (this.#started) throw new Error("Watch is already started");
|
||||
if (this.#end !== undefined) throw new Error("Watch is stopped");
|
||||
this.#started = true;
|
||||
this.#listener = listener;
|
||||
if (this.#pending.length > 0) this.#schedule();
|
||||
}
|
||||
|
||||
stop(): Promise<WatchEnd> {
|
||||
this.#terminate({ reason: "stopped" });
|
||||
return this.#closed.promise;
|
||||
}
|
||||
|
||||
/** End the watch `cancelled` when `signal` aborts; installed once. */
|
||||
observeCancellation(signal: AbortSignal): void {
|
||||
if (this.#cancellationSignal !== undefined) throw new Error("Watch cancellation is already installed");
|
||||
if (this.#end !== undefined) return;
|
||||
this.#cancellationSignal = signal;
|
||||
this.#cancellationListener = () => this.cancel();
|
||||
signal.addEventListener("abort", this.#cancellationListener, { once: true });
|
||||
if (signal.aborted) this.cancel();
|
||||
}
|
||||
|
||||
/** End the watch `cancelled`: its acquisition context was cancelled. */
|
||||
cancel(): void {
|
||||
this.#terminate({ reason: "cancelled" });
|
||||
}
|
||||
|
||||
/** The source closed: end with `ended`, or with `failed` and the error when the source failed. */
|
||||
end(failure?: { readonly error: unknown }): void {
|
||||
this.#terminate(failure === undefined ? { reason: "ended" } : { reason: "failed", error: failure.error });
|
||||
}
|
||||
|
||||
/** Call `listener` with one frame. */
|
||||
protected abstract invoke(listener: L, frame: WatchFrame<T>, context: Context): Promise<void>;
|
||||
|
||||
/** The ops of the frame an overflow delivers instead of the backlog, whose value is `value`. */
|
||||
protected abstract overflowOps(value: T): readonly Op[];
|
||||
|
||||
/** Queue one frame; past the bound the backlog collapses to one frame of `replace()` or the newest value. */
|
||||
protected enqueue(value: T, ops: readonly Op[], context: Context): void {
|
||||
if (this.#end !== undefined || this.#retired) return;
|
||||
if (this.#pending.length >= MAX_PENDING_WATCH_FRAMES) {
|
||||
this.#pending.length = 0;
|
||||
const replacement = this.#replace?.() ?? value;
|
||||
this.#pending.push({ value: replacement, ops: this.overflowOps(replacement), context });
|
||||
} else {
|
||||
this.#pending.push({ value, ops, context });
|
||||
}
|
||||
if (this.#started) this.#schedule();
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue the last frame, after which the watch ends `retired`. An overflowing backlog is dropped, since the frame
|
||||
* replaces the root anyway.
|
||||
*/
|
||||
protected enqueueRetirement(value: T, ops: readonly Op[], context: Context): void {
|
||||
if (this.#end !== undefined || this.#retired) return;
|
||||
this.#retired = true;
|
||||
if (this.#pending.length >= MAX_PENDING_WATCH_FRAMES) this.#pending.length = 0;
|
||||
this.#pending.push({ value, ops, context, retires: true });
|
||||
if (this.#started) this.#schedule();
|
||||
}
|
||||
|
||||
#schedule(): void {
|
||||
if (this.#scheduled || this.#running || this.#end !== undefined) return;
|
||||
this.#scheduled = true;
|
||||
queueMicrotask(() => {
|
||||
this.#scheduled = false;
|
||||
void this.#drain();
|
||||
});
|
||||
}
|
||||
|
||||
async #drain(): Promise<void> {
|
||||
if (this.#running || this.#end !== undefined || !this.#started) return;
|
||||
this.#running = true;
|
||||
try {
|
||||
while (this.#end === undefined) {
|
||||
const frame = this.#pending.shift();
|
||||
const listener = this.#listener;
|
||||
if (frame === undefined || listener === undefined) break;
|
||||
this.#value = frame.value;
|
||||
const deliveryContext = withoutAbortSignal(frame.context);
|
||||
try {
|
||||
await this.invoke(listener, frame, deliveryContext);
|
||||
} catch (error) {
|
||||
if (this.#end === undefined) {
|
||||
this.#report?.(error);
|
||||
this.#terminate({ reason: "listener_error", error: toError(error) });
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (frame.retires === true) {
|
||||
this.#terminate({ reason: "retired" });
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.#running = false;
|
||||
if (this.#end === undefined && this.#pending.length > 0) this.#schedule();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* End the watch, once: detach, drop pending frames and every reference into the source, and settle `closed`. A
|
||||
* callback already running keeps running.
|
||||
*/
|
||||
#terminate(end: WatchEnd): void {
|
||||
if (this.#end !== undefined) return;
|
||||
this.#end = end;
|
||||
const detach = this.#detach;
|
||||
this.#detach = undefined;
|
||||
this.#report = undefined;
|
||||
this.#replace = undefined;
|
||||
this.#listener = undefined;
|
||||
this.#pending.length = 0;
|
||||
if (this.#cancellationListener !== undefined) {
|
||||
this.#cancellationSignal?.removeEventListener("abort", this.#cancellationListener);
|
||||
}
|
||||
this.#cancellationSignal = undefined;
|
||||
this.#cancellationListener = undefined;
|
||||
detach?.();
|
||||
this.#closed.resolve(end);
|
||||
}
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
if (error instanceof Error) return error;
|
||||
try {
|
||||
return new Error(String(error));
|
||||
} catch {
|
||||
return new Error(Object.prototype.toString.call(error));
|
||||
}
|
||||
}
|
||||
|
||||
/** Canonical terminal frame of a retired source. */
|
||||
const RETIREMENT_OPERATIONS: readonly Op[] = [["r", null]];
|
||||
|
||||
/**
|
||||
* The watch handle of a JSON source: exact frames in order, an overflow becomes one root replacement `[["r", newest]]`.
|
||||
* `detach` unsubscribes it from its source on termination; `report` gets a listener's throw, which also ends the
|
||||
* watch; `replace` gives the value an overflow delivers, by default the newest value.
|
||||
*/
|
||||
export class Watch<T> extends WatchLifecycle<T, WatchListener<T>> implements WatchHandle<T> {
|
||||
/** Queue the frame `ops`, which turn the previous value into `value`. */
|
||||
advance(value: T, ops: readonly Op[], context: Context): void {
|
||||
this.enqueue(value, ops, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* The source retired: queue the last frame, `[["r", null]]` with value `null`, then end `retired`. Typed for watches
|
||||
* whose values include `null`.
|
||||
*/
|
||||
retire(this: Watch<T | null>, context: Context): void {
|
||||
this.enqueueRetirement(null, RETIREMENT_OPERATIONS, context);
|
||||
}
|
||||
|
||||
protected invoke(listener: WatchListener<T>, frame: WatchFrame<T>, context: Context): Promise<void> {
|
||||
return listener(frame.value, frame.ops, context);
|
||||
}
|
||||
|
||||
protected overflowOps(value: T): readonly Op[] {
|
||||
// Values of a JSON watch are JSON; `T` is the source's narrower or wider view of them (e.g. a typed document).
|
||||
return [["r", value as JsonValue]];
|
||||
}
|
||||
}
|
||||
|
||||
const NO_OPS: readonly Op[] = [];
|
||||
|
||||
/**
|
||||
* The watch handle of a native source: values in order, an overflow collapses to the newest value. `detach` and
|
||||
* `report` as for `Watch`.
|
||||
*/
|
||||
export class NativeWatch<T> extends WatchLifecycle<T, NativeWatchListener<T>> implements NativeWatchHandle<T> {
|
||||
constructor(value: T, detach: () => void, report: (error: unknown) => void) {
|
||||
// No replacement: an overflow delivers the newest value.
|
||||
super(value, detach, report, undefined);
|
||||
}
|
||||
|
||||
/** Queue `value`, the source's new value. */
|
||||
advance(value: T, context: Context): void {
|
||||
this.enqueue(value, NO_OPS, context);
|
||||
}
|
||||
|
||||
protected invoke(listener: NativeWatchListener<T>, frame: WatchFrame<T>, context: Context): Promise<void> {
|
||||
return listener(frame.value, context);
|
||||
}
|
||||
|
||||
protected overflowOps(): readonly Op[] {
|
||||
return NO_OPS;
|
||||
}
|
||||
}
|
||||
|
|
@ -91,15 +91,23 @@ describe("Context", () => {
|
|||
expect(replaced.value(key)).toBe("owner");
|
||||
expect(replaced.abortSignal).toBe(base.abortSignal);
|
||||
expect(withoutAbortSignal(context).telemetry).toBe(parent);
|
||||
await traceIn(context, { name: "outer" }, async (child) => {
|
||||
expect(child.value(key)).toBe("owner");
|
||||
expect(child.abortSignal).toBe(context.abortSignal);
|
||||
child.telemetry.addEvent("started");
|
||||
await traceIn(child, { name: "inner" }, (nested) => {
|
||||
nested.telemetry.addLink({ context: child.telemetry });
|
||||
return 42;
|
||||
});
|
||||
});
|
||||
await traceIn(
|
||||
{ name: "outer" },
|
||||
async (child) => {
|
||||
expect(child.value(key)).toBe("owner");
|
||||
expect(child.abortSignal).toBe(context.abortSignal);
|
||||
child.telemetry.addEvent("started");
|
||||
await traceIn(
|
||||
{ name: "inner" },
|
||||
(nested) => {
|
||||
nested.telemetry.addLink({ context: child.telemetry });
|
||||
return 42;
|
||||
},
|
||||
child,
|
||||
);
|
||||
},
|
||||
context,
|
||||
);
|
||||
const [caller, outer, inner] = telemetry.getSpans();
|
||||
expect(outer?.parentId).toBe(caller?.id);
|
||||
expect(inner?.parentId).toBe(outer?.id);
|
||||
|
|
@ -119,15 +127,23 @@ describe("Context", () => {
|
|||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const first = traceIn(context, { name: "first" }, async (child) => {
|
||||
await gate;
|
||||
return traceIn(child, { name: "first.child" }, () => "first");
|
||||
});
|
||||
const second = traceIn(context, { name: "second" }, async (child) => {
|
||||
const result = await traceIn(child, { name: "second.child" }, () => "second");
|
||||
release?.();
|
||||
return result;
|
||||
});
|
||||
const first = traceIn(
|
||||
{ name: "first" },
|
||||
async (child) => {
|
||||
await gate;
|
||||
return traceIn({ name: "first.child" }, () => "first", child);
|
||||
},
|
||||
context,
|
||||
);
|
||||
const second = traceIn(
|
||||
{ name: "second" },
|
||||
async (child) => {
|
||||
const result = await traceIn({ name: "second.child" }, () => "second", child);
|
||||
release?.();
|
||||
return result;
|
||||
},
|
||||
context,
|
||||
);
|
||||
expect(await Promise.all([first, second])).toEqual(["first", "second"]);
|
||||
const spans = telemetry.getSpans();
|
||||
for (const name of ["first", "second"]) {
|
||||
|
|
@ -136,16 +152,24 @@ describe("Context", () => {
|
|||
}
|
||||
const failure = { reason: "same value" };
|
||||
await expect(
|
||||
traceIn(context, { name: "throw" }, () => {
|
||||
throw failure;
|
||||
}),
|
||||
traceIn(
|
||||
{ name: "throw" },
|
||||
() => {
|
||||
throw failure;
|
||||
},
|
||||
context,
|
||||
),
|
||||
).rejects.toBe(failure);
|
||||
await expect(traceIn(context, { name: "reject" }, () => Promise.reject(failure))).rejects.toBe(failure);
|
||||
await expect(traceIn({ name: "reject" }, () => Promise.reject(failure), context)).rejects.toBe(failure);
|
||||
expect(
|
||||
await traceIn(BACKGROUND_CONTEXT, { name: "noop" }, (child) => {
|
||||
child.telemetry.addLink({ context: parent });
|
||||
return 42;
|
||||
}),
|
||||
await traceIn(
|
||||
{ name: "noop" },
|
||||
(child) => {
|
||||
child.telemetry.addLink({ context: parent });
|
||||
return 42;
|
||||
},
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).toBe(42);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
490
packages/chord/test/watch.test.ts
Normal file
490
packages/chord/test/watch.test.ts
Normal file
|
|
@ -0,0 +1,490 @@
|
|||
// Tests for Chord's watch handles: the shared lifecycle through `Watch`, and what `NativeWatch` does differently.
|
||||
import { setFlagsFromString } from "node:v8";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { describe, expect, expectTypeOf, it } from "vitest";
|
||||
import {
|
||||
BACKGROUND_CONTEXT,
|
||||
type Context,
|
||||
createContextKey,
|
||||
withAbortSignal,
|
||||
withContextValue,
|
||||
} from "../src/context/index.ts";
|
||||
import type { Op } from "../src/delta/index.ts";
|
||||
import type { JsonValue } from "../src/json.ts";
|
||||
import { NativeWatch, Watch, type WatchEnd } from "../src/watch.ts";
|
||||
|
||||
const context = BACKGROUND_CONTEXT;
|
||||
|
||||
function flush(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function deferred(): { readonly promise: Promise<void>; readonly resolve: () => void } {
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
return { promise, resolve: () => resolve() };
|
||||
}
|
||||
|
||||
/** A watch whose detach and reports are recorded. */
|
||||
function recordedWatch<T>(value: T, replace?: () => T) {
|
||||
const recorded = { detached: 0, reported: [] as unknown[] };
|
||||
const watch = new Watch<T>(
|
||||
value,
|
||||
() => recorded.detached++,
|
||||
(error) => recorded.reported.push(error),
|
||||
replace,
|
||||
);
|
||||
return { watch, recorded };
|
||||
}
|
||||
|
||||
/** A started watch collecting its frames. */
|
||||
function collectingWatch<T>(value: T, replace?: () => T) {
|
||||
const { watch, recorded } = recordedWatch(value, replace);
|
||||
const frames: Array<{ value: T; ops: readonly Op[] }> = [];
|
||||
watch.start(async (delivered, ops) => {
|
||||
frames.push({ value: delivered, ops });
|
||||
});
|
||||
return { watch, recorded, frames };
|
||||
}
|
||||
|
||||
/** The end of `closed`, or undefined while the watch is open. */
|
||||
function endOf(watch: { readonly closed: Promise<WatchEnd> }): { readonly end: WatchEnd | undefined } {
|
||||
const settled: { end: WatchEnd | undefined } = { end: undefined };
|
||||
void watch.closed.then((end) => {
|
||||
settled.end = end;
|
||||
});
|
||||
return settled;
|
||||
}
|
||||
|
||||
const collect = (() => {
|
||||
setFlagsFromString("--expose-gc");
|
||||
const gc = runInNewContext("gc") as () => void;
|
||||
return async (): Promise<void> => {
|
||||
for (let round = 0; round < 5; round++) {
|
||||
await flush();
|
||||
gc();
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
describe("Watch delivery", () => {
|
||||
it("keeps the acquisition value until start and delivers exact frames in order", async () => {
|
||||
const { watch } = recordedWatch<JsonValue>({ n: 0 });
|
||||
watch.advance({ n: 1 }, [["s", ["n"], 1]], context);
|
||||
watch.advance({ n: 2 }, [["s", ["n"], 2]], context);
|
||||
await flush();
|
||||
expect(watch.value).toEqual({ n: 0 });
|
||||
|
||||
const frames: Array<{ value: JsonValue; ops: readonly Op[] }> = [];
|
||||
watch.start(async (value, ops) => {
|
||||
frames.push({ value, ops });
|
||||
});
|
||||
expect(frames).toEqual([]);
|
||||
await flush();
|
||||
expect(frames).toEqual([
|
||||
{ value: { n: 1 }, ops: [["s", ["n"], 1]] },
|
||||
{ value: { n: 2 }, ops: [["s", ["n"], 2]] },
|
||||
]);
|
||||
expect(watch.value).toEqual({ n: 2 });
|
||||
});
|
||||
|
||||
it("never invokes the listener inline", async () => {
|
||||
const { watch, frames } = collectingWatch<JsonValue>(0);
|
||||
watch.advance(1, [["r", 1]], context);
|
||||
expect(frames).toEqual([]);
|
||||
await flush();
|
||||
expect(frames).toEqual([{ value: 1, ops: [["r", 1]] }]);
|
||||
});
|
||||
|
||||
it("awaits each callback and buffers frames advanced while one is in flight", async () => {
|
||||
const { watch } = recordedWatch<JsonValue>(0);
|
||||
const gate = deferred();
|
||||
const calls: string[] = [];
|
||||
watch.start(async (value) => {
|
||||
calls.push(`start ${value}`);
|
||||
if (value === 1) await gate.promise;
|
||||
calls.push(`end ${value}`);
|
||||
});
|
||||
watch.advance(1, [["r", 1]], context);
|
||||
await flush();
|
||||
watch.advance(2, [["r", 2]], context);
|
||||
watch.advance(3, [["r", 3]], context);
|
||||
await flush();
|
||||
expect(calls).toEqual(["start 1"]);
|
||||
gate.resolve();
|
||||
await flush();
|
||||
expect(calls).toEqual(["start 1", "end 1", "start 2", "end 2", "start 3", "end 3"]);
|
||||
});
|
||||
|
||||
it("delivers a context with the frame's values and without its abort signal", async () => {
|
||||
const { watch } = recordedWatch<JsonValue>(0);
|
||||
const key = createContextKey<string>("frame");
|
||||
const controller = new AbortController();
|
||||
const frameContext = withContextValue(key, "first", withAbortSignal(controller.signal, context));
|
||||
let delivered: Context | undefined;
|
||||
watch.start(async (_value, _ops, deliveryContext) => {
|
||||
delivered = deliveryContext;
|
||||
});
|
||||
watch.advance(1, [["r", 1]], frameContext);
|
||||
await flush();
|
||||
expect(delivered?.value(key)).toBe("first");
|
||||
expect(delivered?.abortSignal).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Watch overflow", () => {
|
||||
it("collapses 101 pending frames to one root replacement of the newest value", async () => {
|
||||
const { watch } = recordedWatch<JsonValue>(0);
|
||||
for (let n = 1; n <= 101; n++) watch.advance(n, [["r", n]], context);
|
||||
const frames: Array<{ value: JsonValue; ops: readonly Op[] }> = [];
|
||||
watch.start(async (value, ops) => {
|
||||
frames.push({ value, ops });
|
||||
});
|
||||
await flush();
|
||||
expect(frames).toEqual([{ value: 101, ops: [["r", 101]] }]);
|
||||
});
|
||||
|
||||
it("keeps 100 pending frames exact", async () => {
|
||||
const { watch } = recordedWatch<JsonValue>(0);
|
||||
for (let n = 1; n <= 100; n++) watch.advance(n, [["r", n]], context);
|
||||
const values: JsonValue[] = [];
|
||||
watch.start(async (value) => {
|
||||
values.push(value);
|
||||
});
|
||||
await flush();
|
||||
expect(values).toEqual(Array.from({ length: 100 }, (_, index) => index + 1));
|
||||
});
|
||||
|
||||
it("delivers the value of `replace` on overflow", async () => {
|
||||
const { watch } = recordedWatch<JsonValue>(0, () => "replacement");
|
||||
for (let n = 1; n <= 101; n++) watch.advance(n, [], context);
|
||||
const frames: Array<{ value: JsonValue; ops: readonly Op[] }> = [];
|
||||
watch.start(async (value, ops) => {
|
||||
frames.push({ value, ops });
|
||||
});
|
||||
await flush();
|
||||
expect(frames).toEqual([{ value: "replacement", ops: [["r", "replacement"]] }]);
|
||||
});
|
||||
|
||||
it("never folds the in-flight frame into an overflow", async () => {
|
||||
const { watch } = recordedWatch<JsonValue>(0);
|
||||
const gate = deferred();
|
||||
const values: JsonValue[] = [];
|
||||
watch.start(async (value) => {
|
||||
values.push(value);
|
||||
if (value === 1) await gate.promise;
|
||||
});
|
||||
watch.advance(1, [["r", 1]], context);
|
||||
await flush();
|
||||
for (let n = 2; n <= 102; n++) watch.advance(n, [["r", n]], context);
|
||||
gate.resolve();
|
||||
await flush();
|
||||
expect(values).toEqual([1, 102]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Watch retirement", () => {
|
||||
it('delivers `[["r", null]]` as the last frame and then ends retired', async () => {
|
||||
const { watch, frames, recorded } = collectingWatch<JsonValue>(1);
|
||||
const settled = endOf(watch);
|
||||
watch.advance(2, [["r", 2]], context);
|
||||
watch.retire(context);
|
||||
watch.advance(3, [["r", 3]], context);
|
||||
await flush();
|
||||
expect(frames).toEqual([
|
||||
{ value: 2, ops: [["r", 2]] },
|
||||
{ value: null, ops: [["r", null]] },
|
||||
]);
|
||||
expect(watch.value).toBeNull();
|
||||
expect(settled.end).toEqual({ reason: "retired" });
|
||||
expect(recorded.detached).toBe(1);
|
||||
});
|
||||
|
||||
it("drops an overflowing backlog for the retirement frame", async () => {
|
||||
const { watch } = recordedWatch<JsonValue>(0);
|
||||
for (let n = 1; n <= 100; n++) watch.advance(n, [["r", n]], context);
|
||||
watch.retire(context);
|
||||
const frames: Array<{ value: JsonValue; ops: readonly Op[] }> = [];
|
||||
watch.start(async (value, ops) => {
|
||||
frames.push({ value, ops });
|
||||
});
|
||||
expect(await watch.closed).toEqual({ reason: "retired" });
|
||||
expect(frames).toEqual([{ value: null, ops: [["r", null]] }]);
|
||||
});
|
||||
|
||||
it("ends retired only on its source's retirement, never on a null value", async () => {
|
||||
const { watch, frames } = collectingWatch<JsonValue>(1);
|
||||
const settled = endOf(watch);
|
||||
watch.advance(null, [["r", null]], context);
|
||||
watch.advance(2, [["r", 2]], context);
|
||||
await flush();
|
||||
expect(frames).toEqual([
|
||||
{ value: null, ops: [["r", null]] },
|
||||
{ value: 2, ops: [["r", 2]] },
|
||||
]);
|
||||
expect(settled.end).toBeUndefined();
|
||||
expect(await watch.stop()).toEqual({ reason: "stopped" });
|
||||
});
|
||||
|
||||
it("is only available to watches whose values include null", () => {
|
||||
const check = (numbers: Watch<number>, documents: Watch<{ n: number } | null>) => {
|
||||
// @ts-expect-error `retire` delivers `null`, which a number watch cannot hold.
|
||||
numbers.retire(context);
|
||||
documents.retire(context);
|
||||
};
|
||||
expectTypeOf(check).toBeFunction();
|
||||
});
|
||||
|
||||
it("discards a retirement still pending when the source ends", async () => {
|
||||
const { watch } = recordedWatch<JsonValue>(1);
|
||||
watch.retire(context);
|
||||
watch.end();
|
||||
expect(await watch.closed).toEqual({ reason: "ended" });
|
||||
expect(watch.value).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Watch ends", () => {
|
||||
it("stops idempotently and rejects a repeated or late start", async () => {
|
||||
const { watch, recorded } = recordedWatch<JsonValue>(0);
|
||||
watch.start(async () => {});
|
||||
expect(() => watch.start(async () => {})).toThrow("Watch is already started");
|
||||
expect(await watch.stop()).toEqual({ reason: "stopped" });
|
||||
expect(await watch.stop()).toEqual({ reason: "stopped" });
|
||||
expect(recorded.detached).toBe(1);
|
||||
|
||||
const late = recordedWatch<JsonValue>(0).watch;
|
||||
await late.stop();
|
||||
expect(() => late.start(async () => {})).toThrow("Watch is stopped");
|
||||
});
|
||||
|
||||
it("ends `ended` or `failed` from its source, dropping pending frames", async () => {
|
||||
const { watch, frames } = collectingWatch<JsonValue>(0);
|
||||
watch.advance(1, [["r", 1]], context);
|
||||
watch.end();
|
||||
await flush();
|
||||
expect(frames).toEqual([]);
|
||||
expect(await watch.closed).toEqual({ reason: "ended" });
|
||||
|
||||
const failure = new Error("source failed");
|
||||
const failed = recordedWatch<JsonValue>(0).watch;
|
||||
failed.end({ error: failure });
|
||||
failed.end();
|
||||
expect(await failed.closed).toEqual({ reason: "failed", error: failure });
|
||||
});
|
||||
|
||||
it("stops future delivery without aborting or awaiting an in-flight callback", async () => {
|
||||
const { watch } = recordedWatch<JsonValue>(0);
|
||||
const gate = deferred();
|
||||
const calls: string[] = [];
|
||||
watch.start(async (value) => {
|
||||
calls.push(`start ${value}`);
|
||||
await gate.promise;
|
||||
calls.push(`end ${value}`);
|
||||
});
|
||||
watch.advance(1, [["r", 1]], context);
|
||||
await flush();
|
||||
watch.advance(2, [["r", 2]], context);
|
||||
expect(await watch.stop()).toEqual({ reason: "stopped" });
|
||||
gate.resolve();
|
||||
await flush();
|
||||
expect(calls).toEqual(["start 1", "end 1"]);
|
||||
});
|
||||
|
||||
it("ends `cancelled` when its acquisition signal aborts, also when already aborted", async () => {
|
||||
const controller = new AbortController();
|
||||
const { watch, recorded } = recordedWatch<JsonValue>(0);
|
||||
watch.observeCancellation(controller.signal);
|
||||
expect(() => watch.observeCancellation(controller.signal)).toThrow("Watch cancellation is already installed");
|
||||
controller.abort();
|
||||
expect(await watch.closed).toEqual({ reason: "cancelled" });
|
||||
expect(recorded.detached).toBe(1);
|
||||
|
||||
const aborted = recordedWatch<JsonValue>(0).watch;
|
||||
aborted.observeCancellation(AbortSignal.abort());
|
||||
expect(await aborted.closed).toEqual({ reason: "cancelled" });
|
||||
|
||||
const cancelled = recordedWatch<JsonValue>(0).watch;
|
||||
cancelled.cancel();
|
||||
expect(await cancelled.closed).toEqual({ reason: "cancelled" });
|
||||
});
|
||||
|
||||
it("ends `listener_error` on a throw, reports it, and delivers nothing more", async () => {
|
||||
const { watch, recorded } = recordedWatch<JsonValue>(0);
|
||||
const failure = new Error("listener failed");
|
||||
const values: JsonValue[] = [];
|
||||
watch.start(async (value) => {
|
||||
values.push(value);
|
||||
throw failure;
|
||||
});
|
||||
watch.advance(1, [["r", 1]], context);
|
||||
watch.advance(2, [["r", 2]], context);
|
||||
expect(await watch.closed).toEqual({ reason: "listener_error", error: failure });
|
||||
expect(values).toEqual([1]);
|
||||
expect(recorded.reported).toEqual([failure]);
|
||||
expect(recorded.detached).toBe(1);
|
||||
});
|
||||
|
||||
it("wraps a thrown non-Error in an Error", async () => {
|
||||
const { watch } = recordedWatch<JsonValue>(0);
|
||||
watch.start(() => Promise.reject("plain"));
|
||||
watch.advance(1, [["r", 1]], context);
|
||||
const end = await watch.closed;
|
||||
expect(end.reason).toBe("listener_error");
|
||||
expect(end.reason === "listener_error" && end.error.message).toBe("plain");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Watch references", () => {
|
||||
it("retains neither its source, its listener, nor its signal once ended, while still referenced", async () => {
|
||||
/** A watch over a source only its closures reference; `end` ends it before returning. */
|
||||
const acquire = async (end: (watch: Watch<JsonValue>) => void) => {
|
||||
const source = { value: 1 as JsonValue };
|
||||
const controller = new AbortController();
|
||||
const watch = new Watch<JsonValue>(
|
||||
0,
|
||||
() => void source,
|
||||
() => void source,
|
||||
() => source.value,
|
||||
);
|
||||
const listener = async (): Promise<void> => {};
|
||||
watch.observeCancellation(controller.signal);
|
||||
watch.start(listener);
|
||||
watch.advance(1, [["r", 1]], context);
|
||||
await flush();
|
||||
end(watch);
|
||||
return {
|
||||
watch,
|
||||
source: new WeakRef(source),
|
||||
listener: new WeakRef(listener),
|
||||
signal: new WeakRef(controller.signal),
|
||||
};
|
||||
};
|
||||
|
||||
// Control: an open watch keeps its source, so the collections below are real.
|
||||
const open = await acquire(() => {});
|
||||
await collect();
|
||||
expect(open.source.deref()).toBeDefined();
|
||||
expect(open.listener.deref()).toBeDefined();
|
||||
expect(open.signal.deref()).toBeDefined();
|
||||
await open.watch.stop();
|
||||
|
||||
for (const end of [
|
||||
(watch: Watch<JsonValue>) => void watch.stop(),
|
||||
(watch: Watch<JsonValue>) => watch.cancel(),
|
||||
(watch: Watch<JsonValue>) => watch.end(),
|
||||
(watch: Watch<JsonValue>) => watch.retire(context),
|
||||
]) {
|
||||
const ended = await acquire(end);
|
||||
await flush();
|
||||
await collect();
|
||||
expect(ended.source.deref()).toBeUndefined();
|
||||
expect(ended.listener.deref()).toBeUndefined();
|
||||
expect(ended.signal.deref()).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("NativeWatch", () => {
|
||||
it("delivers values by identity in order with a context, never inline", async () => {
|
||||
const first = { run: () => 1 };
|
||||
const second = { run: () => 2 };
|
||||
const watch = new NativeWatch<{ run: () => number }>(
|
||||
{ run: () => 0 },
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
const key = createContextKey<string>("frame");
|
||||
const delivered: Array<{ value: { run: () => number }; frame: string | undefined }> = [];
|
||||
watch.start(async (value, deliveryContext) => {
|
||||
delivered.push({ value, frame: deliveryContext.value(key) });
|
||||
});
|
||||
watch.advance(first, withContextValue(key, "first", context));
|
||||
watch.advance(second, withContextValue(key, "second", context));
|
||||
expect(delivered).toEqual([]);
|
||||
await flush();
|
||||
expect(delivered).toHaveLength(2);
|
||||
expect(delivered[0].value).toBe(first);
|
||||
expect(delivered[1].value).toBe(second);
|
||||
expect(delivered.map((entry) => entry.frame)).toEqual(["first", "second"]);
|
||||
expect(watch.value).toBe(second);
|
||||
});
|
||||
|
||||
it("collapses 101 pending values to the newest", async () => {
|
||||
const watch = new NativeWatch<number>(
|
||||
0,
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
for (let n = 1; n <= 101; n++) watch.advance(n, context);
|
||||
const values: number[] = [];
|
||||
watch.start(async (value) => {
|
||||
values.push(value);
|
||||
});
|
||||
await flush();
|
||||
expect(values).toEqual([101]);
|
||||
});
|
||||
|
||||
it("ends with every reason of its lifecycle", async () => {
|
||||
const create = () =>
|
||||
new NativeWatch<number>(
|
||||
0,
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
const stopped = create();
|
||||
expect(await stopped.stop()).toEqual({ reason: "stopped" });
|
||||
|
||||
const cancelled = create();
|
||||
cancelled.observeCancellation(AbortSignal.abort());
|
||||
expect(await cancelled.closed).toEqual({ reason: "cancelled" });
|
||||
|
||||
const ended = create();
|
||||
ended.end();
|
||||
expect(await ended.closed).toEqual({ reason: "ended" });
|
||||
|
||||
const error = new Error("source failed");
|
||||
const failed = create();
|
||||
failed.end({ error });
|
||||
expect(await failed.closed).toEqual({ reason: "failed", error });
|
||||
|
||||
const reported: unknown[] = [];
|
||||
const throwing = new NativeWatch<number>(
|
||||
0,
|
||||
() => {},
|
||||
(thrown) => reported.push(thrown),
|
||||
);
|
||||
const failure = new Error("listener failed");
|
||||
throwing.start(() => Promise.reject(failure));
|
||||
throwing.advance(1, context);
|
||||
expect(await throwing.closed).toEqual({ reason: "listener_error", error: failure });
|
||||
expect(reported).toEqual([failure]);
|
||||
});
|
||||
|
||||
it("retains neither its source nor its listener once stopped, while still referenced", async () => {
|
||||
const acquire = async (stop: boolean) => {
|
||||
const source = { value: 1 };
|
||||
const watch = new NativeWatch<number>(
|
||||
0,
|
||||
() => void source,
|
||||
() => void source,
|
||||
);
|
||||
const listener = async (): Promise<void> => {};
|
||||
watch.start(listener);
|
||||
watch.advance(source.value, context);
|
||||
await flush();
|
||||
if (stop) await watch.stop();
|
||||
return { watch, source: new WeakRef(source), listener: new WeakRef(listener) };
|
||||
};
|
||||
|
||||
const open = await acquire(false);
|
||||
await collect();
|
||||
expect(open.source.deref()).toBeDefined();
|
||||
await open.watch.stop();
|
||||
|
||||
const stopped = await acquire(true);
|
||||
await collect();
|
||||
expect(stopped.source.deref()).toBeUndefined();
|
||||
expect(stopped.listener.deref()).toBeUndefined();
|
||||
expect(stopped.watch.value).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.4.0] - 2026-10-21
|
||||
|
||||
## [1.3.0] - 2026-10-15
|
||||
|
||||
### Breaking Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@earendil-works/pi-codemode",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"description": "Sandboxed JavaScript execution where the only capability is calling injected tools",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
|
@ -71,6 +71,6 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"shx": "0.3.4",
|
||||
"vitest": "4.1.11"
|
||||
"vitest": "4.1.12"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,15 +2,37 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- `pi update` for installs from the pi.dev installer no longer passes `--min-release-age=0` to `npm ci`. The release lockfile pins every package, so the flag had no effect, and security scanners that block it stopped the update ([#11262](https://github.com/earendil-works/pi/issues/11262))
|
||||
|
||||
## [1.4.0] - 2026-10-21
|
||||
|
||||
### New Features
|
||||
|
||||
- **Image input for classifiers**: codemode `models.classify()` accepts `images` for TypeSafe's Jev and Cloudflare's Clef models on `typesafe`, `cloudflare-workers-ai`, and OpenRouter. See [Use classifier models](docs/models.md#use-classifier-models) and [Codemode](docs/codemode.md#models).
|
||||
- **Models Configuration reference**: every provider, model, model override, and compatibility field of `models.json`, generated from its schema. See [Models Configuration](docs/models-configuration.md).
|
||||
- **Summaries without a fixed output cap**: compaction and branch summaries use the model's output limit, so long reasoning no longer makes them fail. See [Compaction](docs/compaction.md#compaction).
|
||||
- **Codemode waits for a free worker**: parallel scripts and in-process subagents queue instead of failing, and `PI_CODEMODE_MAX_WORKERS` raises the limit. See [Codemode limits](docs/codemode.md#limits).
|
||||
- **Rex terminal support**: OSC 8 hyperlinks, truecolor, and inline images.
|
||||
|
||||
### Added
|
||||
|
||||
- Added a Models Configuration reference generated from the `models.json` schema, listing all provider, model, model override, and compatibility fields ([#10790](https://github.com/earendil-works/pi/issues/10790))
|
||||
- Codemode `models.classify()` now accepts `images` for TypeSafe's Jev and Cloudflare's Clef models on `typesafe`, `cloudflare-workers-ai`, and OpenRouter, and lists Clef Omni and Jev 1.14 on Workers AI.
|
||||
- Provider-level `compat` in `models.json` now also applies to classifier models, with the System One fields `imageInput` and `sendImageOptInHeader` for proxies that serve another service's classifiers. Extension classifier models accept `compat` too.
|
||||
- Added `MiniMax-M3.1-Flash` to the `minimax` and `minimax-cn` providers, with thinking levels low through max ([#11121](https://github.com/earendil-works/pi/issues/11121))
|
||||
- Added support for the Rex terminal: OSC 8 hyperlinks, truecolor, and inline images ([#11131](https://github.com/earendil-works/pi/issues/11131), [#11150](https://github.com/earendil-works/pi/pull/11150) by [@petrroll](https://github.com/petrroll), [#11183](https://github.com/earendil-works/pi/pull/11183) by [@nihaals](https://github.com/nihaals))
|
||||
|
||||
### Changed
|
||||
|
||||
- Provider attribution headers now come from pi-ai's client identity. Extensions' `before_provider_headers` handlers no longer see the OpenRouter, NVIDIA NIM, and OpenCode attribution headers; returning `null` for one still removes it. Cloudflare requests send pi's detailed `User-Agent` instead of `pi-coding-agent`, as do GitHub release checks for tool downloads, and OpenCode requests send `x-opencode-client` whenever attribution is enabled, not only with a session.
|
||||
- `generateSummary()` and `generateSummaryWithUsage()` now ignore their `reserveTokens` parameter. Summary requests use the model's output limit instead.
|
||||
- Changed the default GitHub Copilot model from `gpt-5.4`, which Copilot retired, to `gpt-6.1-sol`.
|
||||
- Changed the default NVIDIA model to `nvidia/nemotron-3.5-super-vl-120b-a12b` ([#10910](https://github.com/earendil-works/pi/pull/10910) by [@cv](https://github.com/cv))
|
||||
- Changed the default Xiaomi model to `mimo-v2.6-pro` for the `xiaomi` and Xiaomi Token Plan providers, since `mimo-v2.5-pro` left the catalog.
|
||||
- Grok 4.3 and Grok 4.5 on the `xai` provider now offer the `xhigh` thinking level.
|
||||
- Sign in with ChatGPT: ChatGPT Business workspace usage-limit errors are not retried and tell the user to ask their workspace admin to increase the limit.
|
||||
|
||||
### Fixed
|
||||
|
||||
|
|
@ -25,6 +47,20 @@
|
|||
- Fixed extensions failing to load when importing `@earendil-works/pi-ai/models` (e.g. for `clampThinkingLevel`) by exposing the subpath through the extension loader aliases and virtual modules ([#11156](https://github.com/earendil-works/pi/issues/11156))
|
||||
- Fixed `mcp.json` files that start with a UTF-8 BOM failing to parse, which skipped all their MCP servers and made `pi mcp add`/`pi mcp remove` fail ([#11191](https://github.com/earendil-works/pi/issues/11191))
|
||||
- Fixed `/compact <instructions>` ignoring the instructions when summarizing the start of a turn that compaction splits. When the split turn was the only new content since the last compaction, the instructions were ignored entirely ([#11058](https://github.com/earendil-works/pi/issues/11058))
|
||||
- Fixed resumed sessions and `/tree` navigation leaving extension tools inactive when the branch recorded its tools before they were registered, such as tools from extensions installed after the session started. Extension tools the branch never added or removed are now active unless they are registered with `defaultActive: false` ([#11062](https://github.com/earendil-works/pi/issues/11062), [#11075](https://github.com/earendil-works/pi/issues/11075))
|
||||
- Fixed sessions breaking after recovery from a response cut off at the output limit: the truncated tool call was omitted but its tool results were kept, so providers rejected every later request ([#11227](https://github.com/earendil-works/pi/issues/11227), [#11233](https://github.com/earendil-works/pi/pull/11233) by [@petrroll](https://github.com/petrroll))
|
||||
- Fixed the `grep` tool following `RIPGREP_CONFIG_PATH`, where options such as `--fixed-strings` silently changed regex searches into literal ones. The tool now runs `rg --no-config` ([#11203](https://github.com/earendil-works/pi/issues/11203), [#11230](https://github.com/earendil-works/pi/pull/11230) by [@petrroll](https://github.com/petrroll))
|
||||
- Fixed Windows shell detection rejecting runnable Microsoft Store shell aliases ([#9504](https://github.com/earendil-works/pi/pull/9504) by [@petrroll](https://github.com/petrroll))
|
||||
- Fixed Windows shell detection missing PowerShell outside `PATH` and the legacy WSL `bash.exe` launcher when Windows is not installed in `C:\Windows`. pi now checks `%ProgramFiles%\PowerShell\7`, `%SystemRoot%\System32\WindowsPowerShell\v1.0`, and `%SystemRoot%` ([#9501](https://github.com/earendil-works/pi/pull/9501) by [@petrroll](https://github.com/petrroll))
|
||||
- Fixed GitHub Copilot sessions with large request bodies, such as many images, failing with HTTP 413 `failed to parse request` instead of compacting and retrying ([#11016](https://github.com/earendil-works/pi/pull/11016) by [@petrroll](https://github.com/petrroll))
|
||||
- Fixed the `openai-codex` provider offering `gpt-5.5` and `gpt-5.3-codex-spark`, which always fail for ChatGPT accounts ([#11173](https://github.com/earendil-works/pi/pull/11173) by [@lucasmeijer](https://github.com/lucasmeijer))
|
||||
- Fixed whitespace-only tool output (for example a bare `\r\n` from bash) making OpenAI Completions, OpenAI Responses, and Google requests fail with a 400 ([#8720](https://github.com/earendil-works/pi/issues/8720))
|
||||
- Fixed automatic retry not triggering for more gateway upstream failures, `stream disconnected before completion` errors relayed by Codex CLI proxies, and transient Google Vertex OAuth token refresh failures, and retrying errors because of digits inside request ids or byte counts ([#10920](https://github.com/earendil-works/pi/issues/10920), [#11057](https://github.com/earendil-works/pi/issues/11057), [#10996](https://github.com/earendil-works/pi/issues/10996))
|
||||
- Fixed OpenAI Responses models losing the prompt cache on every deferred tool load after a tool was removed ([#10940](https://github.com/earendil-works/pi/issues/10940))
|
||||
- Fixed Cloudflare Workers AI classifier pricing: Clef Flash costs $0.038 and Jev $0.042 per million input tokens instead of Jev being listed as free.
|
||||
- Fixed an unclosed `$$` display-math block swallowing or corrupting every Markdown table after it ([#11159](https://github.com/earendil-works/pi/issues/11159))
|
||||
- Fixed Markdown bold and italic not rendering next to CJK punctuation ([#10730](https://github.com/earendil-works/pi/pull/10730) by [@theBucky](https://github.com/theBucky))
|
||||
- Fixed `@` file autocomplete returning no results for queries containing regex metacharacters such as `[` or `(` ([#10972](https://github.com/earendil-works/pi/issues/10972))
|
||||
|
||||
## [1.3.0] - 2026-10-15
|
||||
|
||||
|
|
@ -46,10 +82,6 @@
|
|||
- Changed the `kimi-coding` provider to use OpenAI Chat Completions at `https://api.kimi.com/coding/v1`, the protocol used by the official Kimi Code client. K3, K3-256K, and Kimi For Coding now accept tools added mid-conversation without invalidating the prompt cache. `models.json` overrides of the `kimi-coding` base URL must now include `/v1` ([#10720](https://github.com/earendil-works/pi/issues/10720))
|
||||
- Changed model switching to drop thinking blocks from earlier assistant messages produced by a different model, instead of sending them as plain text. The new model previously saw the old model's reasoning as part of its replies and could start writing its own reasoning into the visible answer ([#10995](https://github.com/earendil-works/pi/issues/10995))
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the default NVIDIA model to `nvidia/nemotron-3.5-super-vl-120b-a12b` ([#10910](https://github.com/earendil-works/pi/pull/10910) by [@cv](https://github.com/cv))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Pinned internal Pi dependencies to exact release versions to prevent fresh installs from mixing incompatible packages ([#10865](https://github.com/earendil-works/pi/issues/10865))
|
||||
|
|
|
|||
|
|
@ -138,6 +138,8 @@ An OAuth provider supplies a display name, login flow, token refresh, and access
|
|||
|
||||
OAuth callbacks are UI-neutral. They can open an authorization URL, show a device code, report progress, request input, or ask the user to choose a login method. Honor cancellation and the supplied abort signal during network requests.
|
||||
|
||||
Press Enter (`tui.select.confirm`) to open the device-login URL and copy the code, or log in manually. Providers can disable either action with `openBrowserOnConfirm: false` or `copyCodeOnConfirm: false` in `onDeviceCode` or native `AuthEvent` notifications.
|
||||
|
||||
Never write access tokens, refresh tokens, authorization headers, or complete provider responses to ordinary logs.
|
||||
|
||||
## Supply and refresh models
|
||||
|
|
|
|||
|
|
@ -185,3 +185,12 @@ Also applies to `azure-openai-responses` and `openai-codex-responses`.
|
|||
| Field | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `supportsMidConvoSystemMessages` | boolean | `false` | Whether the exact model accepts system messages after the conversation has started. When false, later system messages are folded into the leading system message. |
|
||||
|
||||
### `typesafe-system-one`
|
||||
|
||||
Also applies to `cloudflare-workers-ai-system-one`.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `imageInput` | `"assets" \| "images" \| "state-image-urls"` | | How the service takes classifier images. "assets" sends TypeSafe's assets map (PNG and JPEG) and references the images from the state, as TypeSafe and Jev on Workers AI expect. "images" sends Clef's images list of data URLs (PNG, JPEG, and WebP, at most four), as Clef on Workers AI expects. "state-image-urls" places image_url parts before the state, as OpenRouter expects; OpenRouter drops assets without an error. Auto-detected from the provider, baseUrl, and model ID. Classifier models read it from the provider-level compat; models and modelOverrides entries apply to chat models only. |
|
||||
| `sendImageOptInHeader` | boolean | | Whether requests with images send the TypeSafe-Danger-Zone-Images: true header, which TypeSafe's own API requires for its experimental image input. Defaults to true for the typesafe-system-one API unless the model is detected as OpenRouter. |
|
||||
|
|
|
|||
|
|
@ -141,20 +141,20 @@ Compatibility settings should describe verified differences in the endpoint's re
|
|||
|
||||
## Use classifier models
|
||||
|
||||
Classifier models do not chat. They answer typed questions about JSON state: pick one of several choices, answer yes or no, or give a score, each with probabilities. Pi includes TypeSafe's Jev model from these providers, Cloudflare's Clef and Clef Flash models from Workers AI, and OpenAI's GPT-6 Luna through the [Decisions API](https://developers.openai.com/api/docs/guides/decisions):
|
||||
Classifier models do not chat. They answer typed questions about JSON state: pick one of several choices, answer yes or no, or give a score, each with probabilities. Pi includes TypeSafe's Jev model from these providers, Cloudflare's Clef, Clef Flash, and Clef Omni models from Workers AI and OpenRouter, and OpenAI's GPT-6 Luna through the [Decisions API](https://developers.openai.com/api/docs/guides/decisions):
|
||||
|
||||
| Provider | Model IDs | Authentication |
|
||||
|---|---|---|
|
||||
| `typesafe` | `jev-latest` | `TYPESAFE_API_KEY` |
|
||||
| `openrouter` | `typesafe/jev-1.13`, `~typesafe/jev-latest` | `OPENROUTER_API_KEY` or `/login` |
|
||||
| `cloudflare-workers-ai` | `typesafe/jev`, `@cf/cloudflare/clef`, `@cf/cloudflare/clef-flash` | `CLOUDFLARE_API_KEY` and `CLOUDFLARE_ACCOUNT_ID` |
|
||||
| `openrouter` | `typesafe/jev-1.13`, `typesafe/jev-1.14`, `~typesafe/jev-latest`, `cloudflare/clef`, `cloudflare/clef-flash`, `cloudflare/clef-omni` | `OPENROUTER_API_KEY` or `/login` |
|
||||
| `cloudflare-workers-ai` | `typesafe/jev`, `typesafe/jev-1.14.0`, `@cf/cloudflare/clef`, `@cf/cloudflare/clef-flash`, `@cf/cloudflare/clef-omni` | `CLOUDFLARE_API_KEY` and `CLOUDFLARE_ACCOUNT_ID` |
|
||||
| `vercel-ai-gateway` | `typesafe-ai/jev` | `AI_GATEWAY_API_KEY` |
|
||||
| `opencode` | `jev-1.13`, `jev-1.13-free` | `OPENCODE_API_KEY` |
|
||||
| `openai` | `gpt-6-luna` | `OPENAI_API_KEY` |
|
||||
|
||||
Chat models on a [llama.cpp router](llama-cpp.md#classification) are also listed as classifier models.
|
||||
|
||||
OpenAI's Decisions API needs an API key. Sign in with ChatGPT credentials do not work with it, so `gpt-6-luna` is not listed as available while `openai` is logged in through `/login`, even when `OPENAI_API_KEY` is set; log out of `openai` to use the key. GPT-6 Luna also judges images passed in `images` (see [Codemode](codemode.md#classify)); other classifier models return an error for them. The API rejects inputs above 922K tokens, but requests that run longer than about five seconds, currently above roughly 600K input tokens, fail with a gateway timeout.
|
||||
OpenAI's Decisions API needs an API key. Sign in with ChatGPT credentials do not work with it, so `gpt-6-luna` is not listed as available while `openai` is logged in through `/login`, even when `OPENAI_API_KEY` is set; log out of `openai` to use the key. The API rejects inputs above 922K tokens, but requests that run longer than about five seconds, currently above roughly 600K input tokens, fail with a gateway timeout.
|
||||
|
||||
Classifier models do not appear in `/model`. The model reaches them through the [`codemode`](cli.md#enable-codemode) tool, which is off unless an MCP server turned it on. Enable it with `"defaultTools": ["+codemode"]` in [settings](settings.md#tools). Scripts then list classifier models with `models.getAvailableOfType("classifier")` and call `models.classify(model, { state, questions })`:
|
||||
|
||||
|
|
@ -175,6 +175,10 @@ return result.answers;
|
|||
|
||||
[Codemode](codemode.md#classify) describes the question and answer types.
|
||||
|
||||
Classifier models whose `input` includes `"image"` also judge images passed in `images`; other classifier models return an error for them. Image input works with GPT-6 Luna, with Jev from `typesafe` and `cloudflare-workers-ai`, with Clef from `cloudflare-workers-ai`, and with the OpenRouter models whose catalog lists image input. Jev takes PNG and JPEG images, and image input is experimental on TypeSafe's own API. Clef takes PNG, JPEG, and WebP images, at most four per request. Jev from `vercel-ai-gateway` and `opencode` takes text only.
|
||||
|
||||
Each System One service takes images in its own request format, set by the model's `compat.imageInput`. Built-in models set it, so overriding a provider's `baseUrl` keeps it. A custom provider that proxies another service's classifiers sets it in its provider-level `compat` in `models.json` (see [System One compatibility](models-configuration.md#typesafe-system-one)). OpenRouter in particular answers requests in TypeSafe's format without an error but drops the images.
|
||||
|
||||
When the service reports token counts, as all System One services do, `result.usage` carries them with their cost. Pi adds the usage of a script's classifier calls to the `codemode` tool result, so it counts toward the session cost in the footer and `/session`. The cost uses the model's catalog price; models without one, such as TypeSafe's direct `jev-latest`, report tokens at no cost.
|
||||
|
||||
Extensions call classifiers through `ctx.modelRegistry.classify()`, without codemode. [Virtual models](virtual-models.md#route-requests) can use them to route requests; see the `jev-router.ts` example.
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "pi-extension-custom-provider",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-extension-custom-provider",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.52.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "pi-extension-custom-provider-anthropic",
|
||||
"private": true,
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "pi-extension-custom-provider-gitlab-duo",
|
||||
"private": true,
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
{
|
||||
"name": "pi-extension-gondolin",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-extension-gondolin",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"dependencies": {
|
||||
"@earendil-works/gondolin": "0.12.0"
|
||||
"@earendil-works/gondolin": "0.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cto.af/wtf8": {
|
||||
|
|
@ -21,31 +21,30 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@earendil-works/gondolin": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/gondolin/-/gondolin-0.12.0.tgz",
|
||||
"integrity": "sha512-BXbvzQKb5QmxY5NtthRDONJTu7+IDKbzqWGrJyyNXMP7N681Tx0Q9TK8pK1ba8nUvYQTipNJyGZOsJfYiZll1A==",
|
||||
"version": "0.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/gondolin/-/gondolin-0.14.0.tgz",
|
||||
"integrity": "sha512-MSw+EGcqFJeyKHdXEfm+nMRr74ytT4x8DGIohav0oPCHeVQAB9AajquM94cv2xqvIU3VDAaZZVC07IXsOY6OVQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"cbor2": "^2.3.0",
|
||||
"node-forge": "^1.3.3",
|
||||
"ssh2": "^1.17.0",
|
||||
"undici": "^6.21.0"
|
||||
"undici": "^6.29.0"
|
||||
},
|
||||
"bin": {
|
||||
"gondolin": "dist/bin/gondolin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=23.6.0"
|
||||
"node": ">=22.19.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@earendil-works/gondolin-krun-runner-darwin-arm64": "0.12.0",
|
||||
"@earendil-works/gondolin-krun-runner-linux-x64": "0.12.0"
|
||||
"@earendil-works/gondolin-krun-runner-darwin-arm64": "0.14.0",
|
||||
"@earendil-works/gondolin-krun-runner-linux-x64": "0.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/gondolin-krun-runner-darwin-arm64": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-darwin-arm64/-/gondolin-krun-runner-darwin-arm64-0.12.0.tgz",
|
||||
"integrity": "sha512-ftDlusht4PcT7Y3TuPrZIKrCXy3isiBTVMvlXYK0pcud2uXY6uwFTGeunYgP+8ND/60ddb+MImqbfmkcK8B84A==",
|
||||
"version": "0.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-darwin-arm64/-/gondolin-krun-runner-darwin-arm64-0.14.0.tgz",
|
||||
"integrity": "sha512-fvuhz1CZPaEbmXC0luZoXd1obAb0bjaaGJz33Jo63bhMlosYunAahItakh7cU0dnTlNFzufOSnpGOPqtuYjnpQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -59,9 +58,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@earendil-works/gondolin-krun-runner-linux-x64": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-linux-x64/-/gondolin-krun-runner-linux-x64-0.12.0.tgz",
|
||||
"integrity": "sha512-RRYsgwe2r5ApKmFNy469QgwnyjAHpAs9XANdWpTd9ol4iUYOY3sX7e0xIooAKxd+ktxGI4N/xRWicwGen3D/Ow==",
|
||||
"version": "0.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-linux-x64/-/gondolin-krun-runner-linux-x64-0.14.0.tgz",
|
||||
"integrity": "sha512-91iw4smoOH/yOoAuQS6N2UZOxqSFwRhtk8rTyrokXBoWiWcveAzg4kHvnLcm40ZlNrWwzIDFrere4bkZeGDQcA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -134,15 +133,6 @@
|
|||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/node-forge": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
|
||||
"integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
|
||||
"license": "(BSD-3-Clause OR GPL-2.0)",
|
||||
"engines": {
|
||||
"node": ">= 6.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
|
|
@ -173,9 +163,9 @@
|
|||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "6.26.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz",
|
||||
"integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==",
|
||||
"version": "6.29.1",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.29.1.tgz",
|
||||
"integrity": "sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "pi-extension-gondolin",
|
||||
"private": true,
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
|
|
@ -14,6 +14,6 @@
|
|||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/gondolin": "0.12.0"
|
||||
"@earendil-works/gondolin": "0.14.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "pi-extension-sandbox",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-extension-sandbox",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sandbox-runtime": "^0.0.26"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "pi-extension-sandbox",
|
||||
"private": true,
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "pi-extension-with-deps",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-extension-with-deps",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "pi-extension-with-deps",
|
||||
"private": true,
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
|
|
|
|||
48
packages/coding-agent/install-lock/package-lock.json
generated
48
packages/coding-agent/install-lock/package-lock.json
generated
|
|
@ -1,14 +1,14 @@
|
|||
{
|
||||
"name": "@earendil-works/pi-coding-agent-install",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@earendil-works/pi-coding-agent-install",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "1.3.0"
|
||||
"@earendil-works/pi-coding-agent": "1.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
|
|
@ -386,11 +386,11 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-agent-core": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-1.3.0.tgz",
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-1.4.0.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-ai": "1.3.0",
|
||||
"@earendil-works/pi-ai": "1.4.0",
|
||||
"typebox": "1.3.27"
|
||||
},
|
||||
"engines": {
|
||||
|
|
@ -398,13 +398,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-ai": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-1.3.0.tgz",
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-1.4.0.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.129.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.1127.0",
|
||||
"@earendil-works/pi-telemetry": "1.3.0",
|
||||
"@earendil-works/pi-telemetry": "1.4.0",
|
||||
"@google/genai": "2.21.0",
|
||||
"@smithy/node-http-handler": "4.12.1",
|
||||
"http-proxy-agent": "9.1.0",
|
||||
|
|
@ -481,8 +481,8 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-codemode": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-codemode/-/pi-codemode-1.3.0.tgz",
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-codemode/-/pi-codemode-1.4.0.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"quickjs-wasi": "3.6.2"
|
||||
|
|
@ -492,15 +492,15 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-coding-agent": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-1.3.0.tgz",
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-1.4.0.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-agent-core": "1.3.0",
|
||||
"@earendil-works/pi-ai": "1.3.0",
|
||||
"@earendil-works/pi-codemode": "1.3.0",
|
||||
"@earendil-works/pi-mcp": "1.3.0",
|
||||
"@earendil-works/pi-tui": "1.3.0",
|
||||
"@earendil-works/pi-agent-core": "1.4.0",
|
||||
"@earendil-works/pi-ai": "1.4.0",
|
||||
"@earendil-works/pi-codemode": "1.4.0",
|
||||
"@earendil-works/pi-mcp": "1.4.0",
|
||||
"@earendil-works/pi-tui": "1.4.0",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"brace-expansion": "5.0.12",
|
||||
"chalk": "6.0.0",
|
||||
|
|
@ -527,8 +527,8 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-mcp": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-mcp/-/pi-mcp-1.3.0.tgz",
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-mcp/-/pi-mcp-1.4.0.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "7.0.6"
|
||||
|
|
@ -538,16 +538,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-telemetry": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-1.3.0.tgz",
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-1.4.0.tgz",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-tui": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-1.3.0.tgz",
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-1.4.0.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "1.6.0",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"name": "@earendil-works/pi-coding-agent-install",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"private": true,
|
||||
"description": "Lockfile root used by the Pi installer and updater.",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "1.3.0"
|
||||
"@earendil-works/pi-coding-agent": "1.4.0"
|
||||
},
|
||||
"overrides": {
|
||||
"protobufjs": "7.6.6"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@earendil-works/pi-coding-agent",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
||||
"type": "module",
|
||||
"piConfig": {
|
||||
|
|
@ -41,11 +41,11 @@
|
|||
"prepublishOnly": "npm run clean && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-agent-core": "1.3.0",
|
||||
"@earendil-works/pi-ai": "1.3.0",
|
||||
"@earendil-works/pi-codemode": "1.3.0",
|
||||
"@earendil-works/pi-mcp": "1.3.0",
|
||||
"@earendil-works/pi-tui": "1.3.0",
|
||||
"@earendil-works/pi-agent-core": "1.4.0",
|
||||
"@earendil-works/pi-ai": "1.4.0",
|
||||
"@earendil-works/pi-codemode": "1.4.0",
|
||||
"@earendil-works/pi-mcp": "1.4.0",
|
||||
"@earendil-works/pi-tui": "1.4.0",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"brace-expansion": "5.0.12",
|
||||
"chalk": "6.0.0",
|
||||
|
|
@ -75,7 +75,7 @@
|
|||
"@types/semver": "7.7.1",
|
||||
"marked": "18.0.11",
|
||||
"shx": "0.3.4",
|
||||
"vitest": "4.1.11"
|
||||
"vitest": "4.1.12"
|
||||
},
|
||||
"keywords": [
|
||||
"coding-agent",
|
||||
|
|
|
|||
|
|
@ -1107,6 +1107,27 @@
|
|||
},
|
||||
"maxItems": 3,
|
||||
"description": "Models Anthropic accepts for server-side refusal fallback, with local pricing metadata for returned fallback responses. When absent or empty, callers must omit fallbacks; Anthropic rejects the field for models with no permitted fallback targets."
|
||||
},
|
||||
"imageInput": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"const": "assets"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"const": "images"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"const": "state-image-urls"
|
||||
}
|
||||
],
|
||||
"description": "How the service takes classifier images. \"assets\" sends TypeSafe's assets map (PNG and JPEG) and references the images from the state, as TypeSafe and Jev on Workers AI expect. \"images\" sends Clef's images list of data URLs (PNG, JPEG, and WebP, at most four), as Clef on Workers AI expects. \"state-image-urls\" places image_url parts before the state, as OpenRouter expects; OpenRouter drops assets without an error. Auto-detected from the provider, baseUrl, and model ID. Classifier models read it from the provider-level compat; models and modelOverrides entries apply to chat models only."
|
||||
},
|
||||
"sendImageOptInHeader": {
|
||||
"type": "boolean",
|
||||
"description": "Whether requests with images send the TypeSafe-Danger-Zone-Images: true header, which TypeSafe's own API requires for its experimental image input. Defaults to true for the typesafe-system-one API unless the model is detected as OpenRouter."
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import {
|
|||
runToolCall,
|
||||
type ThinkingLevel,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
import { contentText, getCurrentSystemMessage, retryDelayMs } from "@earendil-works/pi-ai";
|
||||
import { contentText, getCurrentSystemMessage, getCurrentTools, retryDelayMs } from "@earendil-works/pi-ai";
|
||||
import type {
|
||||
AssistantMessage,
|
||||
AuthResult,
|
||||
|
|
@ -427,7 +427,6 @@ export class AgentSession {
|
|||
private readonly _entryIdsByMessage = new WeakMap<object, string>();
|
||||
private readonly _boundaryDispatchedMessages = new WeakSet<object>();
|
||||
private _lastAssistantMessage: AssistantMessage | undefined;
|
||||
private _lastAssistantToolResults: AgentMessage[] = [];
|
||||
private _lastActivityOutcome: AgentActivityOutcome = "completed";
|
||||
private _isBeforeSettle = false;
|
||||
private _abortDuringBeforeSettle = false;
|
||||
|
|
@ -1194,7 +1193,6 @@ export class AgentSession {
|
|||
// extension and listener dispatch above also picks up messages that turn_end
|
||||
// handlers queued.
|
||||
if (event.type === "turn_end") {
|
||||
this._lastAssistantToolResults = event.toolResults;
|
||||
this._flushPendingCustomMessages();
|
||||
}
|
||||
};
|
||||
|
|
@ -1238,7 +1236,24 @@ export class AgentSession {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
private _omitRecoveryAttempt(message: AssistantMessage, toolResults: AgentMessage[] = []): void {
|
||||
/**
|
||||
* Persistently omit a failed attempt and its tool results from model context. Results are
|
||||
* found in the projected context, so the pair stays together whether the attempt comes from
|
||||
* the current run or a resumed session. An unpaired result fails the next request (#11227).
|
||||
*/
|
||||
private _omitRecoveryAttempt(message: AssistantMessage): void {
|
||||
const toolCallIds = new Set(message.content.flatMap((block) => (block.type === "toolCall" ? [block.id] : [])));
|
||||
// A context edit replaces the projected copy, so match it by source entry as well.
|
||||
const entryId = this._findPersistedMessageEntryId(message);
|
||||
const messages = this.agent.state.messages;
|
||||
const index = messages.findIndex(
|
||||
(m) => m === message || (entryId !== undefined && this._entryIdsByMessage.get(m) === entryId),
|
||||
);
|
||||
const toolResults: AgentMessage[] = [];
|
||||
for (const m of index < 0 ? [] : messages.slice(index + 1)) {
|
||||
if (m.role === "assistant") break;
|
||||
if (m.role === "toolResult" && toolCallIds.has(m.toolCallId)) toolResults.push(m);
|
||||
}
|
||||
const targets = [message, ...toolResults];
|
||||
const targetIds = targets.map((target) => this._findPersistedMessageEntryId(target));
|
||||
const unresolvedProjectedTarget = targets.some(
|
||||
|
|
@ -1814,13 +1829,34 @@ export class AgentSession {
|
|||
*/
|
||||
private _restoreToolsFromTranscript(modifiers: readonly string[] = []): void {
|
||||
this._pendingToolNames.clear();
|
||||
const current = getCurrentSystemMessage(this.sessionManager.buildSessionContext().messages);
|
||||
if (!current) return;
|
||||
// All system messages on the branch, including those before a compaction. Compaction snapshots
|
||||
// are derived from these, so replaying them gives the current loadout.
|
||||
const systemMessages = this.sessionManager
|
||||
.getBranch()
|
||||
.flatMap((entry) => (entry.type === "message" && entry.message.role === "system" ? [entry.message] : []));
|
||||
// No recorded loadout: keep the current tools.
|
||||
if (systemMessages.length === 0) return;
|
||||
// Tools the branch ever added or removed. Removed tools stay inactive.
|
||||
const mentioned = new Set(
|
||||
systemMessages
|
||||
.flatMap((message) => [...(message.toolsAdded ?? []), ...(message.toolsRemoved ?? [])])
|
||||
.map((tool) => tool.name),
|
||||
);
|
||||
// Extension tools the branch never mentioned were registered after it recorded its loadout, so
|
||||
// they are activated like newly registered tools. Built-in tools follow `defaultTools` instead.
|
||||
const newTools = [...this._toolRegistry.keys()].filter(
|
||||
(name) =>
|
||||
!mentioned.has(name) && !this._baseToolDefinitions.has(name) && this._isActivatedOnRegistration(name),
|
||||
);
|
||||
// The recorded loadout plus the new tools, with `--tools +name`/`-name` applied.
|
||||
const names = applyToolModifiers(
|
||||
(current.toolsAdded ?? []).map((tool) => tool.name),
|
||||
[...getCurrentTools(systemMessages).map((tool) => tool.name), ...newTools],
|
||||
modifiers,
|
||||
);
|
||||
// Recorded tools that are not registered yet, such as MCP tools of servers still connecting, are
|
||||
// activated when they register. Excluded tools never register, so they are not kept pending.
|
||||
this._pendingToolNames = new Set(names.filter((name) => this._isAllowedTool(name)));
|
||||
// Ignores names that are not registered.
|
||||
this._setActiveTools(names);
|
||||
}
|
||||
|
||||
|
|
@ -1942,9 +1978,7 @@ export class AgentSession {
|
|||
|
||||
private async _handlePostAgentRun(): Promise<boolean> {
|
||||
const message = this._lastAssistantMessage;
|
||||
const toolResults = this._lastAssistantToolResults;
|
||||
this._lastAssistantMessage = undefined;
|
||||
this._lastAssistantToolResults = [];
|
||||
if (this._agentRunAbortRequested) {
|
||||
this._finishCancelledRetry();
|
||||
return false;
|
||||
|
|
@ -1971,7 +2005,7 @@ export class AgentSession {
|
|||
this._retryAttempt = 0;
|
||||
}
|
||||
|
||||
if (await this._checkCompaction(message, true, toolResults)) {
|
||||
if (await this._checkCompaction(message)) {
|
||||
return !this._agentRunAbortRequested;
|
||||
}
|
||||
|
||||
|
|
@ -2974,11 +3008,7 @@ export class AgentSession {
|
|||
* @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true
|
||||
* @returns Whether the post-run loop should call `agent.continue()` for overflow recovery or queued messages
|
||||
*/
|
||||
private async _checkCompaction(
|
||||
assistantMessage: AssistantMessage,
|
||||
skipAbortedCheck = true,
|
||||
toolResults: AgentMessage[] = [],
|
||||
): Promise<boolean> {
|
||||
private async _checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise<boolean> {
|
||||
const settings = this.settingsManager.getCompactionSettings(this.model);
|
||||
if (!settings.enabled) return false;
|
||||
|
||||
|
|
@ -3070,7 +3100,7 @@ export class AgentSession {
|
|||
|
||||
// Persistently omit the selected final attempt before post-run recovery compaction.
|
||||
this._overflowRecoveryAttempted = true;
|
||||
this._omitRecoveryAttempt(assistantMessage, toolResults);
|
||||
this._omitRecoveryAttempt(assistantMessage);
|
||||
const retry = await this._runAutoCompaction("overflow", willRetry);
|
||||
if (retry) this._failedResponse = assistantMessage;
|
||||
return retry;
|
||||
|
|
|
|||
|
|
@ -96,12 +96,12 @@ export async function execCommand(
|
|||
}
|
||||
resolve({ stdout, stderr, code: code ?? 0, killed });
|
||||
})
|
||||
.catch((_err) => {
|
||||
.catch((error: Error) => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (options?.signal) {
|
||||
options.signal.removeEventListener("abort", killProcess);
|
||||
}
|
||||
resolve({ stdout, stderr, code: 1, killed });
|
||||
resolve({ stdout, stderr: stderr || error.message, code: 1, killed });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -687,6 +687,15 @@
|
|||
display: block;
|
||||
}
|
||||
|
||||
.system-entry > .message-timestamp {
|
||||
padding-left: var(--line-height);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.system-entry > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.system-prompt.provider-prompt {
|
||||
border-left: 3px solid var(--warning);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,14 +98,12 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Sort children by timestamp
|
||||
function sortChildren(node) {
|
||||
// Sort children by timestamp. Iterative: long sessions form chains deeper than the JS call stack.
|
||||
for (const node of nodeMap.values()) {
|
||||
node.children.sort((a, b) =>
|
||||
new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime()
|
||||
);
|
||||
node.children.forEach(sortChildren);
|
||||
}
|
||||
roots.forEach(sortChildren);
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
|
@ -134,14 +132,14 @@
|
|||
const path = [];
|
||||
let current = byId.get(targetId);
|
||||
while (current) {
|
||||
path.unshift(current);
|
||||
path.push(current);
|
||||
// Stop if no parent or self-referencing (root)
|
||||
if (!current.parentId || current.parentId === current.id) {
|
||||
break;
|
||||
}
|
||||
current = byId.get(current.parentId);
|
||||
}
|
||||
return path;
|
||||
return path.reverse();
|
||||
}
|
||||
|
||||
// Tree node lookup for finding leaves
|
||||
|
|
@ -156,12 +154,12 @@
|
|||
// Build tree node map lazily
|
||||
if (!treeNodeMap) {
|
||||
treeNodeMap = new Map();
|
||||
const tree = buildTree();
|
||||
function mapNodes(node) {
|
||||
const stack = [...buildTree()];
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop();
|
||||
treeNodeMap.set(node.entry.id, node);
|
||||
node.children.forEach(mapNodes);
|
||||
for (const child of node.children) stack.push(child);
|
||||
}
|
||||
tree.forEach(mapNodes);
|
||||
}
|
||||
|
||||
const node = treeNodeMap.get(nodeId);
|
||||
|
|
@ -185,16 +183,22 @@
|
|||
const multipleRoots = roots.length > 1;
|
||||
|
||||
// Mark which subtrees contain the active leaf
|
||||
// Iterative post-order: long sessions form chains deeper than the JS call stack.
|
||||
const containsActive = new Map();
|
||||
function markActive(node) {
|
||||
let has = activePathIds.has(node.entry.id);
|
||||
for (const child of node.children) {
|
||||
if (markActive(child)) has = true;
|
||||
}
|
||||
containsActive.set(node, has);
|
||||
return has;
|
||||
const postOrder = [];
|
||||
const pending = [...roots];
|
||||
while (pending.length > 0) {
|
||||
const node = pending.pop();
|
||||
postOrder.push(node);
|
||||
for (const child of node.children) pending.push(child);
|
||||
}
|
||||
for (let i = postOrder.length - 1; i >= 0; i--) {
|
||||
const node = postOrder[i];
|
||||
containsActive.set(
|
||||
node,
|
||||
activePathIds.has(node.entry.id) || node.children.some(child => containsActive.get(child))
|
||||
);
|
||||
}
|
||||
roots.forEach(markActive);
|
||||
|
||||
// Stack: [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild]
|
||||
const stack = [];
|
||||
|
|
@ -313,6 +317,76 @@
|
|||
return '';
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SYSTEM MESSAGES (pure data)
|
||||
// ============================================================
|
||||
|
||||
function systemContentText(content) {
|
||||
if (typeof content === 'string') return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content.filter(c => c.type === 'text' && c.text).map(c => c.text).join('\n');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Full prompt text of a system message, mirroring getSystemMessageText() in pi-ai. */
|
||||
function getSystemMessageText(msg) {
|
||||
const parts = [systemContentText(msg.content)];
|
||||
for (const value of Object.values(msg.sections || {})) {
|
||||
if (value !== null) parts.push(value);
|
||||
}
|
||||
return parts.filter(part => part.length > 0).join('\n\n');
|
||||
}
|
||||
|
||||
/** Text of a later system message, mirroring renderSystemMessageUpdate() in pi-ai. */
|
||||
function renderSystemMessageUpdate(msg) {
|
||||
const parts = [];
|
||||
const text = systemContentText(msg.content);
|
||||
if (text.length > 0) parts.push(text);
|
||||
for (const [name, value] of Object.entries(msg.sections || {})) {
|
||||
parts.push(value === null
|
||||
? `Removed system prompt section "${name}".`
|
||||
: `Updated system prompt section "${name}":\n\n${value}`);
|
||||
}
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
function carriesSystemState(entry) {
|
||||
return (entry.type === 'message' && entry.message.role === 'system') ||
|
||||
(entry.type === 'compaction' && entry.systemMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a system entry starts the prompt on its branch. Later system entries
|
||||
* patch the prompt, so they render as updates instead of a full prompt.
|
||||
*/
|
||||
function isLeadingSystemEntry(entry) {
|
||||
let current = entry.parentId && entry.parentId !== entry.id ? byId.get(entry.parentId) : undefined;
|
||||
while (current) {
|
||||
if (carriesSystemState(current)) return false;
|
||||
if (!current.parentId || current.parentId === current.id) break;
|
||||
current = byId.get(current.parentId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Short description of what a later system message changes, e.g. "project_context, -rules, tools +bash". */
|
||||
function describeSystemUpdate(msg) {
|
||||
const parts = [];
|
||||
if (systemContentText(msg.content).length > 0) parts.push('instructions');
|
||||
for (const [name, value] of Object.entries(msg.sections || {})) {
|
||||
parts.push(value === null ? `-${name}` : name);
|
||||
}
|
||||
const toolChanges = [
|
||||
...(msg.toolsAdded || []).map(t => `+${t.name}`),
|
||||
...(msg.toolsRemoved || []).map(t => `-${t.name}`),
|
||||
];
|
||||
if (toolChanges.length > 0) parts.push(`tools ${toolChanges.join(' ')}`);
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
const hasSystemEntries = entries.some(entry => entry.type === 'message' && entry.message.role === 'system');
|
||||
|
||||
/**
|
||||
* Parse a skill block from message text.
|
||||
* Returns null if the text doesn't contain a skill block.
|
||||
|
|
@ -337,6 +411,13 @@
|
|||
case 'message': {
|
||||
const msg = entry.message;
|
||||
parts.push(msg.role);
|
||||
if (msg.role === 'system') {
|
||||
parts.push(isLeadingSystemEntry(entry) ? 'system prompt' : 'system update');
|
||||
parts.push(getSystemMessageText(msg));
|
||||
for (const name of Object.keys(msg.sections || {})) parts.push(name);
|
||||
for (const tool of [...(msg.toolsAdded || []), ...(msg.toolsRemoved || [])]) parts.push(tool.name);
|
||||
break;
|
||||
}
|
||||
if (msg.content) parts.push(extractContent(msg.content));
|
||||
if (msg.role === 'bashExecution' && msg.command) parts.push(msg.command);
|
||||
break;
|
||||
|
|
@ -675,6 +756,13 @@
|
|||
const cmd = truncate(normalize(msg.command || ''));
|
||||
return labelHtml + `<span class="tree-role-tool">[bash]:</span> ${escapeHtml(cmd)}`;
|
||||
}
|
||||
if (msg.role === 'system') {
|
||||
if (isLeadingSystemEntry(entry)) {
|
||||
return labelHtml + `<span class="tree-muted">[system prompt]</span>`;
|
||||
}
|
||||
const changes = describeSystemUpdate(msg);
|
||||
return labelHtml + `<span class="tree-muted">[system update${changes ? ': ' + escapeHtml(truncate(changes)) : ''}]</span>`;
|
||||
}
|
||||
return labelHtml + `<span class="tree-muted">[${escapeHtml(msg.role)}]</span>`;
|
||||
}
|
||||
case 'compaction':
|
||||
|
|
@ -1311,6 +1399,24 @@
|
|||
}
|
||||
|
||||
if (msg.role === 'toolResult') return '';
|
||||
|
||||
if (msg.role === 'system') {
|
||||
let html = '';
|
||||
if (isLeadingSystemEntry(entry)) {
|
||||
const text = getSystemMessageText(msg);
|
||||
if (text) html += renderSystemPromptBlock('System Prompt', text);
|
||||
html += renderToolsList('Available Tools', msg.toolsAdded);
|
||||
} else {
|
||||
const text = renderSystemMessageUpdate(msg);
|
||||
const changes = describeSystemUpdate(msg);
|
||||
if (text) html += renderSystemPromptBlock(`System Prompt Update${changes ? ': ' + changes : ''}`, text);
|
||||
html += renderToolsList('Tools Added', msg.toolsAdded);
|
||||
if (msg.toolsRemoved && msg.toolsRemoved.length > 0) {
|
||||
html += `<div class="tools-list"><div class="tools-header">Tools Removed</div><div class="tools-content">${msg.toolsRemoved.map(t => `<div class="tool-item"><span class="tool-item-name">${escapeHtml(t.name)}</span></div>`).join('')}</div></div>`;
|
||||
}
|
||||
}
|
||||
return html ? `<div class="system-entry" id="${entryDomId}">${tsHtml}${html}</div>` : '';
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.type === 'model_change') {
|
||||
|
|
@ -1351,7 +1457,7 @@
|
|||
let userMessages = 0, assistantMessages = 0, toolResults = 0;
|
||||
let customMessages = 0, compactions = 0, branchSummaries = 0, toolCalls = 0;
|
||||
const tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
||||
const cost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
||||
const cost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
|
||||
const models = new Set();
|
||||
|
||||
for (const entry of entryList) {
|
||||
|
|
@ -1367,10 +1473,18 @@
|
|||
tokens.cacheRead += msg.usage.cacheRead || 0;
|
||||
tokens.cacheWrite += msg.usage.cacheWrite || 0;
|
||||
if (msg.usage.cost) {
|
||||
const componentTotal =
|
||||
(msg.usage.cost.input || 0) +
|
||||
(msg.usage.cost.output || 0) +
|
||||
(msg.usage.cost.cacheRead || 0) +
|
||||
(msg.usage.cost.cacheWrite || 0);
|
||||
cost.input += msg.usage.cost.input || 0;
|
||||
cost.output += msg.usage.cost.output || 0;
|
||||
cost.cacheRead += msg.usage.cost.cacheRead || 0;
|
||||
cost.cacheWrite += msg.usage.cost.cacheWrite || 0;
|
||||
// Provider-reported totals can intentionally differ from catalog-estimated components.
|
||||
// Fall back to the component sum for sessions written before cost.total was recorded.
|
||||
cost.total += typeof msg.usage.cost.total === 'number' ? msg.usage.cost.total : componentTotal;
|
||||
}
|
||||
}
|
||||
toolCalls += msg.content.filter(c => c.type === 'toolCall').length;
|
||||
|
|
@ -1391,7 +1505,7 @@
|
|||
const globalStats = computeStats(entries);
|
||||
|
||||
function renderHeader() {
|
||||
const totalCost = globalStats.cost.input + globalStats.cost.output + globalStats.cost.cacheRead + globalStats.cost.cacheWrite;
|
||||
const totalCost = globalStats.cost.total;
|
||||
|
||||
const tokenParts = [];
|
||||
if (globalStats.tokens.input) tokenParts.push(`↑${formatTokens(globalStats.tokens.input)}`);
|
||||
|
|
@ -1429,30 +1543,39 @@
|
|||
</div>
|
||||
</div>`;
|
||||
|
||||
// Render system prompt (user's base prompt, applies to all providers)
|
||||
if (systemPrompt) {
|
||||
const lines = systemPrompt.split('\n');
|
||||
const previewLines = 10;
|
||||
if (lines.length > previewLines) {
|
||||
const preview = lines.slice(0, previewLines).join('\n');
|
||||
const remaining = lines.length - previewLines;
|
||||
html += `<div class="system-prompt expandable" onclick="if(window.getSelection().toString())return;this.classList.toggle('expanded')">
|
||||
<div class="system-prompt-header">System Prompt</div>
|
||||
<div class="system-prompt-preview">${escapeHtml(preview)}</div>
|
||||
<div class="system-prompt-expand-hint">... (${remaining} more lines, click to expand)</div>
|
||||
<div class="system-prompt-full">${escapeHtml(systemPrompt)}</div>
|
||||
</div>`;
|
||||
} else {
|
||||
html += `<div class="system-prompt">
|
||||
<div class="system-prompt-header">System Prompt</div>
|
||||
<div class="system-prompt-full" style="display: block">${escapeHtml(systemPrompt)}</div>
|
||||
</div>`;
|
||||
}
|
||||
// Sessions with system entries render the prompt and tools in the transcript, where they
|
||||
// occur. Older sessions have none, so fall back to the exporting agent's current state.
|
||||
if (!hasSystemEntries) {
|
||||
if (systemPrompt) html += renderSystemPromptBlock('System Prompt', systemPrompt);
|
||||
html += renderToolsList('Available Tools', tools);
|
||||
}
|
||||
|
||||
if (tools && tools.length > 0) {
|
||||
html += `<div class="tools-list">
|
||||
<div class="tools-header">Available Tools</div>
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderSystemPromptBlock(title, text) {
|
||||
const lines = text.split('\n');
|
||||
const previewLines = 10;
|
||||
if (lines.length > previewLines) {
|
||||
const preview = lines.slice(0, previewLines).join('\n');
|
||||
const remaining = lines.length - previewLines;
|
||||
return `<div class="system-prompt expandable" onclick="if(window.getSelection().toString())return;this.classList.toggle('expanded')">
|
||||
<div class="system-prompt-header">${escapeHtml(title)}</div>
|
||||
<div class="system-prompt-preview">${escapeHtml(preview)}</div>
|
||||
<div class="system-prompt-expand-hint">... (${remaining} more lines, click to expand)</div>
|
||||
<div class="system-prompt-full">${escapeHtml(text)}</div>
|
||||
</div>`;
|
||||
}
|
||||
return `<div class="system-prompt">
|
||||
<div class="system-prompt-header">${escapeHtml(title)}</div>
|
||||
<div class="system-prompt-full" style="display: block">${escapeHtml(text)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderToolsList(title, tools) {
|
||||
if (!tools || tools.length === 0) return '';
|
||||
return `<div class="tools-list">
|
||||
<div class="tools-header">${escapeHtml(title)}</div>
|
||||
<div class="tools-content">
|
||||
${tools.map(t => {
|
||||
const hasParams = t.parameters && typeof t.parameters === 'object' && t.parameters.properties && Object.keys(t.parameters.properties).length > 0;
|
||||
|
|
@ -1477,9 +1600,6 @@
|
|||
}).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -1542,6 +1542,10 @@ export interface MarkdownTransformContext {
|
|||
messageType: "user" | "assistant" | "assistant-thinking";
|
||||
isStreaming: boolean;
|
||||
availableWidth: number;
|
||||
/** Persisted session-entry id. Undefined for live messages or live rendering; available when rendering from persisted session entries. */
|
||||
messageId?: string;
|
||||
/** Persisted session-entry ISO timestamp. Undefined for live messages or live rendering; available when rendering from persisted session entries. */
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export type MarkdownTransformer = (markdown: string, context: MarkdownTransformContext) => string;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
type AuthInteraction,
|
||||
type AuthResult,
|
||||
type ClassifierApi,
|
||||
type ClassifierModel,
|
||||
type Credential,
|
||||
type ImageApi,
|
||||
isModelType,
|
||||
|
|
@ -83,6 +84,7 @@ export interface ProviderClassifierModelConfig extends ProviderModelConfigBase {
|
|||
type: "classifier";
|
||||
api?: ClassifierApi;
|
||||
contextWindow: number;
|
||||
compat?: ClassifierModel<ClassifierApi>["compat"];
|
||||
}
|
||||
|
||||
export type ProviderModelConfig = ProviderChatModelConfig | ProviderImageModelConfig | ProviderClassifierModelConfig;
|
||||
|
|
@ -324,9 +326,12 @@ function applyModelsJson(
|
|||
|
||||
const models: AnyModel[] = baseModels.map((model) => {
|
||||
const baseUrl = config.oauth === "radius" ? model.baseUrl : (config.baseUrl ?? model.baseUrl);
|
||||
return isModelType(model, "chat")
|
||||
? { ...model, baseUrl, compat: mergeCompat(model.compat, config.compat) }
|
||||
: { ...model, baseUrl };
|
||||
if (isModelType(model, "chat")) return { ...model, baseUrl, compat: mergeCompat(model.compat, config.compat) };
|
||||
// Classifier APIs read their own compat fields, such as how a System One service takes images.
|
||||
if (isModelType(model, "classifier") && config.compat) {
|
||||
return { ...model, baseUrl, compat: { ...model.compat, ...config.compat } };
|
||||
}
|
||||
return { ...model, baseUrl };
|
||||
});
|
||||
for (const definition of config.models ?? []) {
|
||||
const existingIndex = models.findIndex((model) => isModelType(model, "chat") && model.id === definition.id);
|
||||
|
|
|
|||
|
|
@ -159,7 +159,9 @@ export function createGrepToolDefinition(
|
|||
return lines;
|
||||
};
|
||||
|
||||
const args: string[] = ["--json", "--line-number", "--color=never", "--hidden"];
|
||||
// --no-config: the tool contract (literal, glob, limit, ...) must not depend on
|
||||
// ambient user config like RIPGREP_CONFIG_PATH. Users can customize via bash or extensions.
|
||||
const args: string[] = ["--no-config", "--json", "--line-number", "--color=never", "--hidden"];
|
||||
if (ignoreCase) args.push("--ignore-case");
|
||||
if (literal) args.push("--fixed-strings");
|
||||
if (glob) args.push("--glob", glob);
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ export function combineUsage(first: Usage, second: Usage): Usage {
|
|||
cacheRead: first.cost.cacheRead + second.cost.cacheRead,
|
||||
cacheWrite: first.cost.cacheWrite + second.cost.cacheWrite,
|
||||
total: first.cost.total + second.cost.total,
|
||||
// A sum is provider-reported only when every part is.
|
||||
totalFromProvider: first.cost.totalFromProvider === true && second.cost.totalFromProvider === true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ export class AssistantMessageComponent extends Container {
|
|||
private lastMessage?: AssistantMessage;
|
||||
private hasToolCalls = false;
|
||||
private isStreaming = false;
|
||||
private messageId?: string;
|
||||
private timestamp?: string;
|
||||
private thinkingVisibilityOverrides = new Map<number, boolean>();
|
||||
|
||||
constructor(
|
||||
|
|
@ -30,6 +32,8 @@ export class AssistantMessageComponent extends Container {
|
|||
hiddenThinkingLabel = "Thinking...",
|
||||
outputPad = 1,
|
||||
markdownTransformers: readonly MarkdownTransformer[] = [],
|
||||
messageId?: string,
|
||||
timestamp?: string,
|
||||
) {
|
||||
super();
|
||||
|
||||
|
|
@ -38,6 +42,8 @@ export class AssistantMessageComponent extends Container {
|
|||
this.hiddenThinkingLabel = hiddenThinkingLabel;
|
||||
this.outputPad = outputPad;
|
||||
this.markdownTransformers = markdownTransformers;
|
||||
this.messageId = messageId;
|
||||
this.timestamp = timestamp;
|
||||
|
||||
// Container for text/thinking content
|
||||
this.contentContainer = new Container();
|
||||
|
|
@ -112,7 +118,13 @@ export class AssistantMessageComponent extends Container {
|
|||
// Set paddingY=0 to avoid extra spacing before tool executions
|
||||
this.contentContainer.addChild(
|
||||
new Markdown(content.text.trim(), this.outputPad, 0, this.markdownTheme, undefined, {
|
||||
transform: createMarkdownTransform("assistant", this.isStreaming, this.markdownTransformers),
|
||||
transform: createMarkdownTransform(
|
||||
"assistant",
|
||||
this.isStreaming,
|
||||
this.markdownTransformers,
|
||||
this.messageId,
|
||||
this.timestamp,
|
||||
),
|
||||
}),
|
||||
);
|
||||
} else if (content.type === "thinking") {
|
||||
|
|
@ -157,6 +169,8 @@ export class AssistantMessageComponent extends Container {
|
|||
"assistant-thinking",
|
||||
this.isStreaming,
|
||||
this.markdownTransformers,
|
||||
this.messageId,
|
||||
this.timestamp,
|
||||
),
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
import type { AuthInfoLink, OAuthDeviceCodeInfo } from "@earendil-works/pi-ai";
|
||||
import { Container, type Focusable, getKeybindings, Input, Spacer, Text, type TUI } from "@earendil-works/pi-tui";
|
||||
import {
|
||||
Container,
|
||||
type Focusable,
|
||||
getKeybindings,
|
||||
Input,
|
||||
Spacer,
|
||||
Text,
|
||||
TruncatedText,
|
||||
type TUI,
|
||||
} from "@earendil-works/pi-tui";
|
||||
import { copyToClipboard } from "../../../utils/clipboard.ts";
|
||||
import { openBrowser } from "../../../utils/open-browser.ts";
|
||||
import { theme } from "../theme/theme.ts";
|
||||
import { AuthUrlComponent } from "./auth-url.ts";
|
||||
|
|
@ -18,6 +28,8 @@ export class LoginDialogComponent extends Container implements Focusable {
|
|||
private abortController = new AbortController();
|
||||
private inputResolver?: (value: string) => void;
|
||||
private inputRejecter?: (error: Error) => void;
|
||||
private deviceCodeAction?: { info: OAuthDeviceCodeInfo; hint: Container };
|
||||
private deviceCodeHint?: Container;
|
||||
private onComplete: (success: boolean, message?: string) => void;
|
||||
|
||||
// Focusable implementation - propagate to input for IME cursor positioning
|
||||
|
|
@ -83,7 +95,16 @@ export class LoginDialogComponent extends Container implements Focusable {
|
|||
);
|
||||
}
|
||||
|
||||
private clearDeviceCodeAction(): void {
|
||||
if (this.deviceCodeHint) {
|
||||
this.contentContainer.removeChild(this.deviceCodeHint);
|
||||
}
|
||||
this.deviceCodeHint = undefined;
|
||||
this.deviceCodeAction = undefined;
|
||||
}
|
||||
|
||||
private cancel(): void {
|
||||
this.clearDeviceCodeAction();
|
||||
this.abortController.abort();
|
||||
if (this.inputRejecter) {
|
||||
this.inputRejecter(new Error("Login cancelled"));
|
||||
|
|
@ -97,6 +118,7 @@ export class LoginDialogComponent extends Container implements Focusable {
|
|||
* Called by onAuth callback - show URL and optional instructions
|
||||
*/
|
||||
showAuth(url: string, instructions?: string): void {
|
||||
this.clearDeviceCodeAction();
|
||||
this.contentContainer.clear();
|
||||
this.contentContainer.addChild(new Spacer(1));
|
||||
this.authUrl = new AuthUrlComponent(this.tui, url);
|
||||
|
|
@ -116,6 +138,12 @@ export class LoginDialogComponent extends Container implements Focusable {
|
|||
*/
|
||||
showDeviceCode(info: OAuthDeviceCodeInfo): void {
|
||||
this.authUrl = undefined;
|
||||
info = {
|
||||
...info,
|
||||
openBrowserOnConfirm: info.openBrowserOnConfirm ?? true,
|
||||
copyCodeOnConfirm: info.copyCodeOnConfirm ?? true,
|
||||
};
|
||||
this.clearDeviceCodeAction();
|
||||
this.contentContainer.clear();
|
||||
this.contentContainer.addChild(new Spacer(1));
|
||||
const linkedUrl = `\x1b]8;;${info.verificationUri}\x07${info.verificationUri}\x1b]8;;\x07`;
|
||||
|
|
@ -127,13 +155,52 @@ export class LoginDialogComponent extends Container implements Focusable {
|
|||
this.contentContainer.addChild(new Spacer(1));
|
||||
this.contentContainer.addChild(new Text(theme.fg("warning", `Enter code: ${info.userCode}`), 1, 0));
|
||||
|
||||
if (!this.signal.aborted && (info.openBrowserOnConfirm || info.copyCodeOnConfirm)) {
|
||||
const actionLabel = info.openBrowserOnConfirm
|
||||
? info.copyCodeOnConfirm
|
||||
? "to open browser and copy code"
|
||||
: "to open browser"
|
||||
: "to copy code";
|
||||
// Reuse this single row for progress/result text, including in narrow terminals.
|
||||
const hint = new Container();
|
||||
hint.addChild(new TruncatedText(`(${keyHint("tui.select.confirm", actionLabel)})`, 1, 0));
|
||||
this.contentContainer.addChild(hint);
|
||||
this.deviceCodeHint = hint;
|
||||
this.deviceCodeAction = { info, hint };
|
||||
}
|
||||
this.tui.requestRender();
|
||||
}
|
||||
|
||||
private setDeviceCodeStatus(hint: Container, message: string): void {
|
||||
if (this.signal.aborted || !this.focused || !this.contentContainer.children.includes(hint)) return;
|
||||
hint.children = [new TruncatedText(theme.fg("dim", message), 1, 0)];
|
||||
this.tui.requestRender();
|
||||
}
|
||||
|
||||
private activateDeviceCode(info: OAuthDeviceCodeInfo, hint: Container): void {
|
||||
this.setDeviceCodeStatus(hint, info.copyCodeOnConfirm ? "Copying code..." : "Browser open requested");
|
||||
if (info.copyCodeOnConfirm) {
|
||||
// Do not delay polling or browser opening while clipboard tools are running.
|
||||
void copyToClipboard(info.userCode)
|
||||
.then(() => this.setDeviceCodeStatus(hint, "Code copied to clipboard"))
|
||||
.catch(() => this.setDeviceCodeStatus(hint, "Copy code manually"));
|
||||
}
|
||||
if (info.openBrowserOnConfirm) {
|
||||
try {
|
||||
// The shared launcher also opens files; device-code login must only open web URLs.
|
||||
const url = new URL(info.verificationUri);
|
||||
if (url.protocol === "https:" || url.protocol === "http:") openBrowser(url.href);
|
||||
} catch {
|
||||
// Invalid URLs or launcher failures must not interrupt login.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show input for manual code/URL entry (for callback server providers)
|
||||
*/
|
||||
showManualInput(prompt: string): Promise<string> {
|
||||
this.clearDeviceCodeAction();
|
||||
this.input.setValue("");
|
||||
this.contentContainer.addChild(new Spacer(1));
|
||||
this.contentContainer.addChild(new Text(theme.fg("dim", prompt), 1, 0));
|
||||
|
|
@ -152,6 +219,7 @@ export class LoginDialogComponent extends Container implements Focusable {
|
|||
* Note: Does NOT clear content, appends to existing (preserves URL from showAuth)
|
||||
*/
|
||||
showPrompt(message: string, placeholder?: string): Promise<string> {
|
||||
this.clearDeviceCodeAction();
|
||||
this.contentContainer.addChild(new Spacer(1));
|
||||
this.contentContainer.addChild(new Text(theme.fg("text", message), 1, 0));
|
||||
if (placeholder) {
|
||||
|
|
@ -178,6 +246,7 @@ export class LoginDialogComponent extends Container implements Focusable {
|
|||
/** Show informational text before another login step. */
|
||||
showDetails(lines: string[]): void {
|
||||
this.authUrl = undefined;
|
||||
this.clearDeviceCodeAction();
|
||||
this.contentContainer.clear();
|
||||
this.contentContainer.addChild(new Spacer(1));
|
||||
for (const line of lines) {
|
||||
|
|
@ -232,6 +301,21 @@ export class LoginDialogComponent extends Container implements Focusable {
|
|||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.deviceCodeAction &&
|
||||
!this.inputResolver &&
|
||||
!this.signal.aborted &&
|
||||
this.focused &&
|
||||
kb.matches(data, "tui.select.confirm")
|
||||
) {
|
||||
const action = this.deviceCodeAction;
|
||||
// Consume once so a repeated Enter cannot launch multiple browsers or clipboard writes.
|
||||
this.deviceCodeAction = undefined;
|
||||
this.activateDeviceCode(action.info, action.hint);
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass to input
|
||||
this.input.handleInput(data);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,15 @@ export function createMarkdownTransform(
|
|||
messageType: MarkdownTransformContext["messageType"],
|
||||
isStreaming: boolean,
|
||||
transformers: readonly MarkdownTransformer[],
|
||||
messageId?: string,
|
||||
timestamp?: string,
|
||||
): (markdown: string, availableWidth: number) => string {
|
||||
return (markdown, availableWidth) =>
|
||||
applyMarkdownTransformers(markdown, { messageType, isStreaming, availableWidth }, transformers);
|
||||
applyMarkdownTransformers(
|
||||
markdown,
|
||||
{ messageType, isStreaming, availableWidth, messageId, timestamp },
|
||||
transformers,
|
||||
);
|
||||
}
|
||||
|
||||
function applyMarkdownTransformers(
|
||||
|
|
|
|||
|
|
@ -23,8 +23,83 @@ function normalizeWhitespaceLower(text: string): string {
|
|||
return text.toLowerCase().replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function getSessionSearchText(session: SessionInfo): string {
|
||||
return `${session.id} ${session.name ?? ""} ${session.allMessagesText} ${session.cwd}`;
|
||||
/**
|
||||
* Match tiers, best first. Each token is scored by the best field it matches; tiers dominate
|
||||
* fuzzy scores so an exact hit anywhere beats a scattered subsequence in a short field.
|
||||
*/
|
||||
const TIER_NAME = 0; // query text appears in the session name
|
||||
const TIER_TEXT = 1; // query text appears in the session id, messages, or cwd
|
||||
const TIER_FUZZY = 2; // query characters appear scattered in the session name or cwd
|
||||
const TIER_WEIGHT = 1_000_000;
|
||||
|
||||
interface LowercaseCache {
|
||||
source: string;
|
||||
lower: string;
|
||||
normalized?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lowercased message text per session. allMessagesText holds every user/assistant message and
|
||||
* can be megabytes, so lowercase it once instead of on every keystroke.
|
||||
*/
|
||||
const messageTextCache = new WeakMap<SessionInfo, LowercaseCache>();
|
||||
|
||||
function getMessageText(session: SessionInfo): LowercaseCache {
|
||||
let cached = messageTextCache.get(session);
|
||||
if (!cached || cached.source !== session.allMessagesText) {
|
||||
cached = { source: session.allMessagesText, lower: session.allMessagesText.toLowerCase() };
|
||||
messageTextCache.set(session, cached);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
function getNormalizedMessageText(session: SessionInfo): string {
|
||||
const cached = getMessageText(session);
|
||||
cached.normalized ??= cached.lower.replace(/\s+/g, " ").trim();
|
||||
return cached.normalized;
|
||||
}
|
||||
|
||||
function tierScore(tier: number, score = 0): number {
|
||||
return tier * TIER_WEIGHT + score;
|
||||
}
|
||||
|
||||
/** Exact (case-insensitive) text match. Messages and ids are only matched exactly: scattered matches in them are noise. */
|
||||
function matchLiteralToken(session: SessionInfo, value: string): number | undefined {
|
||||
const name = session.name?.toLowerCase() ?? "";
|
||||
if (name.includes(value)) return tierScore(TIER_NAME, fuzzyMatch(value, name).score);
|
||||
if (session.id.toLowerCase().includes(value)) return tierScore(TIER_TEXT);
|
||||
if (getMessageText(session).lower.includes(value)) return tierScore(TIER_TEXT);
|
||||
if (session.cwd.toLowerCase().includes(value)) return tierScore(TIER_TEXT);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function matchFuzzyToken(session: SessionInfo, value: string): number | undefined {
|
||||
const literal = matchLiteralToken(session, value.toLowerCase());
|
||||
if (literal !== undefined) return literal;
|
||||
|
||||
let best: number | undefined;
|
||||
for (const field of [session.name ?? "", session.cwd]) {
|
||||
const m = fuzzyMatch(value, field);
|
||||
if (m.matches && (best === undefined || m.score < best)) best = m.score;
|
||||
}
|
||||
return best === undefined ? undefined : tierScore(TIER_FUZZY, best);
|
||||
}
|
||||
|
||||
function matchPhraseToken(session: SessionInfo, phrase: string): number | undefined {
|
||||
const name = normalizeWhitespaceLower(session.name ?? "");
|
||||
if (name.includes(phrase)) return tierScore(TIER_NAME, fuzzyMatch(phrase, name).score);
|
||||
if (session.id.toLowerCase().includes(phrase)) return tierScore(TIER_TEXT);
|
||||
if (getNormalizedMessageText(session).includes(phrase)) return tierScore(TIER_TEXT);
|
||||
if (normalizeWhitespaceLower(session.cwd).includes(phrase)) return tierScore(TIER_TEXT);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function matchRegex(session: SessionInfo, regex: RegExp): number | undefined {
|
||||
if (session.name && regex.test(session.name)) return tierScore(TIER_NAME);
|
||||
if (regex.test(session.id) || regex.test(session.allMessagesText) || regex.test(session.cwd)) {
|
||||
return tierScore(TIER_TEXT);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function hasSessionName(session: SessionInfo): boolean {
|
||||
|
|
@ -113,41 +188,32 @@ export function parseSearchQuery(query: string): ParsedSearchQuery {
|
|||
return { mode: "tokens", tokens, regex: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Each field is matched separately, so a token never matches across field boundaries.
|
||||
* Different tokens may match different fields.
|
||||
*/
|
||||
export function matchSession(session: SessionInfo, parsed: ParsedSearchQuery): MatchResult {
|
||||
const text = getSessionSearchText(session);
|
||||
|
||||
if (parsed.mode === "regex") {
|
||||
if (!parsed.regex) {
|
||||
return { matches: false, score: 0 };
|
||||
}
|
||||
const idx = text.search(parsed.regex);
|
||||
if (idx < 0) return { matches: false, score: 0 };
|
||||
return { matches: true, score: idx * 0.1 };
|
||||
}
|
||||
|
||||
if (parsed.tokens.length === 0) {
|
||||
return { matches: true, score: 0 };
|
||||
const score = matchRegex(session, parsed.regex);
|
||||
return score === undefined ? { matches: false, score: 0 } : { matches: true, score };
|
||||
}
|
||||
|
||||
let totalScore = 0;
|
||||
let normalizedText: string | null = null;
|
||||
|
||||
for (const token of parsed.tokens) {
|
||||
let score: number | undefined;
|
||||
if (token.kind === "phrase") {
|
||||
if (normalizedText === null) {
|
||||
normalizedText = normalizeWhitespaceLower(text);
|
||||
}
|
||||
const phrase = normalizeWhitespaceLower(token.value);
|
||||
if (!phrase) continue;
|
||||
const idx = normalizedText.indexOf(phrase);
|
||||
if (idx < 0) return { matches: false, score: 0 };
|
||||
totalScore += idx * 0.1;
|
||||
continue;
|
||||
score = matchPhraseToken(session, phrase);
|
||||
} else {
|
||||
score = matchFuzzyToken(session, token.value);
|
||||
}
|
||||
|
||||
const m = fuzzyMatch(token.value, text);
|
||||
if (!m.matches) return { matches: false, score: 0 };
|
||||
totalScore += m.score;
|
||||
if (score === undefined) return { matches: false, score: 0 };
|
||||
totalScore += score;
|
||||
}
|
||||
|
||||
return { matches: true, score: totalScore };
|
||||
|
|
|
|||
|
|
@ -15,18 +15,24 @@ export class UserMessageComponent extends Container {
|
|||
private markdownTheme: MarkdownTheme;
|
||||
private outputPad: number;
|
||||
private markdownTransformers: readonly MarkdownTransformer[];
|
||||
private messageId?: string;
|
||||
private timestamp?: string;
|
||||
|
||||
constructor(
|
||||
text: string,
|
||||
markdownTheme: MarkdownTheme = getMarkdownTheme(),
|
||||
outputPad = 1,
|
||||
markdownTransformers: readonly MarkdownTransformer[] = [],
|
||||
messageId?: string,
|
||||
timestamp?: string,
|
||||
) {
|
||||
super();
|
||||
this.text = text;
|
||||
this.markdownTheme = markdownTheme;
|
||||
this.outputPad = outputPad;
|
||||
this.markdownTransformers = markdownTransformers;
|
||||
this.messageId = messageId;
|
||||
this.timestamp = timestamp;
|
||||
this.rebuild();
|
||||
}
|
||||
|
||||
|
|
@ -52,7 +58,13 @@ export class UserMessageComponent extends Container {
|
|||
{
|
||||
preserveOrderedListMarkers: true,
|
||||
preserveBackslashEscapes: true,
|
||||
transform: createMarkdownTransform("user", false, this.markdownTransformers),
|
||||
transform: createMarkdownTransform(
|
||||
"user",
|
||||
false,
|
||||
this.markdownTransformers,
|
||||
this.messageId,
|
||||
this.timestamp,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3834,7 +3834,10 @@ export class InteractiveMode {
|
|||
this.chatContainer.addChild(component);
|
||||
}
|
||||
|
||||
private addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void {
|
||||
private addMessageToChat(
|
||||
message: AgentMessage,
|
||||
options?: { populateHistory?: boolean; messageId?: string; timestamp?: string },
|
||||
): void {
|
||||
switch (message.role) {
|
||||
case "bashExecution": {
|
||||
const component = new BashExecutionComponent(
|
||||
|
|
@ -3917,6 +3920,8 @@ export class InteractiveMode {
|
|||
this.getMarkdownThemeWithSettings(),
|
||||
this.outputPad,
|
||||
this.getMarkdownTransformers(),
|
||||
options?.messageId,
|
||||
options?.timestamp,
|
||||
);
|
||||
this.chatContainer.addChild(userComponent);
|
||||
}
|
||||
|
|
@ -3926,6 +3931,8 @@ export class InteractiveMode {
|
|||
this.getMarkdownThemeWithSettings(),
|
||||
this.outputPad,
|
||||
this.getMarkdownTransformers(),
|
||||
options?.messageId,
|
||||
options?.timestamp,
|
||||
);
|
||||
this.chatContainer.addChild(userComponent);
|
||||
}
|
||||
|
|
@ -3943,6 +3950,8 @@ export class InteractiveMode {
|
|||
this.hiddenThinkingLabel,
|
||||
this.outputPad,
|
||||
this.getMarkdownTransformers(),
|
||||
options?.messageId,
|
||||
options?.timestamp,
|
||||
);
|
||||
this.chatContainer.addChild(assistantComponent);
|
||||
break;
|
||||
|
|
@ -3959,6 +3968,7 @@ export class InteractiveMode {
|
|||
|
||||
private renderSessionItems(
|
||||
items: readonly RenderSessionItem[],
|
||||
messageMeta?: Map<AgentMessage, { id: string; timestamp: string }>,
|
||||
options: { updateFooter?: boolean; populateHistory?: boolean } = {},
|
||||
): void {
|
||||
this.pendingTools.clear();
|
||||
|
|
@ -3991,7 +4001,11 @@ export class InteractiveMode {
|
|||
const message = item;
|
||||
// Assistant messages need special handling for tool calls
|
||||
if (message.role === "assistant") {
|
||||
this.addMessageToChat(message);
|
||||
const meta = messageMeta?.get(message);
|
||||
this.addMessageToChat(message, {
|
||||
messageId: meta?.id,
|
||||
timestamp: meta?.timestamp,
|
||||
});
|
||||
// Render tool call components
|
||||
for (const content of message.content) {
|
||||
if (content.type === "toolCall") {
|
||||
|
|
@ -4041,7 +4055,12 @@ export class InteractiveMode {
|
|||
}
|
||||
} else {
|
||||
// All other messages use standard rendering
|
||||
this.addMessageToChat(message, options);
|
||||
const meta = messageMeta?.get(message);
|
||||
this.addMessageToChat(message, {
|
||||
...options,
|
||||
messageId: meta?.id,
|
||||
timestamp: meta?.timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4063,17 +4082,21 @@ export class InteractiveMode {
|
|||
): void {
|
||||
// Selection coordinates point into the transcript being replaced (#9311).
|
||||
if (this.renderer instanceof TuiAltScreen) this.renderer.resetTextSelection();
|
||||
const messageMeta = new Map<AgentMessage, { id: string; timestamp: string }>();
|
||||
const items = entries.flatMap((entry): RenderSessionItem[] => {
|
||||
if (entry.type === "custom" || (entry.type === "usage" && entry.kind === "cache_warm")) {
|
||||
return [entry];
|
||||
}
|
||||
const messages = sessionEntryToContextMessages(entry);
|
||||
for (const msg of messages) {
|
||||
messageMeta.set(msg, { id: entry.id, timestamp: entry.timestamp });
|
||||
}
|
||||
if ((entry.type === "compaction" || entry.type === "branch_summary") && entry.usage && messages.length > 0) {
|
||||
return [...messages, { type: "compaction_cost", kind: entry.type, usage: entry.usage }];
|
||||
}
|
||||
return messages;
|
||||
});
|
||||
this.renderSessionItems(items, options);
|
||||
this.renderSessionItems(items, messageMeta, options);
|
||||
}
|
||||
|
||||
private addCacheWarmingUsage(entry: UsageEntry): void {
|
||||
|
|
|
|||
|
|
@ -88,10 +88,11 @@ async function fetchInstallerArtifact(url: string, label: string): Promise<strin
|
|||
}
|
||||
|
||||
async function runManagedNpmCi(stageDir: string): Promise<void> {
|
||||
// No age-gate override: the release lockfile pins every package, so npm never
|
||||
// resolves versions and a configured min-release-age does not apply.
|
||||
const args = [
|
||||
"ci",
|
||||
"--ignore-scripts",
|
||||
"--min-release-age=0",
|
||||
"--omit=dev",
|
||||
"--include=optional",
|
||||
"--no-fund",
|
||||
|
|
|
|||
|
|
@ -236,6 +236,19 @@ export function killProcessTree(pid: number): void {
|
|||
// Ignore errors if taskkill fails.
|
||||
}
|
||||
} else {
|
||||
if (process.platform === "darwin") {
|
||||
// macOS signals group members individually after releasing the group lock. A killed
|
||||
// child can wake its waiting shell before that shell is signalled, letting `a; b`
|
||||
// start b during cancellation (#11252). Stop the group first to reduce this race;
|
||||
// this is best-effort, not an atomic freeze. SIGKILL also kills stopped processes.
|
||||
// Linux holds tasklist_lock across group signalling; child exit notification needs
|
||||
// its write lock, so the shell is signalled before observing the killed child's exit.
|
||||
try {
|
||||
process.kill(-pid, "SIGSTOP");
|
||||
} catch {
|
||||
// A failed stop must not prevent the kill attempt or its single-process fallback.
|
||||
}
|
||||
}
|
||||
// Use SIGKILL on Unix/Linux/Mac
|
||||
try {
|
||||
process.kill(-pid, "SIGKILL");
|
||||
|
|
|
|||
|
|
@ -314,7 +314,7 @@ describe("generateSummary reasoning options", () => {
|
|||
input: 20,
|
||||
output: 20,
|
||||
totalTokens: 40,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, totalFromProvider: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -309,15 +309,31 @@ describe("defaultTools setting", () => {
|
|||
});
|
||||
};
|
||||
|
||||
const toolReferences = (names: string[]) =>
|
||||
names.map((name) => ({ name, description: name, parameters: Type.Object({}) }));
|
||||
|
||||
/** Append a system message that adds and removes tools, as a run records loadout changes. */
|
||||
function recordLoadoutChange(sessionManager: SessionManager, added: string[], removed: string[] = []): string {
|
||||
return sessionManager.appendMessage({
|
||||
role: "system",
|
||||
content: "",
|
||||
...(added.length > 0 ? { toolsAdded: toolReferences(added) } : {}),
|
||||
...(removed.length > 0 ? { toolsRemoved: toolReferences(removed) } : {}),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/** A session whose transcript records the loadout `toolNames`, as a run records it. */
|
||||
function createRecordedSession(toolNames: string[]): SessionManager {
|
||||
const sessionManager = SessionManager.inMemory(tempDir);
|
||||
sessionManager.appendMessage({
|
||||
role: "system",
|
||||
content: "",
|
||||
toolsAdded: toolNames.map((name) => ({ name, description: name, parameters: Type.Object({}) })),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
recordLoadoutChange(sessionManager, toolNames);
|
||||
return sessionManager;
|
||||
}
|
||||
|
||||
/** A session that recorded `read`, `grep`, and `inactive_tool`, and deactivated `active_tool`. */
|
||||
function createChangedSession(): SessionManager {
|
||||
const sessionManager = createRecordedSession(["read", "grep", "active_tool"]);
|
||||
recordLoadoutChange(sessionManager, ["inactive_tool"], ["active_tool"]);
|
||||
return sessionManager;
|
||||
}
|
||||
|
||||
|
|
@ -344,14 +360,68 @@ describe("defaultTools setting", () => {
|
|||
}
|
||||
|
||||
it("restores the loadout the transcript records", async () => {
|
||||
// active_tool was deactivated during the session, inactive_tool activated.
|
||||
const session = await resume(createRecordedSession(["read", "grep", "inactive_tool"]));
|
||||
const session = await resume(createChangedSession());
|
||||
expect(session.getActiveToolNames().sort()).toEqual(["grep", "inactive_tool", "read"]);
|
||||
session.dispose();
|
||||
});
|
||||
|
||||
// #11062
|
||||
it("activates extension tools the transcript never added or removed", async () => {
|
||||
// Built-in tools and tools registered inactive stay inactive.
|
||||
const session = await resume(createRecordedSession(["read", "grep"]));
|
||||
expect(session.getActiveToolNames().sort()).toEqual(["active_tool", "grep", "read"]);
|
||||
session.dispose();
|
||||
});
|
||||
|
||||
it("keeps tools removed before a compaction inactive", async () => {
|
||||
const sessionManager = createChangedSession();
|
||||
const kept = sessionManager.appendMessage({ role: "user", content: "hi", timestamp: Date.now() });
|
||||
sessionManager.appendCompaction("summary", kept, 1000);
|
||||
const session = await resume(sessionManager);
|
||||
expect(session.getActiveToolNames().sort()).toEqual(["grep", "inactive_tool", "read"]);
|
||||
session.dispose();
|
||||
});
|
||||
|
||||
// #11075
|
||||
it("restores the target branch's loadout on tree navigation", async () => {
|
||||
// The target branch never mentions active_tool, so it is active there. It removed grep.
|
||||
const sessionManager = createRecordedSession(["read", "grep"]);
|
||||
recordLoadoutChange(sessionManager, [], ["grep"]);
|
||||
const target = sessionManager.appendMessage({ role: "user", content: "first", timestamp: Date.now() });
|
||||
recordLoadoutChange(sessionManager, ["grep"], ["active_tool"]);
|
||||
const session = await resume(sessionManager);
|
||||
expect(session.getActiveToolNames().sort()).toEqual(["grep", "read"]);
|
||||
|
||||
await session.navigateTree(target, { summarize: false });
|
||||
expect(session.getActiveToolNames().sort()).toEqual(["active_tool", "read"]);
|
||||
session.dispose();
|
||||
});
|
||||
|
||||
it("keeps registered tools outside the tools allowlist inactive on tree navigation", async () => {
|
||||
// An allowlist without mcp__ entries keeps MCP tools registered but inactive.
|
||||
const sessionManager = createRecordedSession(["read"]);
|
||||
const target = sessionManager.appendMessage({ role: "user", content: "first", timestamp: Date.now() });
|
||||
const session = await resume(sessionManager, {
|
||||
tools: ["read"],
|
||||
customTools: [
|
||||
{
|
||||
name: "mcp__server__lookup",
|
||||
label: "Lookup",
|
||||
description: "MCP tool",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(session.getActiveToolNames()).toEqual(["read"]);
|
||||
|
||||
await session.navigateTree(target, { summarize: false });
|
||||
expect(session.getActiveToolNames()).toEqual(["read"]);
|
||||
session.dispose();
|
||||
});
|
||||
|
||||
it("does not restore excluded tools", async () => {
|
||||
const session = await resume(createRecordedSession(["read", "grep", "inactive_tool"]), {
|
||||
const session = await resume(createChangedSession(), {
|
||||
excludeTools: ["inactive_tool"],
|
||||
});
|
||||
expect(session.getActiveToolNames().sort()).toEqual(["grep", "read"]);
|
||||
|
|
@ -359,19 +429,17 @@ describe("defaultTools setting", () => {
|
|||
});
|
||||
|
||||
it("uses explicit tool options instead of the recorded loadout", async () => {
|
||||
const recorded = ["read", "grep", "inactive_tool"];
|
||||
|
||||
const allowlisted = await resume(createRecordedSession(recorded), { tools: ["read", "write"] });
|
||||
const allowlisted = await resume(createChangedSession(), { tools: ["read", "write"] });
|
||||
expect(allowlisted.getActiveToolNames()).toEqual(["read", "write"]);
|
||||
allowlisted.dispose();
|
||||
|
||||
const builtinless = await resume(createRecordedSession(recorded), { noTools: "builtin" });
|
||||
const builtinless = await resume(createChangedSession(), { noTools: "builtin" });
|
||||
expect(builtinless.getActiveToolNames()).toEqual(["active_tool"]);
|
||||
builtinless.dispose();
|
||||
});
|
||||
|
||||
it("applies +name and -name tool options to the recorded loadout", async () => {
|
||||
const session = await resume(createRecordedSession(["read", "grep", "inactive_tool"]), {
|
||||
const session = await resume(createChangedSession(), {
|
||||
tools: ["-grep", "+write"],
|
||||
});
|
||||
expect(session.getActiveToolNames().sort()).toEqual(["inactive_tool", "read", "write"]);
|
||||
|
|
|
|||
22
packages/coding-agent/test/exec.test.ts
Normal file
22
packages/coding-agent/test/exec.test.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { execCommand } from "../src/core/exec.ts";
|
||||
|
||||
describe("execCommand", () => {
|
||||
// Regression test for https://github.com/earendil-works/pi/issues/11085
|
||||
it("reports spawn errors in stderr", async () => {
|
||||
const result = await execCommand("pi-command-that-does-not-exist-11085", [], process.cwd());
|
||||
|
||||
expect(result).toMatchObject({
|
||||
code: 1,
|
||||
killed: false,
|
||||
stdout: "",
|
||||
});
|
||||
expect(result.stderr).toMatch(/ENOENT/);
|
||||
});
|
||||
|
||||
it("keeps stderr empty when a command exits silently", async () => {
|
||||
const result = await execCommand(process.execPath, ["-e", "process.exit(1)"], process.cwd());
|
||||
|
||||
expect(result).toEqual({ stdout: "", stderr: "", code: 1, killed: false });
|
||||
});
|
||||
});
|
||||
52
packages/coding-agent/test/export-html-cost.test.ts
Normal file
52
packages/coding-agent/test/export-html-cost.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type ExportStats = {
|
||||
cost: {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
total: number;
|
||||
};
|
||||
};
|
||||
|
||||
describe("export HTML cost", () => {
|
||||
it("uses recorded totals while falling back to component sums for old sessions", () => {
|
||||
// Regression test for #9980: provider-reported totals may differ from catalog-estimated components.
|
||||
const templateJs = readFileSync(new URL("../src/core/export-html/template.js", import.meta.url), "utf-8");
|
||||
const functionStart = templateJs.indexOf("function computeStats(entryList)");
|
||||
const functionEnd = templateJs.indexOf("const globalStats = computeStats(entries);");
|
||||
expect(functionStart).toBeGreaterThanOrEqual(0);
|
||||
expect(functionEnd).toBeGreaterThan(functionStart);
|
||||
|
||||
const context: { result?: ExportStats } = {};
|
||||
runInNewContext(
|
||||
`${templateJs.slice(functionStart, functionEnd)}
|
||||
result = computeStats(${JSON.stringify([
|
||||
{
|
||||
type: "message",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
usage: { cost: { input: 0.1, output: 0.2, cacheRead: 0, cacheWrite: 0, total: 0.9 } },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
usage: { cost: { input: 0.4, output: 0.1, cacheRead: 0, cacheWrite: 0 } },
|
||||
},
|
||||
},
|
||||
])});`,
|
||||
context,
|
||||
);
|
||||
|
||||
expect(context.result?.cost.input).toBeCloseTo(0.5);
|
||||
expect(context.result?.cost.output).toBeCloseTo(0.3);
|
||||
expect(context.result?.cost.total).toBeCloseTo(1.4);
|
||||
});
|
||||
});
|
||||
93
packages/coding-agent/test/export-html-deep-tree.test.ts
Normal file
93
packages/coding-agent/test/export-html-deep-tree.test.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createContext, runInContext } from "node:vm";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { exportFromFile } from "../src/core/export-html/index.ts";
|
||||
import { CURRENT_SESSION_VERSION, type SessionHeader, type SessionMessageEntry } from "../src/core/session-manager.ts";
|
||||
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
|
||||
|
||||
interface TreeNode {
|
||||
entry: { id: string };
|
||||
children: TreeNode[];
|
||||
}
|
||||
|
||||
interface TreeApi {
|
||||
buildTree: () => TreeNode[];
|
||||
buildActivePathIds: (targetId: string) => Set<string>;
|
||||
flattenTree: (roots: TreeNode[], activePathIds: Set<string>) => Array<{ node: TreeNode }>;
|
||||
findNewestLeaf: (nodeId: string) => string;
|
||||
getPath: (targetId: string) => Array<{ id: string }>;
|
||||
}
|
||||
|
||||
function loadTreeApi(html: string): TreeApi {
|
||||
const sessionData = html.match(/<script id="session-data" type="application\/json">([^<]+)<\/script>/)?.[1];
|
||||
const scripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)].map((match) => match[1]);
|
||||
const viewer = scripts.pop();
|
||||
if (!sessionData || !viewer) throw new Error("Missing exported session data or viewer script");
|
||||
const context = createContext({
|
||||
atob,
|
||||
TextDecoder,
|
||||
URLSearchParams,
|
||||
document: { getElementById: () => ({ textContent: sessionData }), querySelector: () => null },
|
||||
window: { location: { search: "" } },
|
||||
});
|
||||
return runInContext(
|
||||
viewer.replace(
|
||||
"let treeNodeMap = null;",
|
||||
"let treeNodeMap = null; return { buildTree, buildActivePathIds, flattenTree, findNewestLeaf, getPath };",
|
||||
),
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
// #11225
|
||||
describe("HTML export deep session tree", () => {
|
||||
const depth = 20_000;
|
||||
let tempDir: string;
|
||||
let api: TreeApi;
|
||||
|
||||
beforeAll(async () => {
|
||||
initTheme("dark");
|
||||
tempDir = mkdtempSync(join(tmpdir(), "pi-export-deep-"));
|
||||
const header = {
|
||||
type: "session",
|
||||
version: CURRENT_SESSION_VERSION,
|
||||
id: "deep-test",
|
||||
timestamp: new Date(0).toISOString(),
|
||||
cwd: tempDir,
|
||||
} satisfies SessionHeader;
|
||||
const lines = [JSON.stringify(header)];
|
||||
for (let i = 0; i < depth; i++) {
|
||||
const entry: SessionMessageEntry = {
|
||||
type: "message",
|
||||
id: `e${i}`,
|
||||
parentId: i === 0 ? null : `e${i - 1}`,
|
||||
timestamp: new Date(i * 1000).toISOString(),
|
||||
message: { role: "user", content: "x", timestamp: i * 1000 },
|
||||
};
|
||||
lines.push(JSON.stringify(entry));
|
||||
}
|
||||
const sessionPath = join(tempDir, "session.jsonl");
|
||||
writeFileSync(sessionPath, `${lines.join("\n")}\n`);
|
||||
api = loadTreeApi(readFileSync(await exportFromFile(sessionPath, join(tempDir, "out.html")), "utf8"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("builds and flattens a single-child chain deeper than the call stack", () => {
|
||||
const tree = api.buildTree();
|
||||
expect(tree).toHaveLength(1);
|
||||
const leafId = `e${depth - 1}`;
|
||||
const flat = api.flattenTree(tree, api.buildActivePathIds(leafId));
|
||||
expect(flat).toHaveLength(depth);
|
||||
expect(flat[depth - 1].node.entry.id).toBe(leafId);
|
||||
expect(api.findNewestLeaf("e0")).toBe(leafId);
|
||||
const path = api.getPath(leafId);
|
||||
expect(path).toHaveLength(depth);
|
||||
expect(path[0].id).toBe("e0");
|
||||
expect(path[depth - 1].id).toBe(leafId);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { getSystemMessageText, renderSystemMessageUpdate, type SystemMessage } from "@earendil-works/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
interface SystemHelpers {
|
||||
getSystemMessageText(message: SystemMessage): string;
|
||||
renderSystemMessageUpdate(message: SystemMessage): string;
|
||||
isLeadingSystemEntry(entry: { id: string; parentId: string | null }): boolean;
|
||||
describeSystemUpdate(message: SystemMessage): string;
|
||||
}
|
||||
|
||||
const templateJs = readFileSync(new URL("../src/core/export-html/template.js", import.meta.url), "utf-8");
|
||||
|
||||
function loadSystemHelpers(entries: unknown[]): SystemHelpers {
|
||||
const start = templateJs.indexOf("function systemContentText(content)");
|
||||
const end = templateJs.indexOf("const hasSystemEntries");
|
||||
expect(start).toBeGreaterThanOrEqual(0);
|
||||
expect(end).toBeGreaterThan(start);
|
||||
const context: { entries: unknown[]; helpers?: SystemHelpers } = { entries };
|
||||
runInNewContext(
|
||||
`const byId = new Map(entries.map(entry => [entry.id, entry]));
|
||||
${templateJs.slice(start, end)}
|
||||
helpers = { getSystemMessageText, renderSystemMessageUpdate, isLeadingSystemEntry, describeSystemUpdate };`,
|
||||
context,
|
||||
);
|
||||
return context.helpers!;
|
||||
}
|
||||
|
||||
const initial: SystemMessage = {
|
||||
role: "system",
|
||||
content: "You are a helpful assistant.",
|
||||
sections: { rules: "<rules>\nbe concise\n</rules>", project_context: "<project_context>\nv1\n</project_context>" },
|
||||
toolsAdded: [{ name: "read", description: "Read a file", parameters: { type: "object", properties: {} } as never }],
|
||||
timestamp: 1,
|
||||
};
|
||||
|
||||
const update: SystemMessage = {
|
||||
role: "system",
|
||||
content: [{ type: "text", text: "Extra instructions" }],
|
||||
sections: { project_context: "<project_context>\nv2\n</project_context>", rules: null },
|
||||
toolsAdded: [
|
||||
{ name: "bash", description: "Run a command", parameters: { type: "object", properties: {} } as never },
|
||||
],
|
||||
toolsRemoved: [{ name: "read" }],
|
||||
timestamp: 2,
|
||||
};
|
||||
|
||||
const entries = [
|
||||
{ type: "message", id: "s1", parentId: null, message: initial },
|
||||
{ type: "message", id: "u1", parentId: "s1", message: { role: "user", content: "hi" } },
|
||||
{ type: "message", id: "s2", parentId: "u1", message: update },
|
||||
];
|
||||
|
||||
// Regression tests for #11261: system messages were stored in the export but never rendered.
|
||||
describe("export HTML system messages", () => {
|
||||
it("renders system message text the same way the model receives it", () => {
|
||||
const helpers = loadSystemHelpers(entries);
|
||||
expect(helpers.getSystemMessageText(initial)).toBe(getSystemMessageText(initial));
|
||||
expect(helpers.renderSystemMessageUpdate(update)).toBe(renderSystemMessageUpdate(update));
|
||||
});
|
||||
|
||||
it("renders the first system entry on a branch as the prompt and later ones as updates", () => {
|
||||
const helpers = loadSystemHelpers(entries);
|
||||
expect(helpers.isLeadingSystemEntry(entries[0])).toBe(true);
|
||||
expect(helpers.isLeadingSystemEntry(entries[2])).toBe(false);
|
||||
expect(helpers.describeSystemUpdate(update)).toBe("instructions, project_context, -rules, tools +bash -read");
|
||||
});
|
||||
|
||||
it("treats a compaction snapshot as earlier system state", () => {
|
||||
const helpers = loadSystemHelpers([
|
||||
{ type: "compaction", id: "c1", parentId: null, systemMessage: initial },
|
||||
{ type: "message", id: "s2", parentId: "c1", message: update },
|
||||
]);
|
||||
expect(helpers.isLeadingSystemEntry({ id: "s2", parentId: "c1" })).toBe(false);
|
||||
});
|
||||
|
||||
it("renders system entries in the transcript", () => {
|
||||
expect(templateJs).toMatch(/if \(msg\.role === 'system'\) \{\s*let html = '';/);
|
||||
expect(templateJs).toContain("renderSystemPromptBlock('System Prompt', text)");
|
||||
expect(templateJs).toContain("renderToolsList('Available Tools', msg.toolsAdded)");
|
||||
});
|
||||
});
|
||||
|
|
@ -102,8 +102,15 @@ describe("InteractiveMode compaction events", () => {
|
|||
expect.objectContaining({ role: "compactionSummary", summary: "previous summary" }),
|
||||
{ type: "compaction_cost", kind: "compaction", usage: previousUsage },
|
||||
],
|
||||
expect.any(Map),
|
||||
{},
|
||||
);
|
||||
const [items, messageMeta] = fakeThis.renderSessionItems.mock.calls[0] as [
|
||||
unknown[],
|
||||
Map<unknown, { id: string; timestamp: string }>,
|
||||
];
|
||||
expect(messageMeta.get(items[0])).toEqual({ id: "current", timestamp: "2025-01-02T00:00:00Z" });
|
||||
expect(messageMeta.get(items[2])).toEqual({ id: "previous", timestamp: "2025-01-01T00:00:00Z" });
|
||||
});
|
||||
|
||||
test("renders retained entries and appends the latest summary cost at the bottom", async () => {
|
||||
|
|
|
|||
142
packages/coding-agent/test/login-dialog-device-code.test.ts
Normal file
142
packages/coding-agent/test/login-dialog-device-code.test.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { setKeybindings, type TUI } from "@earendil-works/pi-tui";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { KeybindingsManager } from "../src/core/keybindings.ts";
|
||||
import { LoginDialogComponent } from "../src/modes/interactive/components/login-dialog.ts";
|
||||
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
|
||||
import { stripAnsi } from "../src/utils/ansi.ts";
|
||||
import { copyToClipboard } from "../src/utils/clipboard.ts";
|
||||
import { openBrowser } from "../src/utils/open-browser.ts";
|
||||
|
||||
vi.mock("../src/utils/clipboard.ts", () => ({ copyToClipboard: vi.fn() }));
|
||||
vi.mock("../src/utils/open-browser.ts", () => ({ openBrowser: vi.fn() }));
|
||||
|
||||
const device = { userCode: "ABCD-EFGH", verificationUri: "https://example.com/device" };
|
||||
|
||||
function createDialog() {
|
||||
const onComplete = vi.fn();
|
||||
const dialog = new LoginDialogComponent({ requestRender: vi.fn() } as unknown as TUI, "custom-provider", onComplete);
|
||||
dialog.focused = true;
|
||||
return { dialog, onComplete };
|
||||
}
|
||||
|
||||
function render(dialog: LoginDialogComponent): string {
|
||||
return stripAnsi(dialog.render(120).join("\n"));
|
||||
}
|
||||
|
||||
// Regression #9282: device-code convenience actions require explicit confirmation.
|
||||
describe("device-code login", () => {
|
||||
beforeAll(() => initTheme("dark"));
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(copyToClipboard).mockResolvedValue(undefined);
|
||||
setKeybindings(new KeybindingsManager());
|
||||
});
|
||||
|
||||
it("opens the verification page and copies the code once after confirmation", async () => {
|
||||
const { dialog } = createDialog();
|
||||
const domain = dialog.showPrompt("GitHub Enterprise URL/domain (blank for github.com)");
|
||||
dialog.handleInput("\r");
|
||||
await expect(domain).resolves.toBe("");
|
||||
dialog.showDeviceCode(device);
|
||||
dialog.showWaiting("Waiting for authentication...");
|
||||
expect(render(dialog)).toContain("to open browser and copy code");
|
||||
expect(openBrowser).not.toHaveBeenCalled();
|
||||
expect(copyToClipboard).not.toHaveBeenCalled();
|
||||
|
||||
dialog.handleInput("\r");
|
||||
await Promise.resolve();
|
||||
dialog.handleInput("\r");
|
||||
expect(openBrowser).toHaveBeenCalledExactlyOnceWith(device.verificationUri);
|
||||
expect(copyToClipboard).toHaveBeenCalledExactlyOnceWith(device.userCode);
|
||||
expect(render(dialog)).toContain("Code copied to clipboard");
|
||||
expect(render(dialog)).toContain(`Enter code: ${device.userCode}`);
|
||||
expect(render(dialog)).toContain("Waiting for authentication...");
|
||||
expect(render(dialog)).not.toContain("to open browser and copy code");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[false, false],
|
||||
[true, false],
|
||||
[false, true],
|
||||
] as const)(
|
||||
"honors browser=%s / clipboard=%s provider overrides",
|
||||
async (openBrowserOnConfirm, copyCodeOnConfirm) => {
|
||||
const { dialog } = createDialog();
|
||||
dialog.showDeviceCode({ ...device, openBrowserOnConfirm, copyCodeOnConfirm });
|
||||
dialog.handleInput("\r");
|
||||
await Promise.resolve();
|
||||
expect(openBrowser).toHaveBeenCalledTimes(openBrowserOnConfirm ? 1 : 0);
|
||||
expect(copyToClipboard).toHaveBeenCalledTimes(copyCodeOnConfirm ? 1 : 0);
|
||||
expect(dialog.signal.aborted).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("clears the previous auth URL copy action when showing a device code", () => {
|
||||
const { dialog } = createDialog();
|
||||
dialog.showAuth("https://example.com/authorize");
|
||||
vi.mocked(openBrowser).mockClear();
|
||||
dialog.showDeviceCode(device);
|
||||
dialog.handleInput("\x18");
|
||||
expect(copyToClipboard).not.toHaveBeenCalled();
|
||||
expect(openBrowser).not.toHaveBeenCalled();
|
||||
dialog.handleInput("\r");
|
||||
expect(copyToClipboard).toHaveBeenCalledExactlyOnceWith(device.userCode);
|
||||
});
|
||||
|
||||
it("clears device actions when switching to browser authentication", () => {
|
||||
const { dialog } = createDialog();
|
||||
dialog.showDeviceCode(device);
|
||||
dialog.showAuth("https://example.com/authorize");
|
||||
vi.mocked(openBrowser).mockClear();
|
||||
dialog.handleInput("\r");
|
||||
expect(copyToClipboard).not.toHaveBeenCalled();
|
||||
expect(openBrowser).not.toHaveBeenCalled();
|
||||
dialog.handleInput("\x18");
|
||||
expect(copyToClipboard).toHaveBeenCalledExactlyOnceWith("https://example.com/authorize");
|
||||
});
|
||||
|
||||
it("clears both action types when showing details", () => {
|
||||
const { dialog } = createDialog();
|
||||
dialog.showAuth("https://example.com/authorize");
|
||||
dialog.showDetails(["Choose a login method"]);
|
||||
dialog.handleInput("\x18");
|
||||
expect(copyToClipboard).not.toHaveBeenCalled();
|
||||
dialog.showDeviceCode(device);
|
||||
dialog.showDetails(["Choose a login method"]);
|
||||
vi.mocked(openBrowser).mockClear();
|
||||
dialog.handleInput("\r");
|
||||
expect(copyToClipboard).not.toHaveBeenCalled();
|
||||
expect(openBrowser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels without opening a browser or changing the clipboard", () => {
|
||||
const { dialog, onComplete } = createDialog();
|
||||
dialog.showDeviceCode(device);
|
||||
dialog.handleInput("\x1b");
|
||||
dialog.handleInput("\r");
|
||||
expect(dialog.signal.aborted).toBe(true);
|
||||
expect(onComplete).toHaveBeenCalledExactlyOnceWith(false, "Login cancelled");
|
||||
expect(openBrowser).not.toHaveBeenCalled();
|
||||
expect(copyToClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps manual login available when browser and clipboard access fail", async () => {
|
||||
vi.mocked(openBrowser).mockImplementation(() => {
|
||||
throw new Error("Browser unavailable");
|
||||
});
|
||||
vi.mocked(copyToClipboard).mockRejectedValue(new Error("Clipboard unavailable"));
|
||||
const { dialog, onComplete } = createDialog();
|
||||
dialog.showDeviceCode(device);
|
||||
dialog.showWaiting("Waiting for authentication...");
|
||||
expect(() => dialog.handleInput("\r")).not.toThrow();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(openBrowser).toHaveBeenCalledExactlyOnceWith(device.verificationUri);
|
||||
expect(copyToClipboard).toHaveBeenCalledExactlyOnceWith(device.userCode);
|
||||
expect(render(dialog)).toContain(device.verificationUri);
|
||||
expect(render(dialog)).toContain(`Enter code: ${device.userCode}`);
|
||||
expect(render(dialog)).toContain("Copy code manually");
|
||||
expect(render(dialog)).toContain("Waiting for authentication...");
|
||||
expect(dialog.signal.aborted).toBe(false);
|
||||
expect(onComplete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { InMemoryModelsStore } from "@earendil-works/pi-ai";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { AuthStorage } from "../src/core/auth-storage.ts";
|
||||
import { ModelRuntime } from "../src/core/model-runtime.ts";
|
||||
|
||||
|
|
@ -14,14 +17,56 @@ const context = {
|
|||
},
|
||||
};
|
||||
|
||||
const image = { type: "image" as const, data: "cG5n", mimeType: "image/png" };
|
||||
|
||||
describe("ModelRuntime classifiers", () => {
|
||||
it("lists Jev separately and classifies with runtime-resolved auth", async () => {
|
||||
const runtime = await ModelRuntime.create({
|
||||
const tempDirs: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function createRuntime(modelsJson?: object): Promise<ModelRuntime> {
|
||||
let modelsPath: string | null = null;
|
||||
if (modelsJson) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pi-classifiers-"));
|
||||
tempDirs.push(dir);
|
||||
modelsPath = join(dir, "models.json");
|
||||
writeFileSync(modelsPath, JSON.stringify(modelsJson));
|
||||
}
|
||||
return ModelRuntime.create({
|
||||
credentials: AuthStorage.inMemory(),
|
||||
modelsStore: new InMemoryModelsStore(),
|
||||
modelsPath: null,
|
||||
modelsPath,
|
||||
allowModelNetwork: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** Classifies one image and returns the request body and headers the service received. */
|
||||
async function captureImageRequest(runtime: ModelRuntime, provider: string, id: string) {
|
||||
await runtime.setRuntimeApiKey(provider, "secret");
|
||||
const model = runtime.getModelOfType("classifier", provider, id)!;
|
||||
const requests: Array<{ url: string; body: Record<string, unknown>; headers: Headers }> = [];
|
||||
const result = await runtime.classify(
|
||||
model,
|
||||
{ ...context, images: [image] },
|
||||
{
|
||||
fetch: async (input, init) => {
|
||||
requests.push({
|
||||
url: String(input),
|
||||
body: JSON.parse(String(init?.body)) as Record<string, unknown>,
|
||||
headers: new Headers(init?.headers),
|
||||
});
|
||||
return Response.json({ answers: { approved: { type: "noul", noul: 0.8 } } });
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(result.stopReason).toBe("stop");
|
||||
expect(requests).toHaveLength(1);
|
||||
return requests[0]!;
|
||||
}
|
||||
|
||||
it("lists Jev separately and classifies with runtime-resolved auth", async () => {
|
||||
const runtime = await createRuntime();
|
||||
const jev = runtime.getModelOfType("classifier", "typesafe", "jev-latest")!;
|
||||
expect(jev.type).toBe("classifier");
|
||||
expect(runtime.getModel("typesafe", "jev-latest")).toBeUndefined();
|
||||
|
|
@ -39,4 +84,39 @@ describe("ModelRuntime classifiers", () => {
|
|||
const result = await runtime.classify(jev, context, { fetch });
|
||||
expect(result.answers.approved).toEqual({ type: "bool", probability: 0.8 });
|
||||
});
|
||||
|
||||
it("keeps OpenRouter's image form when models.json routes OpenRouter through a proxy", async () => {
|
||||
const runtime = await createRuntime({ providers: { openrouter: { baseUrl: "https://proxy.example.com/v1" } } });
|
||||
|
||||
const request = await captureImageRequest(runtime, "openrouter", "typesafe/jev-1.14");
|
||||
|
||||
// OpenRouter answers TypeSafe `assets` with 200 but drops the images.
|
||||
expect(request.url).toBe("https://proxy.example.com/v1/systemone");
|
||||
expect(request.body.state).toEqual([
|
||||
{ type: "image_url", image_url: { url: "data:image/png;base64,cG5n" } },
|
||||
context.state,
|
||||
]);
|
||||
expect(request.body).not.toHaveProperty("assets");
|
||||
expect(request.headers.has("typesafe-danger-zone-images")).toBe(false);
|
||||
});
|
||||
|
||||
it("applies provider compat from models.json to classifier models", async () => {
|
||||
const runtime = await createRuntime({
|
||||
providers: {
|
||||
typesafe: {
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
compat: { imageInput: "state-image-urls", sendImageOptInHeader: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const request = await captureImageRequest(runtime, "typesafe", "jev-latest");
|
||||
|
||||
expect(request.url).toBe("https://proxy.example.com/v1/systemone");
|
||||
expect(request.body.state).toEqual([
|
||||
{ type: "image_url", image_url: { url: "data:image/png;base64,cG5n" } },
|
||||
context.state,
|
||||
]);
|
||||
expect(request.headers.has("typesafe-danger-zone-images")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
33
packages/coding-agent/test/model-search.test.ts
Normal file
33
packages/coding-agent/test/model-search.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { fuzzyFilter } from "@earendil-works/pi-tui";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getModelSelectorSearchText } from "../src/modes/interactive/model-search.ts";
|
||||
|
||||
describe("model selector search ranking", () => {
|
||||
// #11220
|
||||
it("ranks models containing the query above scattered matches", () => {
|
||||
const models = [
|
||||
{ provider: "openrouter", id: "openai/gpt-oss-120b", name: "OpenAI: gpt-oss-120b" },
|
||||
{ provider: "openrouter", id: "anthropic/claude-sonnet-4.5", name: "Anthropic: Claude Sonnet 4.5" },
|
||||
{ provider: "openrouter", id: "anthropic/claude-opus-4.1", name: "Anthropic: Claude Opus 4.1" },
|
||||
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
|
||||
{ provider: "anthropic", id: "claude-opus-4-1", name: "Claude Opus 4.1" },
|
||||
];
|
||||
|
||||
const result = fuzzyFilter(models, "opus", getModelSelectorSearchText).map((m) => m.id);
|
||||
|
||||
expect(result.slice(0, 2).sort()).toEqual(["anthropic/claude-opus-4.1", "claude-opus-4-1"]);
|
||||
});
|
||||
|
||||
// #10065
|
||||
it("ranks the typed model above models sharing a long provider prefix", () => {
|
||||
const models = [
|
||||
{ provider: "fireworks", id: "accounts/fireworks/models/deepseek-v4-flash-0731" },
|
||||
{ provider: "fireworks", id: "accounts/fireworks/models/kimi-k3" },
|
||||
{ provider: "fireworks", id: "accounts/fireworks/routers/firerouter", name: "FireRouter" },
|
||||
];
|
||||
|
||||
const result = fuzzyFilter(models, "firerouter", getModelSelectorSearchText);
|
||||
|
||||
expect(result[0]?.id).toBe("accounts/fireworks/routers/firerouter");
|
||||
});
|
||||
});
|
||||
|
|
@ -81,19 +81,20 @@ describe("session selector search", () => {
|
|||
it("relevance sort orders by score and tie-breaks by modified desc", () => {
|
||||
const sessions: SessionInfo[] = [
|
||||
makeSession({
|
||||
id: "late",
|
||||
id: "message",
|
||||
modified: new Date("2026-01-03T00:00:00.000Z"),
|
||||
allMessagesText: "xxxx brave",
|
||||
allMessagesText: "brave",
|
||||
}),
|
||||
makeSession({
|
||||
id: "early",
|
||||
id: "name",
|
||||
name: "Brave work",
|
||||
modified: new Date("2026-01-01T00:00:00.000Z"),
|
||||
allMessagesText: "brave xxxx",
|
||||
allMessagesText: "xxxx",
|
||||
}),
|
||||
];
|
||||
|
||||
const result1 = filterAndSortSessions(sessions, '"brave"', "relevance");
|
||||
expect(result1.map((s) => s.id)).toEqual(["early", "late"]);
|
||||
expect(result1.map((s) => s.id)).toEqual(["name", "message"]);
|
||||
|
||||
const tieSessions: SessionInfo[] = [
|
||||
makeSession({
|
||||
|
|
@ -112,6 +113,118 @@ describe("session selector search", () => {
|
|||
expect(result2.map((s) => s.id)).toEqual(["newer", "older"]);
|
||||
});
|
||||
|
||||
// #11220
|
||||
it("ignores the position of a match in message text", () => {
|
||||
const filler = "lorem ipsum dolor sit amet ".repeat(400);
|
||||
const sessions: SessionInfo[] = [
|
||||
makeSession({
|
||||
id: "early",
|
||||
modified: new Date("2026-01-01T00:00:00.000Z"),
|
||||
allMessagesText: `opus ${filler}`,
|
||||
}),
|
||||
makeSession({
|
||||
id: "deep",
|
||||
modified: new Date("2026-01-02T00:00:00.000Z"),
|
||||
allMessagesText: `${filler} we discussed the opus model`,
|
||||
}),
|
||||
makeSession({
|
||||
id: "scattered",
|
||||
modified: new Date("2026-01-03T00:00:00.000Z"),
|
||||
allMessagesText: filler,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = filterAndSortSessions(sessions, "opus", "relevance");
|
||||
expect(result.map((s) => s.id)).toEqual(["deep", "early"]);
|
||||
});
|
||||
|
||||
// #11024
|
||||
it("does not match scattered characters in session ids", () => {
|
||||
const sessions: SessionInfo[] = [
|
||||
makeSession({
|
||||
id: "019e9024-aaaa-7bbb-8ccc-dddddddddddd",
|
||||
modified: new Date("2026-01-03T00:00:00.000Z"),
|
||||
allMessagesText: "unrelated",
|
||||
}),
|
||||
makeSession({
|
||||
id: "01a1ffff-aaaa-7bbb-8ccc-dddddddddddd",
|
||||
name: "#1024 TDD bare env",
|
||||
modified: new Date("2026-01-01T00:00:00.000Z"),
|
||||
allMessagesText: "unrelated",
|
||||
}),
|
||||
];
|
||||
|
||||
expect(filterAndSortSessions(sessions, "1024", "relevance").map((s) => s.name)).toEqual(["#1024 TDD bare env"]);
|
||||
expect(filterAndSortSessions(sessions, "9024-aa", "relevance").map((s) => s.id)).toEqual([
|
||||
"019e9024-aaaa-7bbb-8ccc-dddddddddddd",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ranks name text, then message text, then scattered name matches", () => {
|
||||
const sessions: SessionInfo[] = [
|
||||
makeSession({
|
||||
id: "scattered-name",
|
||||
name: "c-a-c-h-e notes",
|
||||
modified: new Date("2026-01-04T00:00:00.000Z"),
|
||||
allMessagesText: "unrelated",
|
||||
}),
|
||||
makeSession({
|
||||
id: "message",
|
||||
name: "Other work",
|
||||
modified: new Date("2026-01-03T00:00:00.000Z"),
|
||||
allMessagesText: "A discussion mentioning the cache.",
|
||||
}),
|
||||
makeSession({
|
||||
id: "name",
|
||||
name: "Cache work",
|
||||
modified: new Date("2026-01-01T00:00:00.000Z"),
|
||||
allMessagesText: "Summary",
|
||||
}),
|
||||
];
|
||||
|
||||
expect(filterAndSortSessions(sessions, "cache", "relevance").map((s) => s.id)).toEqual([
|
||||
"name",
|
||||
"message",
|
||||
"scattered-name",
|
||||
]);
|
||||
});
|
||||
|
||||
it("matches each token within a single field", () => {
|
||||
const sessions: SessionInfo[] = [
|
||||
makeSession({
|
||||
id: "split",
|
||||
name: "Fuzzy",
|
||||
modified: new Date("2026-01-01T00:00:00.000Z"),
|
||||
allMessagesText: "search ranking",
|
||||
}),
|
||||
];
|
||||
|
||||
expect(filterAndSortSessions(sessions, "fuzzy ranking", "relevance").map((s) => s.id)).toEqual(["split"]);
|
||||
expect(filterAndSortSessions(sessions, "zzysea", "relevance")).toEqual([]);
|
||||
expect(filterAndSortSessions(sessions, "re:fuzzy search", "relevance")).toEqual([]);
|
||||
});
|
||||
|
||||
it("ranks regex name matches first", () => {
|
||||
const sessions: SessionInfo[] = [
|
||||
makeSession({
|
||||
id: "message",
|
||||
modified: new Date("2026-01-02T00:00:00.000Z"),
|
||||
allMessagesText: "brave",
|
||||
}),
|
||||
makeSession({
|
||||
id: "name",
|
||||
name: "Brave",
|
||||
modified: new Date("2026-01-01T00:00:00.000Z"),
|
||||
allMessagesText: "xxxx",
|
||||
}),
|
||||
];
|
||||
|
||||
expect(filterAndSortSessions(sessions, "re:\\bbrave\\b", "relevance").map((s) => s.id)).toEqual([
|
||||
"name",
|
||||
"message",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty list for invalid regex", () => {
|
||||
const sessions: SessionInfo[] = [
|
||||
makeSession({
|
||||
|
|
|
|||
75
packages/coding-agent/test/shell-kill-process-tree.test.ts
Normal file
75
packages/coding-agent/test/shell-kill-process-tree.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { killProcessTree } from "../src/utils/shell.ts";
|
||||
|
||||
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
|
||||
// Regression coverage for #11252: stop-before-kill is macOS-only and best-effort.
|
||||
describe("killProcessTree", () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(process, "platform", { configurable: true, value: "darwin" });
|
||||
vi.spyOn(process, "kill").mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (platformDescriptor) Object.defineProperty(process, "platform", platformDescriptor);
|
||||
});
|
||||
|
||||
it("stops the macOS process group before killing it without resuming it", () => {
|
||||
killProcessTree(1234);
|
||||
|
||||
expect(vi.mocked(process.kill).mock.calls).toEqual([
|
||||
[-1234, "SIGSTOP"],
|
||||
[-1234, "SIGKILL"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("still kills the group when stopping fails", () => {
|
||||
vi.mocked(process.kill).mockImplementationOnce(() => {
|
||||
throw new Error("stop failed");
|
||||
});
|
||||
|
||||
killProcessTree(1234);
|
||||
|
||||
expect(vi.mocked(process.kill).mock.calls).toEqual([
|
||||
[-1234, "SIGSTOP"],
|
||||
[-1234, "SIGKILL"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to killing the shell when the group kill fails", () => {
|
||||
vi.mocked(process.kill).mockImplementation((pid, signal) => {
|
||||
if (pid < 0 && signal === "SIGKILL") throw new Error("group kill failed");
|
||||
return true;
|
||||
});
|
||||
|
||||
killProcessTree(1234);
|
||||
|
||||
expect(vi.mocked(process.kill).mock.calls).toEqual([
|
||||
[-1234, "SIGSTOP"],
|
||||
[-1234, "SIGKILL"],
|
||||
[1234, "SIGKILL"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("tolerates failure of both group signals and the fallback", () => {
|
||||
vi.mocked(process.kill).mockImplementation(() => {
|
||||
throw new Error("process no longer exists");
|
||||
});
|
||||
|
||||
expect(() => killProcessTree(1234)).not.toThrow();
|
||||
expect(vi.mocked(process.kill).mock.calls).toEqual([
|
||||
[-1234, "SIGSTOP"],
|
||||
[-1234, "SIGKILL"],
|
||||
[1234, "SIGKILL"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps Linux group termination unchanged", () => {
|
||||
Object.defineProperty(process, "platform", { configurable: true, value: "linux" });
|
||||
|
||||
killProcessTree(1234);
|
||||
|
||||
expect(vi.mocked(process.kill).mock.calls).toEqual([[-1234, "SIGKILL"]]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { type AssistantMessage, fauxAssistantMessage, fauxToolCall, type Message } from "@earendil-works/pi-ai";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createHarness, type Harness } from "../harness.ts";
|
||||
|
||||
// https://github.com/earendil-works/pi/issues/11227
|
||||
describe("issue #11227 recovery omits tool results with their tool call", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (harnesses.length > 0) {
|
||||
harnesses.pop()?.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("pre-prompt recovery of a resumed length stop omits the truncated call and its result", async () => {
|
||||
const harness = await createHarness({
|
||||
models: [{ id: "faux-1", contextWindow: 100_000, maxTokens: 1_000 }],
|
||||
settings: { compaction: { enabled: true } },
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("session_before_compact", async (event) => ({
|
||||
compaction: {
|
||||
summary: "summary",
|
||||
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
||||
tokensBefore: event.preparation.tokensBefore,
|
||||
details: {},
|
||||
},
|
||||
}));
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
|
||||
const now = Date.now();
|
||||
const model = harness.getModel();
|
||||
harness.sessionManager.appendMessage({ role: "user", content: "check status", timestamp: now - 1000 });
|
||||
const truncated: AssistantMessage = {
|
||||
...fauxAssistantMessage([fauxToolCall("bash", { command: "echo" }, { id: "call_truncated" })], {
|
||||
stopReason: "length",
|
||||
timestamp: now - 500,
|
||||
}),
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 100,
|
||||
output: 16,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 116,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
};
|
||||
harness.sessionManager.appendMessage(truncated);
|
||||
harness.sessionManager.appendMessage({
|
||||
role: "toolResult",
|
||||
toolCallId: "call_truncated",
|
||||
toolName: "bash",
|
||||
isError: true,
|
||||
content: [{ type: "text", text: "Not executed: output token limit reached." }],
|
||||
timestamp: now - 400,
|
||||
});
|
||||
harness.session.agent.state.messages = harness.sessionManager.buildSessionContext().messages;
|
||||
|
||||
let requestMessages: Message[] = [];
|
||||
harness.setResponses([
|
||||
(context) => {
|
||||
requestMessages = context.messages;
|
||||
return fauxAssistantMessage("done");
|
||||
},
|
||||
]);
|
||||
|
||||
await harness.session.prompt("continue");
|
||||
|
||||
const requestRoles = requestMessages.map((message) => message.role);
|
||||
expect(requestRoles).not.toContain("toolResult");
|
||||
expect(requestMessages.some((m) => m.role === "assistant" && m.stopReason === "length")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -908,6 +908,31 @@ describe("Coding Agent Tools", () => {
|
|||
expect(output).not.toContain("match two");
|
||||
});
|
||||
|
||||
// https://github.com/earendil-works/pi/issues/11203
|
||||
it("should ignore RIPGREP_CONFIG_PATH", async () => {
|
||||
const configFile = join(testDir, "rg-config");
|
||||
const testFile = join(testDir, "fixture.txt");
|
||||
writeFileSync(configFile, "--fixed-strings\n");
|
||||
writeFileSync(testFile, "aXb\na.b\n");
|
||||
|
||||
const previous = process.env.RIPGREP_CONFIG_PATH;
|
||||
process.env.RIPGREP_CONFIG_PATH = configFile;
|
||||
try {
|
||||
const result = await grepTool.execute("test-call-rg-config", {
|
||||
pattern: "a.b",
|
||||
path: testFile,
|
||||
literal: false,
|
||||
});
|
||||
|
||||
const output = getTextOutput(result);
|
||||
expect(output).toContain("fixture.txt:1: aXb");
|
||||
expect(output).toContain("fixture.txt:2: a.b");
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.RIPGREP_CONFIG_PATH;
|
||||
else process.env.RIPGREP_CONFIG_PATH = previous;
|
||||
}
|
||||
});
|
||||
|
||||
it("should treat flag-like patterns as search text", async () => {
|
||||
const marker = join(testDir, "grep-injection-marker");
|
||||
const payload = join(testDir, "payload.sh");
|
||||
|
|
|
|||
30
packages/coding-agent/test/usage-totals.test.ts
Normal file
30
packages/coding-agent/test/usage-totals.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { Usage } from "@earendil-works/pi-ai/compat";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { combineUsage } from "../src/core/usage-totals.ts";
|
||||
|
||||
function usage(total: number, totalFromProvider?: boolean): Usage {
|
||||
return {
|
||||
input: 1,
|
||||
output: 1,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 2,
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
total,
|
||||
...(totalFromProvider === undefined ? {} : { totalFromProvider }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("combineUsage", () => {
|
||||
it("marks the combined total as provider-reported only when both totals are", () => {
|
||||
expect(combineUsage(usage(1, true), usage(2, true)).cost).toMatchObject({ total: 3, totalFromProvider: true });
|
||||
expect(combineUsage(usage(1, true), usage(2)).cost.totalFromProvider).toBe(false);
|
||||
expect(combineUsage(usage(1), usage(2, true)).cost.totalFromProvider).toBe(false);
|
||||
expect(combineUsage(usage(1), usage(2)).cost.totalFromProvider).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -55,4 +55,21 @@ describe("UserMessageComponent", () => {
|
|||
|
||||
expect(stripAnsi(component.render(80).join("\n"))).toContain("Message after");
|
||||
});
|
||||
|
||||
test("passes persisted entry identity in transformer context; live leaves it undefined", () => {
|
||||
initTheme("dark");
|
||||
const captured: Array<{ messageId?: string; timestamp?: string }> = [];
|
||||
const capture = (_markdown: string, context: { messageId?: string; timestamp?: string }) => {
|
||||
captured.push({ messageId: context.messageId, timestamp: context.timestamp });
|
||||
return _markdown;
|
||||
};
|
||||
|
||||
new UserMessageComponent("restored", undefined, 1, [capture], "entry-abc", "2025-01-15T10:30:00.000Z").render(80);
|
||||
new UserMessageComponent("live", undefined, 1, [capture]).render(80);
|
||||
|
||||
expect(captured).toEqual([
|
||||
{ messageId: "entry-abc", timestamp: "2025-01-15T10:30:00.000Z" },
|
||||
{ messageId: undefined, timestamp: undefined },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,23 @@
|
|||
|
||||
### Breaking Changes
|
||||
|
||||
- `Storage.commit(writes, adopt, context)` takes an `adopt(seq)` callback, the Session's in-memory adoption of the batch: a storage calls it exactly once, synchronously, once the batch is durable and before its next commit starts, then resolves with `seq`, and never for a commit that fails before the batch is durable. Memory, SQLite, JSONL, and IndexedDB storages do; custom storages and wrappers must call or forward it, and the storage conformance suite checks it. A commit that rejects after its adoption fails the Session without rolling the adopted changes back.
|
||||
- `Storage.mintId()`'s contract is narrowed to IDs greater than every ID minted since the storage was opened and every ID stored, also across reopen. The bundled storages already behave so.
|
||||
- Watches use Chord's `WatchHandle` and `WatchEnd` (Durable re-exports both). End reasons are `stopped`, `cancelled`, `retired`, `ended` (was `session_closed`), `listener_error`, and `failed` (was `session_failed`). Retirement is an explicit event of the watched document incarnation (last frame `[["r", null]]`, then `retired`), never inferred from a `null` value. An ended watch drops its listener and every reference into its Session, so a stopped watch that is still referenced keeps only its last value.
|
||||
- Removed the Chord state getters `Session.documentState()`, `Conversation.viewState()`, and `Harness.taskGraph()`, and the `DocumentState` type; use `watchDoc()`, `Conversation.watch()`, and `Harness.watchTaskGraph()`. Durable no longer uses Chord's `replicatedState()`.
|
||||
|
||||
### Added
|
||||
|
||||
- `HarnessOptions.readOnly` opens a Harness as a viewer of a session another process drives: open leaves running tasks as committed instead of recovering them, every commit with writes rejects with `ReadOnlySession` before it reaches Storage, and `resume()` and every call that would run tasks reject with it.
|
||||
|
||||
### Changed
|
||||
|
||||
- Document, conversation view, task graph, and event watches use Chord's `Watch` implementation; behaviour is unchanged.
|
||||
|
||||
## [1.4.0] - 2026-10-21
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- `TaskRuntime.sleep()` is removed: it waited inside a phase, which holds an invocation and, after a restart, reruns the phase and restarts the wait. A task now ends its phase asleep with `runtime.sleepUntil(until, checkpoint, context)` or by returning `{ status: "sleeping", until, checkpoint }` from `runtime.commit()`; move the work after the wait into the next phase and carry what it needs in the checkpoint. `TaskRuntime.setTimer()` paces work inside a phase without waiting for it.
|
||||
- Tasks have a `sleeping` status, so code that handles every `TaskState`, `TaskGraphState`, or `TaskInspection` state needs a case for it.
|
||||
- Opening a SQLite storage migrates it to schema version 3 automatically, rebuilding its task table, stamping conversations, tasks, and submissions with a commit sequence, and indexing every stamp so a commit log can be read in ranges. A migrated database cannot be opened by 1.3.0 or earlier, so a deployment cannot roll back past this release. JSONL storage needs no migration, but 1.3.0 or earlier never resumes a sleeping task it finds (a run's conversation stays busy).
|
||||
|
|
@ -35,13 +52,13 @@
|
|||
- Generation retry backoffs, deferred-response polls, and compaction retry backoffs sleep instead of waiting in a running phase, so a process can stop or hibernate during them. A record an earlier version stored mid-backoff sleeps again when it resumes before its time. A deferred poll whose model is gone after a reopen now fails `no_model` once the poll is due, not at once.
|
||||
- Code mode measures script deadlines and wall time on the Harness clock (`HarnessOptions.now`) instead of `Date.now()`.
|
||||
- SQLite drivers, portable interfaces, and the generic migration runner now come from `@earendil-works/chord/sqlite`. Existing Durable SQLite entry points keep re-exporting their public names; storage helpers and Durable schema migrations stay here.
|
||||
|
||||
- `ToolControl.terminate` is now typed `boolean` instead of `true`, matching `pi-agent-core` and `pi-coding-agent`; `false` does not end the run ([#10858](https://github.com/earendil-works/pi/issues/10858)).
|
||||
|
||||
### Fixed
|
||||
|
||||
- In Cloudflare Workers with `nodejs_compat`, the timers that evict idle documents and contexts were armed, since timers there have an `unref()`; a pending one kept a Durable Object from hibernating and blocked its eviction. They are now declined in Workers, and memory is freed at later commits.
|
||||
- The README's Errors section now limits the `StorageRequestError` exemption to reads: a `StorageRequestError` thrown by `commit()` or `mintId()` fails the Harness like any other storage error ([#11014](https://github.com/earendil-works/pi/issues/11014)).
|
||||
- The Node environment now detects the legacy WSL `bash.exe` launcher from `%SystemRoot%` instead of assuming `C:\Windows` ([#9501](https://github.com/earendil-works/pi/pull/9501) by [@petrroll](https://github.com/petrroll)).
|
||||
|
||||
## [1.3.0] - 2026-10-15
|
||||
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ const root = await harness.root(context); // the same root as last time
|
|||
harness.resume(); // continue any run the last process left unfinished
|
||||
```
|
||||
|
||||
Work interrupted by a crash or close stays pending. `resume()` starts the task scheduler; submitting or waiting starts it too. Each conversation has its own persisted UUIDv7 in `pi.provider`, forwarded to pi-ai as `sessionId` for provider prompt-cache and session affinity. It survives reopen, retries, reset, compaction, and model changes; a child or fork receives a fresh identity. A legacy conversation receives and persists one before its first generation or compaction request.
|
||||
Work interrupted by a crash or close stays pending. `resume()` starts the task scheduler; submitting or waiting starts it too. A process that only shows a session another one drives opens it with `readOnly: true`: open recovers nothing, and every write, `resume()`, and every call that would run tasks rejects with `ReadOnlySession`. Each conversation has its own persisted UUIDv7 in `pi.provider`, forwarded to pi-ai as `sessionId` for provider prompt-cache and session affinity. It survives reopen, retries, reset, compaction, and model changes; a child or fork receives a fresh identity. A legacy conversation receives and persists one before its first generation or compaction request.
|
||||
|
||||
A retried submission with the same `requestId` returns the existing submission instead of submitting twice:
|
||||
|
||||
|
|
@ -473,30 +473,23 @@ Work that already started keeps the code it took: a running tool call finishes u
|
|||
|
||||
## Watching a Conversation
|
||||
|
||||
Everything a UI needs is committed state. `viewState()` returns the conversation's structural view as a read-only Chord state, updated after every commit that touches it:
|
||||
|
||||
```typescript
|
||||
const view = await root.viewState(context);
|
||||
view.subscribe((value) => {
|
||||
// value.entries: the active transcript
|
||||
// value.docs["pi.live"]: the running generation (streamed partial, retry, deferred) and tool calls (output, details)
|
||||
// value.docs["pi.inbox"], value.docs["pi.usage"], value.docs["pi.agent"], value.docs["pi.provider"]
|
||||
render(value);
|
||||
});
|
||||
// later: view.dispose();
|
||||
```
|
||||
|
||||
`watch()` delivers the same view with the exact Chord operations of each commit, one callback at a time:
|
||||
Everything a UI needs is committed state. `watch()` returns a watch of the conversation's structural view: its value at attachment, then each commit that touches it, with the exact Chord operations of that commit, one callback at a time:
|
||||
|
||||
```typescript
|
||||
const watch = await root.watch(context);
|
||||
render(watch.value); // the state at attachment
|
||||
watch.start(async (value, ops) => {
|
||||
// value.entries: the active transcript
|
||||
// value.docs["pi.live"]: the running generation (streamed partial, retry, deferred) and tool calls (output, details)
|
||||
// value.docs["pi.inbox"], value.docs["pi.usage"], value.docs["pi.agent"], value.docs["pi.provider"]
|
||||
render(value);
|
||||
await send(ops); // for example to a remote client that applies them
|
||||
});
|
||||
// later: await watch.stop();
|
||||
```
|
||||
|
||||
Watches are Chord's `WatchHandle` (exported by `@earendil-works/chord`; Durable re-exports it). A watch ends once, and `closed` (or `stop()`) gives the reason: `stopped`, `cancelled` (its acquisition context was cancelled), `retired` (a document watch whose incarnation retired, after it delivered `[["r", null]]`), `ended` (the Harness closed), `listener_error`, or `failed` (the Harness failed). An ended watch keeps its last value and no longer references the Harness.
|
||||
|
||||
A slow watch keeps at most 100 undelivered frames. After that, the pending frames are replaced by one frame holding the whole newest view. A client that joins late or reconnects starts from the current view; nothing is replayed.
|
||||
|
||||
Partial answers and tool output are committed at most every 100 ms by default, so a crash loses at most that window. `settings.progress` changes the intervals; a host whose storage is remote can commit less often, for example `{ partialIntervalMs: 500, outputIntervalMs: 500 }`.
|
||||
|
|
@ -601,7 +594,7 @@ stream.start(async (events) => {
|
|||
});
|
||||
```
|
||||
|
||||
Events are derived from commits, one batch per commit, and apply on top of the snapshot. Message and tool updates carry deltas: text and thinking appends, appended tool-call argument text, and output trims and appends. When a consumer falls more than 100 batches behind, it receives a fresh `snapshot` event instead. See `test/examples/19-json.ts` for the full stream of one run.
|
||||
Events are derived from commits, one batch per commit, and apply on top of the snapshot. Message and tool updates carry deltas: text and thinking appends, appended tool-call argument text, and output trims and appends. When a consumer falls more than 100 batches behind, it receives a fresh `snapshot` event instead. See `test/examples/18-json.ts` for the full stream of one run.
|
||||
|
||||
## Hooks
|
||||
|
||||
|
|
@ -677,8 +670,8 @@ Owned work belongs to its owner:
|
|||
|
||||
The examples show both patterns as product code:
|
||||
|
||||
- [`22-subagent-foreground.ts`](test/examples/22-subagent-foreground.ts): the tool above, returning the child's answer. The UI finds the child through the call's `details` and prints the child's events indented under the call.
|
||||
- [`23-subagent-background.ts`](test/examples/23-subagent-background.ts): persistent subagents behind one `subagent` tool that spawns, messages (steer or follow-up), waits for, stops, and lists them. Each child is owned by a background anchor task, so the parent's Esc and idle waits never reach it. Each message is delivered by a background reporter task that posts the answer back to the parent as a follow-up input once it arrives; request IDs keep a restart from sending a message or a report twice.
|
||||
- [`21-subagent-foreground.ts`](test/examples/21-subagent-foreground.ts): the tool above, returning the child's answer. The UI finds the child through the call's `details` and prints the child's events indented under the call.
|
||||
- [`22-subagent-background.ts`](test/examples/22-subagent-background.ts): persistent subagents behind one `subagent` tool that spawns, messages (steer or follow-up), waits for, stops, and lists them. Each child is owned by a background anchor task, so the parent's Esc and idle waits never reach it. Each message is delivered by a background reporter task that posts the answer back to the parent as a follow-up input once it arrives; request IDs keep a restart from sending a message or a report twice.
|
||||
|
||||
## Child Tasks
|
||||
|
||||
|
|
@ -705,15 +698,15 @@ decide: async (task, runtime, context) => {
|
|||
- **Finishing:** a task that finishes while work it owns is still running is `completing`: its outcome is decided, but it becomes terminal, and `waitForTask()` returns, only once that work is done. A failed or aborted outcome aborts that work first.
|
||||
- **Aborting:** abort runs bottom-up. Aborting a task aborts the work it owns first, and its own abort handler starts only once that work is done, so each task undoes its own effects.
|
||||
|
||||
[`24-child-tasks.ts`](test/examples/24-child-tasks.ts) runs a checkout with four payments: a declined card, a cancelled checkout, and a restart while the payments run.
|
||||
[`23-child-tasks.ts`](test/examples/23-child-tasks.ts) runs a checkout with four payments: a declined card, a cancelled checkout, and a restart while the payments run.
|
||||
|
||||
## Task Graph
|
||||
|
||||
`harness.taskGraph(context)` shows every live task of the Session as one Chord state, for a task panel or debugging. Each node has its owner edge (`owner` task, or none for a task its conversation owns), its status, whether it is `background` or abort-marked, and the conversations it owns. `harness.watchTaskGraph(context)` delivers the same value as a watch, like a conversation's `watch()`.
|
||||
`harness.watchTaskGraph(context)` watches every live task of the Session as one value, for a task panel or debugging, like a conversation's `watch()`. Each node has its owner edge (`owner` task, or none for a task its conversation owns), its status, whether it is `background` or abort-marked, and the conversations it owns.
|
||||
|
||||
```typescript
|
||||
const graph = await harness.taskGraph(context);
|
||||
graph.subscribe((value) => {
|
||||
const graph = await harness.watchTaskGraph(context);
|
||||
graph.start(async (value) => {
|
||||
for (const node of Object.values(value.tasks)) {
|
||||
const status = node.state.status === "waiting" ? `waiting on ${node.state.on.join(", ")}` : node.state.status;
|
||||
console.log(`${node.id} ${node.kind} ${status}`, node.owner ?? `conversation ${node.conversationId}`);
|
||||
|
|
@ -721,7 +714,7 @@ graph.subscribe((value) => {
|
|||
});
|
||||
```
|
||||
|
||||
A task appears with the commit that creates it and leaves with the commit that makes it terminal. Statuses are the committed ones: `pending`, `running`, `waiting` (with `on` and `policy`), and `completing` (with the held outcome's status). After a restart, tasks that were `running` show as `pending` until they run again. Whether a pending task is blocked by a missing definition is not part of the graph; `harness.inspect()` reports that. The graph lists live tasks only: once a subagent's owner task is terminal, a later task in its conversation is a top-level node, and the conversation's `ConversationRecord.owner` (also in its view's `conversation`) links it to its parent. [`24-child-tasks.ts`](test/examples/24-child-tasks.ts) prints the checkout's tree while its payments run.
|
||||
A task appears with the commit that creates it and leaves with the commit that makes it terminal. Statuses are the committed ones: `pending`, `running`, `waiting` (with `on` and `policy`), and `completing` (with the held outcome's status). After a restart, tasks that were `running` show as `pending` until they run again. Whether a pending task is blocked by a missing definition is not part of the graph; `harness.inspect()` reports that. The graph lists live tasks only: once a subagent's owner task is terminal, a later task in its conversation is a top-level node, and the conversation's `ConversationRecord.owner` (also in its view's `conversation`) links it to its parent. [`23-child-tasks.ts`](test/examples/23-child-tasks.ts) prints the checkout's tree while its payments run.
|
||||
|
||||
## Your Own State
|
||||
|
||||
|
|
@ -746,7 +739,7 @@ await root.commit(async (tx) => {
|
|||
console.log(await harness.snapshot(Todos, root.id, context));
|
||||
```
|
||||
|
||||
`harness.watchDoc()` and `harness.documentState()` observe one document like the view above. `HarnessOptions.conversationCreated(tx, conversation)` runs in every commit that creates or forks a conversation, including a tool's raw `tx.createConversation()`, so every conversation gets your documents; `init` in `createConversation()`, `fork()`, and `root()` writes per-call data in the same commit. An extension's tools, sections, and hooks read their own documents through `api` or `input.read`, and treat an absent one as its default ([`11-extension-state.ts`](test/examples/11-extension-state.ts)).
|
||||
`harness.watchDoc()` watches one document like the view above. `HarnessOptions.conversationCreated(tx, conversation)` runs in every commit that creates or forks a conversation, including a tool's raw `tx.createConversation()`, so every conversation gets your documents; `init` in `createConversation()`, `fork()`, and `root()` writes per-call data in the same commit. An extension's tools, sections, and hooks read their own documents through `api` or `input.read`, and treat an absent one as its default ([`10-extension-state.ts`](test/examples/10-extension-state.ts)).
|
||||
|
||||
## Usage and Cost
|
||||
|
||||
|
|
@ -837,7 +830,7 @@ Pulling refuses a copy with commits it has not uploaded (`UnsyncedCommitsError`)
|
|||
Harness closed; a device that falls behind while its Harness is open closes it, pulls, and reopens, or commits first and
|
||||
diverges on its next upload. `FlushScheduler` and `isUrgentCommit()` decide when to upload.
|
||||
|
||||
Custom backends can run the shared conformance suite with any Vitest- or Jest-compatible runner:
|
||||
A custom backend's `commit(writes, adopt, context)` calls `adopt(seq)` once the batch is durable, synchronously and before its next commit starts, and then resolves with `seq`; a commit that fails before the batch is durable never calls it. `adopt` is the Session's in-memory adoption of the batch, so memory never shows a partial log. Custom backends can run the shared conformance suite, which checks this too, with any Vitest- or Jest-compatible runner:
|
||||
|
||||
```typescript
|
||||
import { registerStorageConformance } from "@earendil-works/pi-durable/testing";
|
||||
|
|
@ -859,7 +852,7 @@ The package root loads TypeBox, because the tool task validates arguments with p
|
|||
|
||||
Three rules decide what an error does:
|
||||
|
||||
1. **A storage error ends the Harness.** The call that hit it rejects with the error. Everything after it, and every pending wait, rejects with `SessionFailed` (its `cause` is that error); watches and event streams end with `session_failed`; running tasks get their abort signal; `onReport` gets the error once. The Harness then closes itself. Nothing is retried; a custom storage retries its own transient failures. Reopen to recover: commits are atomic, so the store is consistent, and resubmitting with the same `requestId` finds a submission that was already admitted.
|
||||
1. **A storage error ends the Harness.** The call that hit it rejects with the error. Everything after it, and every pending wait, rejects with `SessionFailed` (its `cause` is that error); watches and event streams end with `failed`; running tasks get their abort signal; `onReport` gets the error once. The Harness then closes itself. Nothing is retried; a custom storage retries its own transient failures. Reopen to recover: commits are atomic, so the store is consistent, and resubmitting with the same `requestId` finds a submission that was already admitted.
|
||||
2. **An invalid read fails only that call:** a storage read throws `StorageRequestError` for a bad cursor, an unknown conversation, or history a document does not keep. A read you cancelled fails only itself too. The exemption covers reads only: any error from `commit()` or `mintId()`, `StorageRequestError` included, ends the Harness as in rule 1.
|
||||
3. **Your code fails only its own unit:** a throwing task phase faults that task, a throwing tool gets an error result, a throwing hook is reported and skipped (`beforeTool` blocks the call), and a throwing commit callback rolls back that commit. Listeners that throw, or return a rejected promise, are reported and the others still run. The exception is a host `RegistryReader` whose `snapshot()` throws: the Harness cannot schedule without it, so it fails the Harness like a storage error.
|
||||
|
||||
|
|
@ -878,29 +871,29 @@ A failure closes the Harness by itself; `closed` settles once that close is done
|
|||
Runnable examples live in [`test/examples`](test/examples). Run one from this package directory with:
|
||||
|
||||
```bash
|
||||
node --conditions=source --experimental-strip-types test/examples/14-chat.ts
|
||||
node --conditions=source --experimental-strip-types test/examples/13-chat.ts
|
||||
```
|
||||
|
||||
| Example | Shows |
|
||||
|---|---|
|
||||
| [14-chat](test/examples/14-chat.ts) | One question and answer |
|
||||
| [16-real-model](test/examples/16-real-model.ts) | Streaming an answer from OpenAI |
|
||||
| [17-coding-tools](test/examples/17-coding-tools.ts) | A tool-using turn on JSONL storage |
|
||||
| [18-print](test/examples/18-print.ts) | Print mode: submit a prompt, print the answer |
|
||||
| [19-json](test/examples/19-json.ts) | JSON mode: agent events or raw view operations, on SQLite, JSONL, or memory |
|
||||
| [20-inbox](test/examples/20-inbox.ts) | Steers, follow-ups, writes, and withdrawal while busy |
|
||||
| [21-late-join](test/examples/21-late-join.ts) | Attaching a view and an event stream mid-run |
|
||||
| [22-subagent-foreground](test/examples/22-subagent-foreground.ts) | A replay-safe subagent tool whose child the call owns, with the child's events under the call |
|
||||
| [23-subagent-background](test/examples/23-subagent-background.ts) | Persistent subagents: spawn, steer, stop, list, answers reported back, restart-safe |
|
||||
| [24-child-tasks](test/examples/24-child-tasks.ts) | A checkout that owns and waits for four payments: failFast, abort, restart |
|
||||
| [25-compaction](test/examples/25-compaction.ts) | A long chat compacted in the background, manually, and after a context overflow |
|
||||
| [26-coding-agent](test/examples/26-coding-agent.ts) | CodingTools, live settings from a settings object, an environment that follows the conversation's directory |
|
||||
| [27-plan-mode](test/examples/27-plan-mode.ts) | A read-only plan mode as an extension with its own document, switched with `configure()` |
|
||||
| [28-reviewer](test/examples/28-reviewer.ts) | A reviewer conversation with its own model, extensions, tools, directory, and review loop |
|
||||
| [29-sandbox-per-conversation](test/examples/29-sandbox-per-conversation.ts) | An environment per conversation, looked up from an app document |
|
||||
| [30-tool-override](test/examples/30-tool-override.ts) | A same-name bash for some conversations, and a wrapper that times whichever bash won |
|
||||
| [31-reload-and-restart](test/examples/31-reload-and-restart.ts) | Reloading an extension mid-call, and stored choices surviving a restart |
|
||||
| [00](test/examples/00-conversation.ts)–[13](test/examples/13-recovery.ts) | The layers underneath: sessions, documents, forks, watches, the Harness, agent configuration, reload, extension state, tasks, recovery |
|
||||
| [13-chat](test/examples/13-chat.ts) | One question and answer |
|
||||
| [15-real-model](test/examples/15-real-model.ts) | Streaming an answer from OpenAI |
|
||||
| [16-coding-tools](test/examples/16-coding-tools.ts) | A tool-using turn on JSONL storage |
|
||||
| [17-print](test/examples/17-print.ts) | Print mode: submit a prompt, print the answer |
|
||||
| [18-json](test/examples/18-json.ts) | JSON mode: agent events or raw view operations, on SQLite, JSONL, or memory |
|
||||
| [19-inbox](test/examples/19-inbox.ts) | Steers, follow-ups, writes, and withdrawal while busy |
|
||||
| [20-late-join](test/examples/20-late-join.ts) | Attaching a view and an event stream mid-run |
|
||||
| [21-subagent-foreground](test/examples/21-subagent-foreground.ts) | A replay-safe subagent tool whose child the call owns, with the child's events under the call |
|
||||
| [22-subagent-background](test/examples/22-subagent-background.ts) | Persistent subagents: spawn, steer, stop, list, answers reported back, restart-safe |
|
||||
| [23-child-tasks](test/examples/23-child-tasks.ts) | A checkout that owns and waits for four payments: failFast, abort, restart |
|
||||
| [24-compaction](test/examples/24-compaction.ts) | A long chat compacted in the background, manually, and after a context overflow |
|
||||
| [25-coding-agent](test/examples/25-coding-agent.ts) | CodingTools, live settings from a settings object, an environment that follows the conversation's directory |
|
||||
| [26-plan-mode](test/examples/26-plan-mode.ts) | A read-only plan mode as an extension with its own document, switched with `configure()` |
|
||||
| [27-reviewer](test/examples/27-reviewer.ts) | A reviewer conversation with its own model, extensions, tools, directory, and review loop |
|
||||
| [28-sandbox-per-conversation](test/examples/28-sandbox-per-conversation.ts) | An environment per conversation, looked up from an app document |
|
||||
| [29-tool-override](test/examples/29-tool-override.ts) | A same-name bash for some conversations, and a wrapper that times whichever bash won |
|
||||
| [30-reload-and-restart](test/examples/30-reload-and-restart.ts) | Reloading an extension mid-call, and stored choices surviving a restart |
|
||||
| [00](test/examples/00-conversation.ts)–[12](test/examples/12-recovery.ts) | The layers underneath: sessions, documents, forks, watches, the Harness, agent configuration, reload, extension state, tasks, recovery |
|
||||
|
||||
Examples that call OpenAI need `OPENAI_API_KEY`; most use the faux provider otherwise.
|
||||
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@ It uses existing package types as follows:
|
|||
|
||||
```ts
|
||||
import type {
|
||||
AttachedReplicatedState,
|
||||
Context,
|
||||
Draft,
|
||||
JsonRepresentation,
|
||||
JsonValue,
|
||||
ReplicatedState,
|
||||
WatchEnd,
|
||||
WatchHandle,
|
||||
} from "@earendil-works/chord";
|
||||
import type { Op } from "@earendil-works/chord/delta";
|
||||
import type {
|
||||
|
|
@ -456,7 +456,7 @@ type HarnessSettings = {
|
|||
readonly followUpMode?: QueueMode;
|
||||
/** How long an idle conversation keeps its last context read in memory; busy ones always keep it. */
|
||||
readonly contextRetentionMs?: number;
|
||||
/** How long a loaded document no watch or document state observes stays in memory after its last use (section 4). */
|
||||
/** How long a loaded document no watch observes stays in memory after its last use (section 4). */
|
||||
readonly documentRetentionMs?: number;
|
||||
/** What a failed run does with the inputs queued behind it (section 6). */
|
||||
readonly afterFailedRun?: "keep" | "continue" | "withdraw";
|
||||
|
|
@ -668,7 +668,7 @@ interface Conversation {
|
|||
/** `{ background: true }` also aborts background work under the conversation (section 5.4). */
|
||||
abort(context: Context, options?: { readonly background?: boolean }): Promise<void>;
|
||||
waitForIdle(context: Context): Promise<void>;
|
||||
viewState(context: Context): Promise<AttachedReplicatedState<ConversationView>>;
|
||||
/** The structural view (section 9.3) as a watch. */
|
||||
watch(context: Context): Promise<ConversationWatch>;
|
||||
}
|
||||
|
||||
|
|
@ -699,8 +699,7 @@ interface Harness extends Session {
|
|||
waitForIdle(context: Context): Promise<void>;
|
||||
/** Session total of every conversation's `pi.usage` (section 8.6). */
|
||||
usage(context: Context): Promise<UsageState>;
|
||||
/** Live tasks as a structural view (section 9.5). */
|
||||
taskGraph(context: Context): Promise<AttachedReplicatedState<TaskGraph>>;
|
||||
/** Live tasks as a structural view (section 9.5), as a watch. */
|
||||
watchTaskGraph(context: Context): Promise<TaskGraphWatch>;
|
||||
}
|
||||
|
||||
|
|
@ -754,6 +753,15 @@ with them. A viewer that only reads never enables scheduling: `inspect()`,
|
|||
`getTask()`, `submission()`, `Submission.status()`, `usage()`, document reads,
|
||||
every conversation read, state, and watch, and the task graph.
|
||||
|
||||
`HarnessOptions.readOnly` opens a viewer of a session another process drives,
|
||||
such as a synced copy on a device that only shows it. Open loads live tasks as
|
||||
committed, `running` ones included, and reconciles nothing. Every commit with
|
||||
writes rejects with `ReadOnlySession` before it reaches Storage, so the Session
|
||||
stays healthy; a commit that writes nothing succeeds. `resume()` throws
|
||||
`ReadOnlySession`, and every call that enables scheduling rejects with it.
|
||||
`wake()` is always inactive and `onWake` is never called. Reopening without
|
||||
`readOnly` recovers as usual: the viewer counted no restart.
|
||||
|
||||
`createConversation({ ownership, agent, init })` and
|
||||
`fork(at, { ownership, agent, init })` commit atomically: the conversation, its
|
||||
explicitly selected ownership, the creation hook's documents, the `agent`
|
||||
|
|
@ -1035,17 +1043,15 @@ one to find out. Blocked reasons are derived, never stored. Queued and placed
|
|||
submissions complete the view. Finished
|
||||
tasks, settled submissions, transcripts, and documents use their own APIs.
|
||||
|
||||
`Conversation.viewState()` returns its current structural mount as a disposable
|
||||
read-only Chord state. `Conversation.watch()` atomically captures that immutable
|
||||
revision and registers for later exact complete-commit frames. Its `WatchHandle`
|
||||
uses the same serialized asynchronous, bounded-buffer contract as `watchDoc()`
|
||||
in section 9.2. Neither carries semantic events or owns a second persistence
|
||||
authority.
|
||||
`Conversation.watch()` atomically captures the immutable revision of its
|
||||
current structural mount and registers for later exact complete-commit frames.
|
||||
Its `WatchHandle` uses the same serialized asynchronous, bounded-buffer contract
|
||||
as `watchDoc()` in section 9.2. It carries no semantic events and owns no second
|
||||
persistence authority.
|
||||
|
||||
`close()` seals mutation admission and task reservation, signals invocations,
|
||||
and ends every document state, view state, and watch at that seal: a commit
|
||||
that settles during close publishes nothing to them, and a state keeps its last
|
||||
value. Outside the Session line it lets already-admitted storage commits settle,
|
||||
and ends every watch at that seal (`ended`): a commit that settles during close
|
||||
publishes nothing to them, and a watch keeps its last value. Outside the Session line it lets already-admitted storage commits settle,
|
||||
joins task, tool, and hook invocations, including code that ignores its signal,
|
||||
then closes Storage. Once `close()` resolves, no invocation code of this Harness
|
||||
runs and no handler holds Storage, so a new Harness may open the same Storage.
|
||||
|
|
@ -1297,13 +1303,6 @@ interface Session extends DocumentObserver {
|
|||
|
||||
snapshotAsOf<T extends JsonObject>(token: RewindableConversationDocToken<T>, conversationId: ConversationId, at: EntryId, context: Context): Promise<Readonly<T> | undefined>;
|
||||
snapshotAsOf<T extends JsonObject, I extends JsonValue>(token: RewindableConversationDocFamilyToken<T, I>, conversationId: ConversationId, key: string, at: EntryId, context: Context): Promise<Readonly<T> | undefined>;
|
||||
|
||||
documentState<T extends JsonObject>(token: SessionDocToken<T>, context: Context): Promise<DocumentState<T> | undefined>;
|
||||
documentState<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: ConversationId, context: Context): Promise<DocumentState<T> | undefined>;
|
||||
documentState<T extends JsonObject>(token: TaskDocToken<T>, taskId: TaskId, context: Context): Promise<DocumentState<T> | undefined>;
|
||||
documentState<T extends JsonObject, I extends JsonValue>(token: SessionDocFamilyToken<T, I>, key: string, context: Context): Promise<DocumentState<T> | undefined>;
|
||||
documentState<T extends JsonObject, I extends JsonValue>(token: ConversationDocFamilyToken<T, I>, conversationId: ConversationId, key: string, context: Context): Promise<DocumentState<T> | undefined>;
|
||||
documentState<T extends JsonObject, I extends JsonValue>(token: TaskDocFamilyToken<T, I>, taskId: TaskId, key: string, context: Context): Promise<DocumentState<T> | undefined>;
|
||||
}
|
||||
|
||||
interface Tx {
|
||||
|
|
@ -1388,7 +1387,7 @@ registered and ordinary documents are not scanned at open.
|
|||
- The first acquisition of one logical address is memoized before awaiting. Later
|
||||
acquisitions in that transaction return the same draft; for a missing family,
|
||||
the first call's detached seed wins and later seeds are ignored.
|
||||
- `snapshot()`, `snapshotAsOf()`, `documentState()`, and `watchDoc()` never create
|
||||
- `snapshot()`, `snapshotAsOf()`, and `watchDoc()` never create
|
||||
or persist migration. They return `undefined` when the requested incarnation is
|
||||
absent and migrate a reconstructed value only in memory.
|
||||
- Task-scoped `tx.doc()` validates against the transaction's latest candidate task
|
||||
|
|
@ -1405,7 +1404,7 @@ registered and ordinary documents are not scanned at open.
|
|||
- `snapshot()` returns the current shareable immutable revision. Mutation of it
|
||||
or any retained descendant is unsupported; callers that need mutable ownership
|
||||
must copy it first.
|
||||
- `documentState()` returns a disposable read-only Chord state bound to one committed incarnation.
|
||||
- `watchDoc()` returns a watch bound to one committed incarnation (section 9.2).
|
||||
- `tx.doc()` returns one revocable Astra overlay `Draft<T>` for the transaction.
|
||||
- Historical reads never create documents in the past.
|
||||
|
||||
|
|
@ -1601,8 +1600,8 @@ no migrate -> reject older stored version
|
|||
```
|
||||
|
||||
`migrate()` is pure and returns a complete current-version value. Migration is
|
||||
access-driven: `tx.doc()`, `snapshot()`, `snapshotAsOf()`, `documentState()`, and
|
||||
`watchDoc()` reconstruct and migrate through the token supplied to that call.
|
||||
access-driven: `tx.doc()`, `snapshot()`, `snapshotAsOf()`, and `watchDoc()`
|
||||
reconstruct and migrate through the token supplied to that call.
|
||||
Harness open does not sweep ordinary documents.
|
||||
|
||||
- Read-only access migrates only in memory and never writes. It may cache a
|
||||
|
|
@ -1666,8 +1665,8 @@ inside the creating transaction lazily reads the detached source, migrates when
|
|||
required, and replaces the copy with one ordinary child create containing the
|
||||
final prepared value. Definition-free copies publish explicit `document.copy`
|
||||
metadata rather than a value. That metadata announces Storage-backed initial
|
||||
state and is never interpreted as a document value. Document states, watches,
|
||||
and mounted views hydrate by capturing their baseline and subscription
|
||||
state and is never interpreted as a document value. Watches and mounted views
|
||||
hydrate by capturing their baseline and subscription
|
||||
atomically on the Session line: a later commit already present becomes the
|
||||
baseline, while one committed after registration is delivered. Publications
|
||||
and watches are convergence mechanisms, not audit streams. A mounted aggregate
|
||||
|
|
@ -1678,7 +1677,7 @@ Session-line operation so it never exposes a mixture from one commit.
|
|||
|
||||
The Session keeps the documents it loads or creates in memory, as Chord
|
||||
trackers, so reads and changes need no Storage access. The cache is bounded by
|
||||
use, not by history: a document no watch or document state observes leaves it
|
||||
use, not by history: a document no watch observes leaves it
|
||||
once it was not read or written for `documentRetentionMs` (default ten
|
||||
minutes; `0` drops it right after use), and the next access loads it from
|
||||
Storage. An observed document never leaves. Eviction runs on the mutation line,
|
||||
|
|
@ -1696,8 +1695,14 @@ sets them for a plain Session; the Harness reads its settings at each use.
|
|||
|
||||
A Session commit callback may be asynchronous. It owns the Session mutation
|
||||
line through callback execution, preparation, storage settlement, committed
|
||||
baseline adoption, and publication enqueue. Commit observers run on the
|
||||
line; document-state and watch user callbacks run later.
|
||||
baseline adoption, and publication enqueue. Adoption runs inside
|
||||
`Storage.commit()`: the Session passes an `adopt(seq)` callback, which the
|
||||
storage calls synchronously once the batch is durable and before its next
|
||||
commit (section 10), so memory always shows a complete prefix of the log. The
|
||||
callback keeps its own error: a failed adoption fails the Session once
|
||||
`Storage.commit()` returns. A commit that rejects after its adoption fails the
|
||||
Session too, and its adopted changes stay adopted. Commit observers run on the
|
||||
line; watch listeners run later.
|
||||
External model, process, tool, network, and human effects run outside it.
|
||||
`subscribeCommits()` observes complete immutable publications synchronously on
|
||||
the line after adoption. `subscribeClose()` observes close synchronously when it
|
||||
|
|
@ -1706,8 +1711,8 @@ Harness ends waits, watches, and streams there, and signals task invocations
|
|||
after every close listener has run, so what a task holds ends with the Session's
|
||||
reason rather than as cancelled. Both return idempotent
|
||||
disposers; their listeners must not block or call Session APIs, and one that
|
||||
throws is reported without affecting the others (section 10.1). Document-state
|
||||
subscribers and watch listeners still run later, off the line.
|
||||
throws is reported without affecting the others (section 10.1). Watch listeners
|
||||
still run later, off the line.
|
||||
|
||||
```ts
|
||||
await session.commit(async tx => {
|
||||
|
|
@ -4610,53 +4615,18 @@ aborted directly (section 8.3).
|
|||
|
||||
## 9. Document observation and Chord
|
||||
|
||||
### 9.1 Document state
|
||||
### 9.1 Watches
|
||||
|
||||
Chord's existing replicated-state layer exposes this source-adoption contract:
|
||||
Durable exposes committed state only through watches: Chord's `WatchHandle`
|
||||
and its end type `WatchEnd`, exported by `@earendil-works/chord` (Durable
|
||||
re-exports both):
|
||||
|
||||
```ts
|
||||
interface ReplicatedStateSourceFrame<T> {
|
||||
readonly cursor: number;
|
||||
readonly value: T;
|
||||
readonly ops: readonly Op[];
|
||||
readonly context: Context;
|
||||
}
|
||||
|
||||
interface ReplicatedStateSourceAttachment<T> {
|
||||
/** Fixed immutable snapshot captured at the atomic attachment boundary. */
|
||||
readonly snapshot: { readonly value: T; readonly cursor: number };
|
||||
/** Install the sole listener and synchronously drain every buffered frame. */
|
||||
activate(listener: (frame: ReplicatedStateSourceFrame<T>) => void): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
interface ReplicatedStateSource<T> {
|
||||
/** Atomically capture a snapshot and begin buffering every later commit. */
|
||||
attach(): ReplicatedStateSourceAttachment<T>;
|
||||
}
|
||||
|
||||
interface ReplicatedStateSourceOptions {
|
||||
readonly onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface AttachedReplicatedState<T> extends ReplicatedState<T> {
|
||||
readonly value: T;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
function replicatedState<T>(
|
||||
source: ReplicatedStateSource<T>,
|
||||
options?: ReplicatedStateSourceOptions,
|
||||
): AttachedReplicatedState<T>;
|
||||
|
||||
type DocumentState<T extends JsonObject> =
|
||||
AttachedReplicatedState<Readonly<T> | null>;
|
||||
|
||||
type WatchEnd =
|
||||
| { readonly reason: "stopped" | "cancelled" | "session_closed" | "retired" }
|
||||
| { readonly reason: "stopped" | "cancelled" | "retired" | "ended" }
|
||||
| { readonly reason: "listener_error"; readonly error: Error }
|
||||
/** The Session failed; `error` is what failed it, usually a storage error (section 10.1). */
|
||||
| { readonly reason: "session_failed"; readonly error: unknown };
|
||||
/** The source failed; for Durable, what failed the Session, usually a storage error (section 10.1). */
|
||||
| { readonly reason: "failed"; readonly error: unknown };
|
||||
|
||||
interface WatchHandle<T> {
|
||||
/** Acquisition revision before start; latest delivered immutable revision afterward. */
|
||||
|
|
@ -4681,30 +4651,32 @@ interface DocumentObserver {
|
|||
}
|
||||
```
|
||||
|
||||
`documentState()` attaches Durable's committed document stream to Chord before
|
||||
returning. The returned state is already hydrated with the exact shareable
|
||||
immutable revision, has no mutation methods, and publishes every later exact
|
||||
committed revision and operation batch without another tracker, value copy, or
|
||||
re-diff. Disposing it unregisters only that observation; Durable remains the sole
|
||||
document mutator.
|
||||
A watch ends once, with the first reason that applies: `stopped` by `stop()`;
|
||||
`cancelled` when its acquisition `Context` is cancelled; `retired` when its
|
||||
document incarnation retired; `ended` when the Session closed; `listener_error`
|
||||
when its listener threw or rejected; `failed` when the Session failed.
|
||||
|
||||
State and watch acquisition never create; absent lookup returns `undefined`.
|
||||
Successful acquisition binds one concrete incarnation. Retirement publishes a
|
||||
JSON `null` root replacement and ends that incarnation's stream. If the state
|
||||
remains exposed, consumers see `null`, never stale state. A later recreation
|
||||
requires acquiring a new state or watch.
|
||||
Watch acquisition never creates; absent lookup returns `undefined`. Successful
|
||||
acquisition binds one concrete incarnation. Retirement is an explicit event of
|
||||
that incarnation, never inferred from a `null` value: it delivers a JSON `null`
|
||||
root replacement `[["r", null]]` as the last frame, then ends the watch as
|
||||
`retired`. A later recreation requires acquiring a new watch.
|
||||
|
||||
Session close ends every state at its seal: the state keeps its last value and
|
||||
Session close ends every watch at its seal: the watch keeps its last value and
|
||||
receives no frame from a commit that settles during close.
|
||||
|
||||
Each document state assigns its own in-memory contiguous Chord delivery sequence.
|
||||
Durable does not persist or expose that sequence through `WatchHandle`. States and watches retain immutable revisions independently of the loaded tracker
|
||||
cache. Reopen creates a new state lifetime and hydration.
|
||||
Termination drops every reference the watch holds into its source: its listener,
|
||||
its subscriptions and the closures that detach, report, and replace, and its
|
||||
cancellation hook. A stopped watch that is still referenced keeps only its last
|
||||
value alive, never the Session or the listener; a callback already running keeps
|
||||
what it needs until it settles. Watches retain immutable revisions independently
|
||||
of the loaded tracker cache. Reopen creates a new watch lifetime.
|
||||
|
||||
Internally, state attachment uses Chord's synchronous atomic snapshot-and-register
|
||||
boundary: the snapshot includes every commit before attachment, and every later
|
||||
exact frame is buffered until activation. This prevents a snapshot from being
|
||||
paired with operations based on a newer unseen revision.
|
||||
Acquisition captures the value and registers for later commits atomically, on
|
||||
the Session line: the acquisition value includes every commit before
|
||||
acquisition, and every later exact frame is buffered until `start()`. This
|
||||
prevents a value from being paired with operations based on a newer unseen
|
||||
revision.
|
||||
|
||||
### 9.2 `watchDoc`
|
||||
|
||||
|
|
@ -4785,8 +4757,8 @@ type ConversationView = {
|
|||
These kinds and their fields are public protocol. Third-party documents are
|
||||
exposed through their own Chord objects, not mounted.
|
||||
|
||||
The Harness keeps at most one mount per conversation. The first `viewState()`,
|
||||
`watch()`, or event attachment builds its revision on the Session line from the
|
||||
The Harness keeps at most one mount per conversation. The first `watch()` or
|
||||
event attachment builds its revision on the Session line from the
|
||||
committed active transcript and documents; the mount is dropped when its last
|
||||
observer detaches. A mount derives each next revision from the Session's
|
||||
`subscribeCommits()` publications, like `watchDoc()`: every publication is
|
||||
|
|
@ -4813,9 +4785,8 @@ redundant nonempty batch remains a real publication. Mounted document revisions
|
|||
are structurally shared under the trusted immutability contract. The mount
|
||||
performs no semantic projection and owns no persistence.
|
||||
|
||||
`Conversation.viewState()` exposes the mount as a disposable read-only Chord
|
||||
state. `Conversation.watch()` exposes the same mount through the serialized
|
||||
exact-frame watch of section 9.2, with bounded pending frames and full-value
|
||||
`Conversation.watch()` exposes the mount through the serialized exact-frame
|
||||
watch of section 9.2, with bounded pending frames and full-value
|
||||
overflow replacements.
|
||||
|
||||
### 9.4 Agent events (experimental)
|
||||
|
|
@ -5030,7 +5001,6 @@ type TaskGraph = {
|
|||
type TaskGraphWatch = WatchHandle<TaskGraph>;
|
||||
|
||||
interface Harness {
|
||||
taskGraph(context: Context): Promise<AttachedReplicatedState<TaskGraph>>;
|
||||
watchTaskGraph(context: Context): Promise<TaskGraphWatch>;
|
||||
}
|
||||
```
|
||||
|
|
@ -5055,8 +5025,7 @@ node that lists it; a later task there is a top-level node of that conversation.
|
|||
A UI that places it under its parent reads the conversation's owner edge from
|
||||
`ConversationRecord.owner`, which `ConversationView.conversation` also carries.
|
||||
|
||||
The Harness keeps at most one graph mount. The first `taskGraph()` or
|
||||
`watchTaskGraph()` builds its revision on the Session line from the committed
|
||||
The Harness keeps at most one graph mount. The first `watchTaskGraph()` builds its revision on the Session line from the committed
|
||||
live tasks and the conversations they own, one owner scan per live task, and
|
||||
holds the line while it does; the mount is dropped when its last observer
|
||||
detaches. It advances from `subscribeCommits()` publications like the
|
||||
|
|
@ -5065,14 +5034,13 @@ that sets or deletes `tasks[id]`, or sets a node's `conversations`. A
|
|||
publication that changes no node creates no revision. Task and conversation
|
||||
records are immutable values; a node shares nothing with them.
|
||||
|
||||
`taskGraph()` returns the mount as a disposable read-only Chord state.
|
||||
`watchTaskGraph()` returns it through the serialized exact-frame watch of
|
||||
section 9.2. Both read only: neither enables scheduling. Close ends them at its
|
||||
seal like every other state and watch (section 2.2).
|
||||
`watchTaskGraph()` returns the mount through the serialized exact-frame watch
|
||||
of section 9.2. It reads only: it never enables scheduling. Close ends it at its
|
||||
seal like every other watch (section 2.2).
|
||||
|
||||
```ts
|
||||
const graph = await harness.taskGraph(context);
|
||||
graph.subscribe((value) => {
|
||||
const graph = await harness.watchTaskGraph(context);
|
||||
graph.start(async (value) => {
|
||||
for (const node of Object.values(value.tasks)) {
|
||||
const where = node.owner === undefined ? `conversation ${node.conversationId}` : `task ${node.owner}`;
|
||||
render(`${node.id} ${node.kind} ${node.state.status} under ${where}`);
|
||||
|
|
@ -5179,10 +5147,16 @@ type StorageWrite =
|
|||
* gaps. Once commit() resolves, later reads through that Storage observe it.
|
||||
*/
|
||||
interface Storage {
|
||||
commit(writes: readonly StorageWrite[], context: Context): Promise<Seq>;
|
||||
/**
|
||||
* Allocate from the one global numeric namespace, above every ID minted or stored before, also across reopen: scans
|
||||
* order by ID as creation order, and the Harness relies on it. The generic brand is compile-time only.
|
||||
* Persist the batch atomically. Once it is durable, call `adopt(seq)` exactly once, synchronously and before this
|
||||
* storage's next commit starts, then resolve with `seq`; a commit that fails before the batch is durable never calls
|
||||
* it. `adopt` must not throw.
|
||||
*/
|
||||
commit(writes: readonly StorageWrite[], adopt: (seq: Seq) => void, context: Context): Promise<Seq>;
|
||||
/**
|
||||
* Allocate from the one global numeric namespace, above every ID minted since this storage was opened and every ID
|
||||
* stored, also across reopen: scans order by ID as creation order, and the Harness relies on it. The generic brand
|
||||
* is compile-time only.
|
||||
*/
|
||||
mintId<I extends Id<string>>(): Promise<I>;
|
||||
|
||||
|
|
@ -5332,8 +5306,8 @@ may not have committed, so a host that resubmits after reopening reuses its
|
|||
and every wait that starts later rejects with `SessionFailed`, whose `cause` is
|
||||
the first error; so does every Storage call still underway when it returns,
|
||||
whatever it returned.
|
||||
- Watches and event streams end with `{ reason: "session_failed", error }`;
|
||||
attached replicated states stop updating and keep their last value.
|
||||
- Watches and event streams end with `{ reason: "failed", error }` and keep
|
||||
their last value.
|
||||
- Running task invocations get their abort signal. `HarnessOptions.onReport`
|
||||
gets the error once, after the Session is sealed, so a report handler that
|
||||
calls back finds it failed.
|
||||
|
|
@ -5345,7 +5319,7 @@ may not have committed, so a host that resubmits after reopening reuses its
|
|||
ignores its abort signal keeps the backend open and `closed` pending.
|
||||
- A failing `Storage.close()` fails the Session too, unless it already failed;
|
||||
the `close()` call rejects with it, and `closed` settles `failed`. Waits and
|
||||
watches had already ended as for a close (`session_closed`).
|
||||
watches had already ended as for a close (`ended`).
|
||||
|
||||
The exceptions fail only their call, and apply to reads alone: a
|
||||
`StorageRequestError`, which a Storage throws for an invalid read, with no
|
||||
|
|
@ -5356,7 +5330,9 @@ exempt: once a batch is admitted, whether it committed is unknown.
|
|||
|
||||
A commit callback that catches a failed read cannot commit afterwards: the Session
|
||||
checks its health after the callback. Adopting a committed batch in memory that
|
||||
fails also fails the Session, since memory would be behind storage.
|
||||
fails also fails the Session, since memory would be behind storage; so does a
|
||||
`Storage.commit()` that resolves without adopting, or rejects after it adopted
|
||||
(the adopted changes are not rolled back).
|
||||
|
||||
**The Session's own bookkeeping fails it too.** A throw in a commit the
|
||||
scheduler makes for itself (reconciliation, reservation, a fault or terminal
|
||||
|
|
@ -5375,8 +5351,8 @@ throw are reported and skipped, except `beforeTool`, whose throw blocks the call
|
|||
a commit callback, `conversationCreated`, `init`, or a document's `initial`,
|
||||
`migrate`, or `checkpointWhen` that throws rolls its commit back and rejects it.
|
||||
Callbacks that must not fail their caller are contained: a host
|
||||
`subscribeCommits()` or `subscribeClose()` listener, a watch listener, a
|
||||
replicated-state subscriber, and a view observer that throws, or returns a
|
||||
`subscribeCommits()` or `subscribeClose()` listener, a watch listener, and a
|
||||
view observer that throws, or returns a
|
||||
promise that rejects, are reported, and the others still run; a watch whose
|
||||
listener throws ends with `listener_error`. A throwing or rejecting `onReport` is
|
||||
dropped, since nothing is left to report it to. A throwing `now()` is reported
|
||||
|
|
@ -5557,13 +5533,13 @@ These are contracts, not invitations to add defensive machinery:
|
|||
- **Long transactions:** an async commit callback holds the Session mutation
|
||||
line. Never await models, tools, processes, network calls, humans, a nested
|
||||
Session commit, or a Session waiter inside it. Use methods on the current `Tx`.
|
||||
- **Reads inside a commit:** `snapshot()`, `documentState()`, `watchDoc()`,
|
||||
- **Reads inside a commit:** `snapshot()`, `watchDoc()`,
|
||||
`getTask()`, and the other Session, Harness, and runtime reads may queue on the
|
||||
mutation line, for example to load a document not in memory. Inside a commit
|
||||
callback, which holds that line, such a read never resolves, and the line stays
|
||||
held. Only a cache hit returns, so the hang is intermittent. Read through `tx`.
|
||||
- **Explicit creation:** only typed `tx.doc()` creates an absent document. Snapshot,
|
||||
state, and watch lookup return `undefined` instead.
|
||||
- **Explicit creation:** only typed `tx.doc()` creates an absent document. Snapshot
|
||||
and watch lookup return `undefined` instead.
|
||||
- **Family initialization:** the first acquisition of an absent family address
|
||||
selects its seed. Existing instances and later calls ignore seeds; a seed is
|
||||
neither identity nor an update.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@earendil-works/pi-durable",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"description": "Durable conversation, task, and document runtime for Pi",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
|
@ -156,10 +156,10 @@
|
|||
"node": ">=22.19.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/chord": "1.3.0",
|
||||
"@earendil-works/pi-ai": "1.3.0",
|
||||
"@earendil-works/pi-codemode": "1.3.0",
|
||||
"@earendil-works/pi-telemetry": "1.3.0",
|
||||
"@earendil-works/chord": "1.4.0",
|
||||
"@earendil-works/pi-ai": "1.4.0",
|
||||
"@earendil-works/pi-codemode": "1.4.0",
|
||||
"@earendil-works/pi-telemetry": "1.4.0",
|
||||
"@silvia-odwyer/photon": "0.3.3",
|
||||
"diff": "8.0.4",
|
||||
"typebox": "1.3.27"
|
||||
|
|
@ -168,6 +168,6 @@
|
|||
"@cloudflare/vitest-plugin": "1.4.4",
|
||||
"fake-indexeddb": "6.2.6",
|
||||
"shx": "0.3.4",
|
||||
"vitest": "4.1.11"
|
||||
"vitest": "4.1.12"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue