From 906e1302f94829ec7e228059199681c11faec0b0 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Fri, 23 Oct 2026 10:31:50 +0200 Subject: [PATCH 01/33] fix(coding-agent): build standalone binaries with Bun 1.4.4 (#11319) Tested locally on darwin-arm64, darwin-x64, linux-arm64, linux-x64, windows-arm64 and windows-x64: the CI smoke checks plus HTTPS responses, file tools, image resizing and session resume. Aborting a streamed reply still stops immediately (#10031, #8823). --- .github/workflows/build-binaries.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 74f6847cf..8e8bf1307 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -41,7 +41,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.4 - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 From 14c8118412bffc8f8df7a088fde51d2ffa74fd8f Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Fri, 23 Oct 2026 10:54:18 +0200 Subject: [PATCH 02/33] feat(ai): carry responseModel on pi-messages terminal events (#11265) This commit makes it so we store the responseModel (i.e. the "phyiscal" model used) on each PiMessage. This allows e.g. Radius to know which physical model was used by virtual models (like `cheap`). It's especially important after we merged https://github.com/earendil-works/pi/pull/11000 (911309429175254550837167baee245d5f9dbcb6), where Pi will drop thinking traces when the model name doesn't match - with this change we can check the physical model instead. --- packages/ai/src/api/pi-messages.ts | 8 ++++++++ packages/ai/src/types.ts | 2 +- packages/ai/test/pi-messages.test.ts | 15 ++++++++++++++- packages/coding-agent/docs/message-types.md | 2 +- 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/ai/src/api/pi-messages.ts b/packages/ai/src/api/pi-messages.ts index 605194937..83ddc06c7 100644 --- a/packages/ai/src/api/pi-messages.ts +++ b/packages/ai/src/api/pi-messages.ts @@ -74,6 +74,7 @@ export type PiMessagesEvent = reason: Extract; usage: PiMessagesUsage; responseId?: string; + responseModel?: string; providerThinkingLevel?: string; rewrite?: PiMessagesRewriteImpact; } @@ -83,6 +84,7 @@ export type PiMessagesEvent = usage: PiMessagesUsage; errorMessage?: string; responseId?: string; + responseModel?: string; providerThinkingLevel?: string; rewrite?: PiMessagesRewriteImpact; }; @@ -199,6 +201,9 @@ function createEventConverter(model: Model<"pi-messages">) { usage: event.usage, responseId: event.responseId, }); + if (event.responseModel !== undefined) { + partial.responseModel = event.responseModel; + } if (event.providerThinkingLevel !== undefined) { partial.providerThinkingLevel = event.providerThinkingLevel; } @@ -211,6 +216,9 @@ function createEventConverter(model: Model<"pi-messages">) { errorMessage: event.errorMessage, responseId: event.responseId, }); + if (event.responseModel !== undefined) { + partial.responseModel = event.responseModel; + } if (event.providerThinkingLevel !== undefined) { partial.providerThinkingLevel = event.providerThinkingLevel; } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index d1fcee314..32b5a5f31 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -588,7 +588,7 @@ export interface AssistantMessage { api: Api; provider: ProviderId; model: string; - responseModel?: string; // Concrete model reported by the provider when different from the requested `model` + responseModel?: string; // Concrete model reported by the provider, when reported. May equal the requested `model` responseId?: string; // Provider-specific response/message identifier when the upstream API exposes one /** Exact provider-native effort level used for this response. Absent for legacy or unmanaged responses. */ providerThinkingLevel?: string; diff --git a/packages/ai/test/pi-messages.test.ts b/packages/ai/test/pi-messages.test.ts index 211c24cb0..54b3d4d9e 100644 --- a/packages/ai/test/pi-messages.test.ts +++ b/packages/ai/test/pi-messages.test.ts @@ -116,6 +116,7 @@ describe("pi-messages", () => { reason: "toolUse", usage, responseId: "resp_1", + responseModel: "claude-opus-5-5", providerThinkingLevel: "high", }, ], @@ -145,6 +146,7 @@ describe("pi-messages", () => { expect(message.responseId).toBe("resp_1"); expect(message.providerThinkingLevel).toBe("high"); expect(message.model).toBe("auto"); + expect(message.responseModel).toBe("claude-opus-5-5"); expect(message.provider).toBe("radius"); expect(message.content).toEqual([ { type: "text", text: "Hello", textSignature: undefined }, @@ -190,6 +192,7 @@ describe("pi-messages", () => { expect(eventModels).toEqual(wireEvents.map(() => model)); expect(message.stopReason).toBe("stop"); expect(message.responseId).toBe("resp_1"); + expect(message).not.toHaveProperty("responseModel"); expect(message.content).toEqual([{ type: "text", text: "Hello", textSignature: undefined }]); }); @@ -234,7 +237,16 @@ describe("pi-messages", () => { it("propagates server-sent error events", async () => { const { baseUrl } = await startServer({ - events: [{ type: "start" }, { type: "error", reason: "error", usage, errorMessage: "Upstream failed" }], + events: [ + { type: "start" }, + { + type: "error", + reason: "error", + usage, + errorMessage: "Upstream failed", + responseModel: "claude-opus-5-5", + }, + ], }); const model = createModel(baseUrl); @@ -243,6 +255,7 @@ describe("pi-messages", () => { expect(message.stopReason).toBe("error"); expect(message.errorMessage).toBe("Upstream failed"); expect(message.usage).toEqual(usage); + expect(message.responseModel).toBe("claude-opus-5-5"); }); it("errors when no API key is provided", async () => { diff --git a/packages/coding-agent/docs/message-types.md b/packages/coding-agent/docs/message-types.md index dfdafd505..6c5d95c63 100644 --- a/packages/coding-agent/docs/message-types.md +++ b/packages/coding-agent/docs/message-types.md @@ -140,7 +140,7 @@ interface AssistantMessage { } ``` -`responseModel` records a concrete provider response model when it differs from the requested model. `responseId`, `providerThinkingLevel`, `thinkingLevel`, `diagnostics`, and `rawStopReason` preserve provider or runtime details. +`responseModel` records the concrete model reported by the provider, when the provider reports one. It may equal the requested model. `responseId`, `providerThinkingLevel`, `thinkingLevel`, `diagnostics`, and `rawStopReason` preserve provider or runtime details. `"pending"` is used for a partial assistant message while it streams. The completed message in `message_end` has a terminal stop reason, and Pi does not persist `"pending"` assistant messages in session JSONL. From a1836b06c3f93af50c76dbbd468b4d4f8771ef15 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Fri, 23 Oct 2026 11:20:01 +0200 Subject: [PATCH 03/33] fix: handle npm 12 pack --json output in preview-packages --- scripts/preview-packages.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/preview-packages.mjs b/scripts/preview-packages.mjs index bf18e9cf4..b979750d3 100644 --- a/scripts/preview-packages.mjs +++ b/scripts/preview-packages.mjs @@ -58,7 +58,9 @@ async function pack(argv) { stdio: ["inherit", "pipe", "inherit"], maxBuffer: 64 * 1024 * 1024, }); - const [info] = JSON.parse(output); + // npm 10/11 print an array, npm 12 prints an object keyed by package name. + const parsed = JSON.parse(output); + const info = Array.isArray(parsed) ? parsed[0] : Object.values(parsed)[0]; packed.push({ name: info.name, file: info.filename }); console.error(`packed ${info.name}@${info.version} -> ${info.filename}`); } finally { From a2fd25f56c07251204168c2dd0e31e8a45669533 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 23 Oct 2026 09:31:09 +0000 Subject: [PATCH 04/33] feat(chord): Watchable, the common interface of values that can be watched --- packages/chord/CHANGELOG.md | 1 + packages/chord/src/index.ts | 9 ++++++++- packages/chord/src/watch.ts | 11 +++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/chord/CHANGELOG.md b/packages/chord/CHANGELOG.md index 5d2dda138..973cf7f2e 100644 --- a/packages/chord/CHANGELOG.md +++ b/packages/chord/CHANGELOG.md @@ -10,6 +10,7 @@ - `WatchHandle` 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`, 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. +- `Watchable`: a value that changes and can be watched (`value`, `watch(context)` returning a `WatchHandle`), the common interface of documents, derives and other observable values. - `NativeWatchHandle` and its implementation `NativeWatch`: 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 diff --git a/packages/chord/src/index.ts b/packages/chord/src/index.ts index d81b621b5..3523e27f1 100644 --- a/packages/chord/src/index.ts +++ b/packages/chord/src/index.ts @@ -78,6 +78,13 @@ 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 { + NativeWatch, + type NativeWatchHandle, + Watch, + type Watchable, + type WatchEnd, + type WatchHandle, +} from "./watch.ts"; export { acceptWebSockets, type WebSocketAcceptOptions, type WebSocketServerLike } from "./websocket.ts"; export { type Frame, PROTOCOL_VERSION } from "./wire.ts"; diff --git a/packages/chord/src/watch.ts b/packages/chord/src/watch.ts index 3b8a19044..d0bbcc6a9 100644 --- a/packages/chord/src/watch.ts +++ b/packages/chord/src/watch.ts @@ -30,6 +30,17 @@ export interface WatchHandle { readonly closed: Promise; } +/** + * A value that changes and can be watched: documents, derives, members of references. Values are immutable by + * convention (JSON for state); each watch starts at the current value and then gets one frame per change. + */ +export interface Watchable { + /** The current value. */ + readonly value: T; + /** A watch holding the current value, then one frame per change; the context's cancellation ends it. */ + watch(context: Context): Promise>; +} + /** Serialized observation of an in-process value: as `WatchHandle`, but a change is a new value without ops. */ export interface NativeWatchHandle { /** Acquisition value before start; latest delivered value afterward. */ From e3bc05e8719ac939c364a2236807376108107afa Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Fri, 23 Oct 2026 12:11:37 +0200 Subject: [PATCH 05/33] fix(ai): send fixed low top-level effort with Anthropic per-message effort Organizations can cap effort, and the previous fixed top-level high was rejected even when the requested level was low or medium. closes #10063 --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/api/anthropic-messages.ts | 5 ++++- packages/ai/test/anthropic-mid-conversation-effort.test.ts | 4 ++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 74aa15c4d..30b17a788 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -5,6 +5,7 @@ ### 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)) +- Fixed Claude models with mid-conversation effort failing with `Invalid effort level` on accounts whose organization caps effort below `high`, even at `low` or `medium` thinking, by sending a fixed `low` top-level effort ([#10063](https://github.com/earendil-works/pi/issues/10063)) ## [1.4.0] - 2026-10-21 diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index b43b11c0e..00d820341 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -1300,7 +1300,10 @@ function buildParams( display: options?.thinkingDisplay ?? "summarized", block_binding: { prefix_mismatch_behavior: "drop_block" }, }; - params.output_config = { effort: "high" }; + // Effort markers set the level for every reply. The top-level effort is part of the cached + // prompt, so it stays fixed. It is "low" because organizations can cap effort and reject + // requests containing any level above the cap. + params.output_config = { effort: "low" }; } else if (model.reasoning && options?.thinkingEnabled) { // Default to "summarized" so Opus 4.7 and Mythos Preview behave like // older Claude 4 models (whose API default is also "summarized"). diff --git a/packages/ai/test/anthropic-mid-conversation-effort.test.ts b/packages/ai/test/anthropic-mid-conversation-effort.test.ts index 3ba1fb2bc..54dad1d95 100644 --- a/packages/ai/test/anthropic-mid-conversation-effort.test.ts +++ b/packages/ai/test/anthropic-mid-conversation-effort.test.ts @@ -108,8 +108,8 @@ describe("Anthropic mid-conversation effort", () => { content: [], output_config: { effort: "high" }, }); - expect(first.payload.output_config).toEqual({ effort: "high" }); - expect(second.payload.output_config).toEqual({ effort: "high" }); + expect(first.payload.output_config).toEqual({ effort: "low" }); + expect(second.payload.output_config).toEqual({ effort: "low" }); expect(second.payload.thinking).toEqual({ type: "adaptive", display: "summarized", From 9ffd27cfaaeed5c1ecf9369f393c3222975729b4 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 23 Oct 2026 12:19:41 +0200 Subject: [PATCH 06/33] fix(mcp): accept only http and https OAuth URLs A malicious MCP server could supply a file: or custom protocol authorization endpoint, which sign-in passed to the platform URL handler. --- packages/coding-agent/CHANGELOG.md | 4 ++++ packages/mcp/CHANGELOG.md | 4 ++++ packages/mcp/src/oauth/flow.ts | 3 +++ packages/mcp/src/oauth/types.ts | 12 ++++++++--- packages/mcp/test/oauth.test.ts | 34 ++++++++++++++++++++++++++++++ 5 files changed, 54 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1870f885f..ed3a38eaa 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,10 @@ - `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)) +### Fixed + +- MCP sign-in now rejects authorization endpoints and other OAuth metadata URLs that are not `http:` or `https:`. Before, a malicious MCP server could supply a `file:` or custom protocol URL, which pi passed to the platform URL handler. + ## [1.4.0] - 2026-10-21 ### New Features diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index da6dedffd..412df6f3f 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- OAuth metadata parsing and `startAuthorization()` now accept only `http:` and `https:` URLs. Before, a server could supply a `file:` or custom protocol authorization endpoint (for example `search-ms:`), which the platform URL handler would launch on sign-in. + ## [1.4.0] - 2026-10-21 ## [1.3.0] - 2026-10-15 diff --git a/packages/mcp/src/oauth/flow.ts b/packages/mcp/src/oauth/flow.ts index 937a5369f..63821fcdb 100644 --- a/packages/mcp/src/oauth/flow.ts +++ b/packages/mcp/src/oauth/flow.ts @@ -21,6 +21,7 @@ import { } from "./errors.ts"; import { type AuthorizationServerMetadata, + isHttpUrl, type OAuthClientInformation, type OAuthClientInformationFull, type OAuthClientInformationMixed, @@ -186,6 +187,8 @@ export async function startAuthorization( throw new Error("Authorization server does not support PKCE S256"); } const url = new URL(metadata?.authorization_endpoint ?? new URL("/authorize", authorizationServerUrl)); + // Callers open this URL with the platform URL handler, which also launches files and custom protocols. + if (!isHttpUrl(url)) throw new Error(`Invalid authorization endpoint ${url.href}`); const { verifier, challenge } = await pkce(); url.searchParams.set("response_type", "code"); url.searchParams.set("client_id", options.clientInformation.client_id); diff --git a/packages/mcp/src/oauth/types.ts b/packages/mcp/src/oauth/types.ts index df5552564..d7d915394 100644 --- a/packages/mcp/src/oauth/types.ts +++ b/packages/mcp/src/oauth/types.ts @@ -123,15 +123,21 @@ function optionalStrings(value: unknown, name: string): string[] | undefined { return [...value]; } +/** + * Accepts only `http:` and `https:` URLs. Servers supply these URLs, and the authorization endpoint is + * opened with the platform URL handler, so a `file:` or custom protocol URL would launch local programs. + */ function safeUrl(value: unknown, name: string): string { const text = requiredString(value, name); // URL parsing throws a `TypeError`, which discovery reserves for network failures. - if (!URL.canParse(text)) throw new Error(`Invalid ${name}`); - const url = new URL(text); - if (["javascript:", "data:", "vbscript:"].includes(url.protocol)) throw new Error(`Invalid ${name}`); + if (!URL.canParse(text) || !isHttpUrl(new URL(text))) throw new Error(`Invalid ${name}`); return text; } +export function isHttpUrl(url: URL): boolean { + return url.protocol === "http:" || url.protocol === "https:"; +} + function optionalUrl(value: unknown, name: string): string | undefined { return absent(value) ? undefined : safeUrl(value, name); } diff --git a/packages/mcp/test/oauth.test.ts b/packages/mcp/test/oauth.test.ts index 90ded0230..c6d175b8c 100644 --- a/packages/mcp/test/oauth.test.ts +++ b/packages/mcp/test/oauth.test.ts @@ -17,6 +17,7 @@ import { OAuthIssuerMismatchError, type OAuthTokens, registerClient, + startAuthorization, } from "../src/oauth/index.ts"; import { closeServers, listen, readBody } from "./helpers.ts"; @@ -450,6 +451,39 @@ describe("MCP OAuth", () => { await expect(discoverAuthorizationServerMetadata(origin)).rejects.toBeInstanceOf(OAuthIssuerMismatchError); }); + it("rejects non-HTTP authorization endpoints, which the platform URL handler would launch", async () => { + const endpoints = ["file://attacker.example/share/x.exe", "search-ms:query=x", "ms-msdt:/id"]; + let endpoint = ""; + const origin = await listen(async (_request, response, serverOrigin) => { + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ + issuer: serverOrigin, + authorization_endpoint: endpoint, + token_endpoint: `${serverOrigin}/token`, + response_types_supported: ["code"], + }), + ); + }); + for (const value of endpoints) { + endpoint = value; + await expect(discoverAuthorizationServerMetadata(origin)).rejects.toThrow("Invalid authorization endpoint"); + const metadata = { + issuer: origin, + authorization_endpoint: value, + token_endpoint: `${origin}/token`, + response_types_supported: ["code"], + }; + await expect( + startAuthorization(origin, { + metadata, + clientInformation: { client_id: "client" }, + redirectUrl: "http://127.0.0.1/callback", + }), + ).rejects.toThrow("Invalid authorization endpoint"); + } + }); + // #10172 it("uses a configured authorization server metadata document as is", async () => { const origin = await listen(async (request, response, serverOrigin) => { From 6dfb5467851adb603d525f9c27e28f341e3f08b9 Mon Sep 17 00:00:00 2001 From: Lincoln <56658553+Lincoln504@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:27:45 -0600 Subject: [PATCH 07/33] fix(coding-agent): start when a configured package fails to install (#11304) fix(coding-agent): start when a configured package fails to install A package in settings.json that could not be installed (registry unreachable, package missing, failing git prepare step) crashed startup with an uncaught rejection and a raw Node stack. Resolving packages now catches the install failure, skips that package the same way offline mode does, and reports it as an extension warning: Warning: Extension package "npm:foo": failed to install (...). Run "pi remove npm:foo" to remove it from settings.json. Project packages point at `pi remove --local`. `-e` sources say to check the source instead. Pi tries the install again on the next start or /reload. --help and --list-models now print runtime diagnostics, so the warning shows there too. pi config prints it as well. pi install and pi update --extensions still fail with a non-zero exit. closes #11088 --- packages/coding-agent/docs/packages.md | 2 + .../coding-agent/src/core/package-manager.ts | 42 +++++++--- .../coding-agent/src/core/resource-loader.ts | 43 +++++++++- packages/coding-agent/src/index.ts | 1 + packages/coding-agent/src/main.ts | 9 +- .../coding-agent/src/package-manager-cli.ts | 10 ++- .../coding-agent/test/package-manager.test.ts | 40 +++++++++ .../coding-agent/test/resource-loader.test.ts | 84 +++++++++++++++++++ 8 files changed, 211 insertions(+), 20 deletions(-) diff --git a/packages/coding-agent/docs/packages.md b/packages/coding-agent/docs/packages.md index 284dd8cb4..a4f609db8 100644 --- a/packages/coding-agent/docs/packages.md +++ b/packages/coding-agent/docs/packages.md @@ -16,6 +16,8 @@ pi install ./local-package `pi list` shows configured packages. Use `pi remove ` to remove one and `pi update --extensions` to reconcile package installations. See [Command Line](cli.md#package-commands) for every package command and option. +If a configured package fails to install at startup, for example because npm cannot reach the registry, Pi warns about it, skips that package's resources, and keeps running. Pi tries to install it again on the next start or `/reload`. `pi install` and `pi update --extensions` still report the failure and exit non-zero. + Personal installs are written to `~/.pi/agent/settings.json`. Add `--local` or `-l` to write the package declaration to `.pi/settings.json`. Pi reads declarations from that file only after project trust is granted. Project packages are installed and loaded only after project trust is resolved. Packages can execute extension code and can include skills that instruct the model to run programs. Review third-party package source before installing it. Review project package declarations before granting project trust. diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 1ca63ebbb..b5015b5ba 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -90,6 +90,15 @@ export interface ResolvedPaths { skills: ResolvedResource[]; prompts: ResolvedResource[]; themes: ResolvedResource[]; + /** Configured packages that were skipped because installing them failed. */ + installFailures?: PackageInstallFailure[]; +} + +/** A configured package that could not be installed while resolving resources. */ +export interface PackageInstallFailure { + source: string; + scope: SourceScope; + message: string; } export type MissingSourceAction = "install" | "skip" | "error"; @@ -939,7 +948,7 @@ export class DefaultPackageManager implements PackageManager { // Dedupe: project scope wins over global for same package identity const packageSources = this.dedupePackages(allPackages); - await this.resolvePackageSources(packageSources, accumulator, onMissing); + const installFailures = await this.resolvePackageSources(packageSources, accumulator, onMissing); const globalBaseDir = this.agentDir; const projectBaseDir = join(this.cwd, CONFIG_DIR_NAME); @@ -991,7 +1000,7 @@ export class DefaultPackageManager implements PackageManager { ); } - return this.toResolvedPaths(accumulator); + return { ...this.toResolvedPaths(accumulator), installFailures }; } async resolveExtensionSources( @@ -1007,8 +1016,8 @@ export class DefaultPackageManager implements PackageManager { const packageSources = sources .filter((source) => !source.startsWith(BUILTIN_PATH_PREFIX)) .map((source) => ({ pkg: source as PackageSource, scope })); - await this.resolvePackageSources(packageSources, accumulator); - return this.toResolvedPaths(accumulator); + const installFailures = await this.resolvePackageSources(packageSources, accumulator); + return { ...this.toResolvedPaths(accumulator), installFailures }; } listConfiguredPackages(): ConfiguredPackage[] { @@ -1289,7 +1298,8 @@ export class DefaultPackageManager implements PackageManager { sources: Array<{ pkg: PackageSource; scope: SourceScope }>, accumulator: ResourceAccumulator, onMissing?: (source: string) => Promise, - ): Promise { + ): Promise { + const installFailures: PackageInstallFailure[] = []; for (const { pkg, scope } of sources) { const sourceStr = typeof pkg === "string" ? pkg : pkg.source; const filter = typeof pkg === "object" ? pkg : undefined; @@ -1307,15 +1317,24 @@ export class DefaultPackageManager implements PackageManager { const installMissing = async (): Promise => { if (isOfflineModeEnabled()) return false; - if (!onMissing) { + if (onMissing) { + const action = await onMissing(resolvedSource); + if (action === "skip") return false; + if (action === "error") throw new Error(`Missing source: ${resolvedSource}`); + } + // A source that cannot be installed (no network, missing package, failing git prepare step) + // must not abort startup. Skip its resources, like offline mode does, and report the failure. + try { await this.installParsedSource(parsed, resolvedScope); return true; + } catch (error) { + installFailures.push({ + source: resolvedSource, + scope: resolvedScope, + message: error instanceof Error ? error.message : String(error), + }); + return false; } - const action = await onMissing(resolvedSource); - if (action === "skip") return false; - if (action === "error") throw new Error(`Missing source: ${resolvedSource}`); - await this.installParsedSource(parsed, resolvedScope); - return true; }; if (parsed.type === "npm") { @@ -1346,6 +1365,7 @@ export class DefaultPackageManager implements PackageManager { this.collectPackageResources(installedPath, accumulator, filter, metadata); } } + return installFailures; } private findAutoloadDeltaBase( diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts index 3d59416af..5957abf27 100644 --- a/packages/coding-agent/src/core/resource-loader.ts +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -2,7 +2,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { basename, dirname, join, resolve, sep } from "node:path"; import { detectCapabilities, getTerminalColorMode, type TerminalColorMode } from "@earendil-works/pi-tui"; import chalk from "chalk"; -import { CONFIG_DIR_NAME } from "../config.ts"; +import { APP_NAME, CONFIG_DIR_NAME } from "../config.ts"; import { loadThemeFromPath, type Theme } from "../modes/interactive/theme/theme.ts"; import type { ResourceDiagnostic } from "./diagnostics.ts"; @@ -25,7 +25,12 @@ import type { LoadExtensionsResult, } from "./extensions/types.ts"; import { findGitPaths } from "./footer-data-provider.ts"; -import { DefaultPackageManager, type PathMetadata, type ResolvedResource } from "./package-manager.ts"; +import { + DefaultPackageManager, + type PackageInstallFailure, + type PathMetadata, + type ResolvedResource, +} from "./package-manager.ts"; import type { PromptTemplate } from "./prompt-templates.ts"; import { loadPromptTemplates } from "./prompt-templates.ts"; import { SettingsManager } from "./settings-manager.ts"; @@ -96,6 +101,28 @@ function collectExtensionPackageWarnings( return warnings; } +/** Describe a package that was skipped because it could not be installed, including how to drop it. */ +function describePackageInstallFailure(failure: PackageInstallFailure): string { + const hint = + failure.scope === "temporary" + ? "Check the source and try again." + : `Run "${APP_NAME} remove ${failure.source}${failure.scope === "project" ? " --local" : ""}" to remove it from settings.json.`; + return `failed to install (${failure.message}). ${hint}`; +} + +/** One-line startup warning for a package that could not be installed. */ +export function formatPackageInstallWarning(failure: PackageInstallFailure): string { + return `Extension package "${failure.source}": ${describePackageInstallFailure(failure)}`; +} + +function collectPackageInstallWarnings( + ...failures: Array +): Array<{ path: string; warning: string }> { + return failures.flatMap((entries) => + (entries ?? []).map((failure) => ({ path: failure.source, warning: describePackageInstallFailure(failure) })), + ); +} + function mergeExtensionWarnings( result: LoadExtensionsResult, warnings: Array<{ path: string; warning: string }>, @@ -530,6 +557,10 @@ export class DefaultResourceLoader implements ResourceLoader { const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, { temporary: true, }); + const installWarnings = collectPackageInstallWarnings( + resolvedPaths.installFailures, + cliExtensionPaths.installFailures, + ); // Kept on the instance so post-reload passes (extendResources) can still resolve package metadata. this.resourceMetadataByPath = new Map(); const metadataByPath = this.resourceMetadataByPath; @@ -584,7 +615,7 @@ export class DefaultResourceLoader implements ResourceLoader { const packageWarnings = collectExtensionPackageWarnings(extensionPaths, metadataByPath); const extensionsResult = await this.loadFinalExtensionSet(extensionPaths, preTrustExtensions); - mergeExtensionWarnings(extensionsResult, packageWarnings); + mergeExtensionWarnings(extensionsResult, [...installWarnings, ...packageWarnings]); for (const p of this.additionalExtensionPaths) { if (isLocalPath(p)) { const resolved = this.resolveResourcePath(p); @@ -696,8 +727,12 @@ export class DefaultResourceLoader implements ResourceLoader { ]), ); const packageWarnings = collectExtensionPackageWarnings(extensionPaths, metadataByPath); + const installWarnings = collectPackageInstallWarnings( + resolvedPaths.installFailures, + cliExtensionPaths.installFailures, + ); const extensionsResult = await loadExtensionsCached(extensionPaths, this.cwd, this.eventBus); - mergeExtensionWarnings(extensionsResult, packageWarnings); + mergeExtensionWarnings(extensionsResult, [...installWarnings, ...packageWarnings]); if (!options.includeInlineFactories) { return extensionsResult; } diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 0b8fb1dd2..f86470ad2 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -236,6 +236,7 @@ export { type ModelRuntimeAuthOverrides, } from "./core/model-runtime.ts"; export type { + PackageInstallFailure, PackageManager, PathMetadata, ProgressCallback, diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 51df447c5..723fc910b 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -875,8 +875,11 @@ export async function main(args: string[], options?: MainOptions) { applyHttpProxySettings(settingsManager.getGlobalSettings().httpProxy); configureHttpDispatcher(settingsManager.getHttpIdleTimeoutMs()); + const startupDiagnostics = deduplicateDiagnostics([...startupSettingsDiagnostics, ...runtime.diagnostics]); + const hasRuntimeErrors = runtime.diagnostics.some((diagnostic) => diagnostic.type === "error"); + if (parsed.help) { - reportDiagnostics(startupSettingsDiagnostics); + reportDiagnostics(startupDiagnostics); const extensionFlags = resourceLoader .getExtensions() .extensions.flatMap((extension) => Array.from(extension.flags.values())); @@ -885,7 +888,7 @@ export async function main(args: string[], options?: MainOptions) { } if (parsed.listModels !== undefined) { - reportDiagnostics(startupSettingsDiagnostics); + reportDiagnostics(startupDiagnostics); const searchPattern = typeof parsed.listModels === "string" ? parsed.listModels : undefined; await listModels(modelRuntime, searchPattern, AbortSignal.timeout(15_000)); process.exit(0); @@ -912,8 +915,6 @@ export async function main(args: string[], options?: MainOptions) { } time("resolveModelScope"); - const startupDiagnostics = deduplicateDiagnostics([...startupSettingsDiagnostics, ...runtime.diagnostics]); - const hasRuntimeErrors = runtime.diagnostics.some((diagnostic) => diagnostic.type === "error"); if (appMode !== "interactive" || hasRuntimeErrors) { reportDiagnostics(startupDiagnostics); } diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index 2eda49536..5696b66ad 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -31,7 +31,7 @@ import type { InlineExtension } from "./core/extensions/types.ts"; import { ModelRuntime } from "./core/model-runtime.ts"; import { DefaultPackageManager } from "./core/package-manager.ts"; import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts"; -import { DefaultResourceLoader, isBuiltinExtension } from "./core/resource-loader.ts"; +import { DefaultResourceLoader, formatPackageInstallWarning, isBuiltinExtension } from "./core/resource-loader.ts"; import { SettingsManager } from "./core/settings-manager.ts"; import { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/trust-manager.ts"; import { spawnProcess, spawnProcessSync, waitForChildProcess } from "./utils/child-process.ts"; @@ -876,6 +876,14 @@ export async function handleConfigCommand( const projectResolvedPaths = settingsManager.isProjectTrusted() ? await new DefaultPackageManager({ cwd, agentDir, settingsManager, builtinExtensions }).resolve() : globalResolvedPaths; + const installWarnings = new Set( + [...(globalResolvedPaths.installFailures ?? []), ...(projectResolvedPaths.installFailures ?? [])].map( + formatPackageInstallWarning, + ), + ); + for (const warning of installWarnings) { + console.error(chalk.yellow(`Warning: ${warning}`)); + } await selectConfig({ resolvedPaths: { global: globalResolvedPaths, project: projectResolvedPaths }, diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index 8aedd93c4..4aaa60ab3 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -2406,6 +2406,46 @@ export default function(api) { api.registerTool({ name: "test", description: "te }); }); + describe("package install failures", () => { + // #11088 + it("should skip a package source that fails to install and report it", async () => { + settingsManager.setProjectPackages(["npm:broken-package"]); + const installParsedSource = vi + .spyOn(packageManager as any, "installParsedSource") + .mockRejectedValue(new Error("npm install broken-package failed with code 1")); + + const result = await packageManager.resolve(); + + expect(installParsedSource).toHaveBeenCalledTimes(1); + expect(result.installFailures).toEqual([ + { + source: "npm:broken-package", + scope: "project", + message: "npm install broken-package failed with code 1", + }, + ]); + expect(result.extensions).toEqual([]); + }); + + it("should not report install failures for sources that are skipped", async () => { + settingsManager.setProjectPackages(["npm:missing-package"]); + const installParsedSource = vi.spyOn(packageManager as any, "installParsedSource"); + + const result = await packageManager.resolve(async () => "skip"); + + expect(installParsedSource).not.toHaveBeenCalled(); + expect(result.installFailures).toEqual([]); + }); + + it("should keep a missing source an error when the caller asks for one", async () => { + settingsManager.setProjectPackages(["npm:missing-package"]); + + await expect(packageManager.resolve(async () => "error")).rejects.toThrow( + "Missing source: npm:missing-package", + ); + }); + }); + describe("offline mode and network timeouts", () => { it("should update npm range packages using the configured spec", async () => { const installedPath = join(tempDir, ".pi", "npm", "node_modules", "example"); diff --git a/packages/coding-agent/test/resource-loader.test.ts b/packages/coding-agent/test/resource-loader.test.ts index bba3fc95a..e68b4cfea 100644 --- a/packages/coding-agent/test/resource-loader.test.ts +++ b/packages/coding-agent/test/resource-loader.test.ts @@ -84,6 +84,90 @@ describe("DefaultResourceLoader", () => { ]); }); + // #11088 + it("should warn and keep loading when a configured package cannot be installed", async () => { + vi.stubEnv("PI_OFFLINE", ""); + const loader = new DefaultResourceLoader({ + cwd, + agentDir, + settingsManager: SettingsManager.inMemory({ + packages: ["npm:broken-package"], + // Any non-zero exit stands in for an npm install that cannot reach the registry. + npmCommand: [process.execPath, "-e", "process.exit(1)"], + }), + }); + + await loader.reload(); + + const warnings = loader.getExtensions().warnings ?? []; + expect(warnings).toHaveLength(1); + expect(warnings[0].path).toBe("npm:broken-package"); + expect(warnings[0].warning).toMatch(/^failed to install \(.*failed with code 1\)\./); + expect(warnings[0].warning).toContain('Run "pi remove npm:broken-package" to remove it from settings.json.'); + }); + + // #11088 + it("should point temporary extension sources at the source instead of pi remove", async () => { + vi.stubEnv("PI_OFFLINE", ""); + const loader = new DefaultResourceLoader({ + cwd, + agentDir, + additionalExtensionPaths: ["npm:broken-package"], + settingsManager: SettingsManager.inMemory({ + npmCommand: [process.execPath, "-e", "process.exit(1)"], + }), + }); + + await loader.reload(); + + const warnings = loader.getExtensions().warnings ?? []; + expect(warnings).toHaveLength(1); + expect(warnings[0].path).toBe("npm:broken-package"); + expect(warnings[0].warning).toContain("Check the source and try again."); + expect(warnings[0].warning).not.toContain("pi remove"); + }); + + // #11088 + it("should point project extension packages at pi remove --local", async () => { + vi.stubEnv("PI_OFFLINE", ""); + writeFileSync( + join(agentDir, "settings.json"), + JSON.stringify({ npmCommand: [process.execPath, "-e", "process.exit(1)"] }), + ); + mkdirSync(join(cwd, ".pi"), { recursive: true }); + writeFileSync(join(cwd, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:broken-package"] })); + const loader = new DefaultResourceLoader({ + cwd, + agentDir, + settingsManager: SettingsManager.create(cwd, agentDir, { projectTrusted: true }), + }); + + await loader.reload(); + + const warnings = loader.getExtensions().warnings ?? []; + expect(warnings).toHaveLength(1); + expect(warnings[0].warning).toContain('Run "pi remove npm:broken-package --local"'); + }); + + // #11088 + it("should warn once about a failing install when project trust is resolved", async () => { + vi.stubEnv("PI_OFFLINE", ""); + const loader = new DefaultResourceLoader({ + cwd, + agentDir, + settingsManager: SettingsManager.inMemory({ + packages: ["npm:broken-package"], + npmCommand: [process.execPath, "-e", "process.exit(1)"], + }), + }); + + await loader.reload({ resolveProjectTrust: async () => false }); + + const warnings = loader.getExtensions().warnings ?? []; + expect(warnings).toHaveLength(1); + expect(warnings[0].path).toBe("npm:broken-package"); + }); + it("should fail when an extension package manifest cannot be parsed", async () => { const packageRoot = join(tempDir, "invalid-extension-package"); const extensionsDir = join(packageRoot, "extensions"); From 8a2ebd38549c3521a02db0e5d758a24320e97f94 Mon Sep 17 00:00:00 2001 From: "robopipi[bot]" <336283572+robopipi[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:46:03 +0200 Subject: [PATCH 08/33] fix(tui): stop nested blockquote rendering from growing exponentially (#11321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Problem.** A line of many `>` characters makes `marked` produce one nested blockquote token per `>`. The blockquote branch of `Markdown.renderToken` renders its children at `Math.max(1, width - 2)` columns, then wraps each child line again at that width and adds a `│ ` prefix. Once the nesting uses up the width (about 39 levels at 80 columns), the width stays clamped at 1. From then on, each child line (`│ x`, 3 columns wide) is wrapped into about 3 lines at every further level, so the output roughly triples per level. At 80 columns: - 50 levels: 4096 lines, about 30 s to render; - 55 levels: `push(...lines)` exceeds the JavaScript argument limit and throws `RangeError: Maximum call stack size exceeded`, which crashes pi. Reopening the session crashes it again. **Fix.** - When there is no room left for another border (`width < 3`), the blockquote branch now renders its child tokens at the same width without adding a border and without wrapping them again. - Child lines are appended with a loop instead of a spread `push`. Quotes that fit in the width render exactly as before. **Testing.** - Added a regression test in `packages/tui/test/markdown.test.ts`: 60 nested `>` rendered at width 80 give one line that keeps the content. Without the fix the test fails with the RangeError; with it, all 92 tests in that file pass (`node --test test/markdown.test.ts`). - `npm run check` passes. - Reproduction script rendering `">".repeat(n) + "x"` at width 80: - before: 50 levels gave 4096 lines in 29.5 s, and 55 or 60 levels threw a RangeError; - after: 40, 50, 60 and 200 levels each give 1 line in about 10 ms. **Not covered.** A run of about 2000 `>` still overflows the stack inside `marked`'s own lexer, before pi's renderer runs. That is a separate limit and is not changed here. Fixes #11272 Requested by @davidbrai Co-authored-by: triagent --- packages/tui/src/components/markdown.ts | 26 +++++++++++++++++++++---- packages/tui/test/markdown.test.ts | 12 ++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index aa2166277..9194c2251 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -596,8 +596,21 @@ export class Markdown implements Component { return quoteStyle(lineWithReappliedStyle); }; + // With no room for another border ("│ " plus at least one content column), render the + // nested content without one. Otherwise each extra level re-wraps every child line at + // width 1 and the output grows exponentially with nesting depth. + if (width < 3) { + const quoteTokens = token.tokens || []; + for (let i = 0; i < quoteTokens.length; i++) { + for (const line of this.renderToken(quoteTokens[i], width, quoteTokens[i + 1]?.type, styleContext)) { + lines.push(line); + } + } + break; + } + // Calculate available width for quote content (subtract border "│ " = 2 chars) - const quoteContentWidth = Math.max(1, width - 2); + const quoteContentWidth = width - 2; // Blockquotes contain block-level tokens (paragraph, list, code, etc.), so render // children with renderToken() instead of renderInlineTokens(). @@ -611,9 +624,14 @@ export class Markdown implements Component { for (let i = 0; i < quoteTokens.length; i++) { const quoteToken = quoteTokens[i]; const nextQuoteToken = quoteTokens[i + 1]; - renderedQuoteLines.push( - ...this.renderToken(quoteToken, quoteContentWidth, nextQuoteToken?.type, quoteInlineStyleContext), - ); + for (const line of this.renderToken( + quoteToken, + quoteContentWidth, + nextQuoteToken?.type, + quoteInlineStyleContext, + )) { + renderedQuoteLines.push(line); + } } // Avoid rendering an extra empty quote line before the outer blockquote spacing. diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index 1cc43381b..38e6ceb5a 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -1367,6 +1367,18 @@ again, hello world`, }); }); + describe("Deeply nested blockquotes", () => { + // Regression test for https://github.com/earendil-works/pi/issues/11272 + it("should render quotes nested deeper than the width allows without exploding", () => { + const markdown = new Markdown(`${">".repeat(60)}x`, 1, 0, defaultMarkdownTheme); + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + assert.strictEqual(plainLines.length, 1, `Expected a single line: ${JSON.stringify(plainLines)}`); + assert.ok(plainLines[0].endsWith("x"), `Expected content to be kept: ${JSON.stringify(plainLines)}`); + }); + }); + describe("Blockquotes with multiline content", () => { it("should apply consistent styling to all lines in lazy continuation blockquote", () => { // Markdown "lazy continuation" - second line without > is still part of the quote From 36be9c612e64082a4f0d4d0a94706c38ecbc20a1 Mon Sep 17 00:00:00 2001 From: "robopipi[bot]" <336283572+robopipi[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:47:34 +0200 Subject: [PATCH 09/33] fix(coding-agent): decode pi.exec output across UTF-8 chunk boundaries (#11326) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `execCommand()` (the function behind `pi.exec()`) decoded each stdout and stderr chunk on its own with `data.toString()`. When a multi-byte UTF-8 character was split across two pipe chunks, each half was turned into U+FFFD, so the result was corrupted. For example, `"你好"` came back as `"���好"` while the command still exited with code 0. This change gives stdout and stderr each their own `StringDecoder("utf8")`. A decoder holds back incomplete bytes until the rest of the character arrives. Both decoders are flushed with `end()` before the promise resolves, on both the success path and the error path. `waitForChildProcess()` can resolve without a stream `end` event, so the decoders are flushed there and not in an `end` handler. The return shape, exit codes, and kill behavior are unchanged. **Testing** - Added a regression test to `packages/coding-agent/test/exec.test.ts`. The child process splits `"你好"` across two writes on both stdout and stderr. The test fails without the fix and passes with it: `node ../../node_modules/vitest/dist/cli.js --run test/exec.test.ts` reports 3 passed. - `npm run check` passes. - Reproduction from the issue: a child that writes the first byte of `"你好"` and then the rest 100 ms later. Before the fix the result was `{"stdout":"���好",...}`. After the fix it is `{"stdout":"你好","stderr":"","code":0,"killed":false}`. Fixes #11312 Requested by @davidbrai --------- Co-authored-by: triagent Co-authored-by: David Brailovsky --- packages/coding-agent/src/core/exec.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/core/exec.ts b/packages/coding-agent/src/core/exec.ts index 53e53366a..cf4205f7f 100644 --- a/packages/coding-agent/src/core/exec.ts +++ b/packages/coding-agent/src/core/exec.ts @@ -3,6 +3,7 @@ */ import { spawn } from "node:child_process"; +import { StringDecoder } from "node:string_decoder"; import { waitForChildProcess } from "../utils/child-process.ts"; /** @@ -46,6 +47,10 @@ export async function execCommand( let stdout = ""; let stderr = ""; + // Decode across chunks: a multi-byte UTF-8 character can be split between two chunks, + // and decoding each chunk separately would corrupt it (earendil-works/pi#11312). + const stdoutDecoder = new StringDecoder("utf8"); + const stderrDecoder = new StringDecoder("utf8"); let killed = false; let timeoutId: NodeJS.Timeout | undefined; @@ -79,11 +84,11 @@ export async function execCommand( } proc.stdout?.on("data", (data) => { - stdout += data.toString(); + stdout += stdoutDecoder.write(data); }); proc.stderr?.on("data", (data) => { - stderr += data.toString(); + stderr += stderrDecoder.write(data); }); // Wait for process termination without hanging on inherited stdio handles @@ -91,6 +96,8 @@ export async function execCommand( waitForChildProcess(proc) .then((code) => { if (timeoutId) clearTimeout(timeoutId); + stdout += stdoutDecoder.end(); + stderr += stderrDecoder.end(); if (options?.signal) { options.signal.removeEventListener("abort", killProcess); } @@ -98,6 +105,8 @@ export async function execCommand( }) .catch((error: Error) => { if (timeoutId) clearTimeout(timeoutId); + stdout += stdoutDecoder.end(); + stderr += stderrDecoder.end(); if (options?.signal) { options.signal.removeEventListener("abort", killProcess); } From ede2c9b468d8e061c557d16957513715e6d49cd6 Mon Sep 17 00:00:00 2001 From: Christian Klotz Date: Fri, 23 Oct 2026 17:02:41 +0200 Subject: [PATCH 10/33] docs: fix provider checklist, MCP anchors, and missing commands/settings (#11338) Fix docs and `--help` drift: update the provider checklist to the `src/api` layout, fix broken MCP anchors, and document `/mcp`, `pi mcp add|remove`, `PI_CLEAR_ON_SHRINK`, and `"disabled"` timeouts. closes #11243 --- .pi/skills/add-llm-provider.md | 41 +++++++++---------- packages/ai/README.md | 9 ++-- packages/coding-agent/docs/cli.md | 6 +-- .../docs/environment-variables.md | 1 + packages/coding-agent/docs/extensions.md | 2 +- packages/coding-agent/docs/settings.md | 6 +-- packages/coding-agent/docs/slash-commands.md | 1 + packages/coding-agent/src/cli/args.ts | 4 +- 8 files changed, 35 insertions(+), 35 deletions(-) diff --git a/.pi/skills/add-llm-provider.md b/.pi/skills/add-llm-provider.md index 97cd9a3ec..884d3da7f 100644 --- a/.pi/skills/add-llm-provider.md +++ b/.pi/skills/add-llm-provider.md @@ -9,32 +9,33 @@ A new provider touches multiple files. Work through these steps in order. ## 1. Core Types (`packages/ai/src/types.ts`) -- Add API identifier to `Api` type union (e.g. `"bedrock-converse-stream"`). -- Create options interface extending `StreamOptions`. -- Add mapping to `ApiOptionsMap`. -- Add provider name to `KnownProvider` type union. +- Add the provider name to the `KnownProvider` union. +- Only for a new API: add the API identifier to `KnownApi` (e.g. `"bedrock-converse-stream"`) and its options type to `ApiOptionsMap`. -## 2. Provider Implementation (`packages/ai/src/providers/`) +## 2. API Implementation (`packages/ai/src/api/`, only for a new API) -Create a provider file exporting: +Most providers reuse an existing API such as `openai-completions`; skip this step then. -- `stream()` returning `AssistantMessageEventStream`. -- `streamSimple()` for `SimpleStreamOptions` mapping. -- Provider-specific options interface. -- Message/tool conversion functions. -- Response parsing that emits standardized events (`text`, `tool_call`, `thinking`, `usage`, `stop`). +- `src/api/.ts` exports exactly `stream` and `streamSimple`, plus an options interface extending `StreamOptions`. +- Convert messages and tools to the provider format. Read the prompt and tools from the transcript with `getInitialSystemMessage()`, `getCurrentTools()`, and `resolveTranscript()`. +- Parse responses into standardized events (`text`, `tool_call`, `thinking`, `usage`, `stop`). +- Add a lazy wrapper `src/api/.lazy.ts` (`Api()` via `lazyApi()`) so providers can reference the implementation without importing its SDK. +- Register the lazy wrapper in the builtin API list in `src/compat.ts`. +- Add `export type` re-exports in `src/index.ts` for option types that should be available from the root entry. The `./api/*` subpath export in `package.json` already covers the new module. -## 3. Provider Exports and Lazy Registration - -- Add a package subpath export in `packages/ai/package.json` pointing at `./dist/providers/.js`. -- Add `export type` re-exports in `packages/ai/src/index.ts` for provider option types that should remain available from the root entry. -- Register the provider in `packages/ai/src/providers/register-builtins.ts` via lazy loader wrappers; do not statically import provider implementation modules there. -- Add credential detection in `packages/ai/src/env-api-keys.ts`. - -## 4. Model Generation (`packages/ai/scripts/generate-models.ts`) +## 3. Model Generation (`packages/ai/scripts/generate-models.ts`) - Add logic to fetch/parse models from the provider source. - Map to the standardized `Model` interface. +- Regenerate. This writes the stable catalog wrapper `src/providers/.models.ts` (exporting `_MODELS`) that the provider factory imports, and updates `src/models.generated.ts`. + +## 4. Provider Factory and Auth (`packages/ai/src/providers/`) + +- Create `src/providers/.ts` exporting `Provider()`, which calls `createProvider({ id, name, baseUrl, auth, models, api })`. `name` is the display name shown by `/login` and related UI. See `deepseek.ts` for a minimal example. +- Auth: `envApiKeyAuth()` for standard API keys, a custom `ApiKeyAuth` for ambient credentials (AWS profiles, ADC), `lazyOAuth()` where an OAuth flow exists. +- Pass the lazy API wrapper (e.g. `openAICompletionsApi()`) as `api`; do not statically import API implementation modules. +- Register the factory in `src/providers/all.ts`. +- Add the env var to `src/env-api-keys.ts` so the compat `getEnvApiKey()` finds it. ## 5. Tests (`packages/ai/test/`) @@ -46,9 +47,7 @@ Create a provider file exporting: ## 6. Coding Agent (`packages/coding-agent/`) - `src/core/model-resolver.ts`: add default model ID to `defaultModelPerProvider`. -- `src/core/provider-display-names.ts`: add API-key login display name so `/login` and related UI show the provider for built-in API-key auth. - `src/cli/args.ts`: add env var documentation. -- `README.md`: add provider setup instructions. - `docs/providers.md`: add setup instructions, env var, and `auth.json` key. ## 7. Documentation diff --git a/packages/ai/README.md b/packages/ai/README.md index 61038c7cf..37fbcb7e2 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -1940,7 +1940,7 @@ Add a lazy wrapper `src/api/.lazy.ts` (`Api()` via `lazyApi()`) so - `createProvider()` wiring catalog + auth + the lazy API wrapper - Auth: `envApiKeyAuth` for standard key providers, a custom `ApiKeyAuth` for ambient auth (AWS profiles, ADC), `lazyOAuth` where an OAuth flow exists - Register the factory in `src/providers/all.ts` -- If it is a new API: register it in the builtin list in `src/compat.ts` and add the package subpath export in `package.json` +- If it is a new API: register it in the builtin list in `src/compat.ts`; the `./api/*` subpath export in `package.json` already covers the new module #### 5. Tests (`test/`) @@ -1951,7 +1951,6 @@ Create or update test files to cover the new provider: - `abort.test.ts` - Request cancellation - `empty.test.ts` - Empty message handling - `context-overflow.test.ts` - Context limit errors -- `image-limits.test.ts` - Image support (if applicable) - `unicode-surrogate.test.ts` - Unicode handling - `tool-call-without-result.test.ts` - Orphaned tool calls - `image-tool-result.test.ts` - Images in tool results @@ -1967,15 +1966,15 @@ For providers with non-standard auth (AWS, Google Vertex), create a utility like Update `src/core/model-resolver.ts`: -- Add a default model ID for the provider in `DEFAULT_MODELS` +- Add a default model ID for the provider in `defaultModelPerProvider` Update `src/cli/args.ts`: - Add environment variable documentation in the help text -Update `README.md`: +Update `docs/providers.md`: -- Add the provider to the providers section with setup instructions +- Add setup instructions, the environment variable, and the `auth.json` key #### 7. Documentation diff --git a/packages/coding-agent/docs/cli.md b/packages/coding-agent/docs/cli.md index 6719bd355..c471f7576 100644 --- a/packages/coding-agent/docs/cli.md +++ b/packages/coding-agent/docs/cli.md @@ -13,7 +13,7 @@ pi update [target] [options] pi list pi config [options] pi auth [options] -pi mcp [options] +pi mcp [options] ``` @@ -148,7 +148,7 @@ The MCP resource tools (`list_mcp_resources`, `list_mcp_resource_templates`, `re | `find` | Find paths using glob patterns | | `ls` | List directory contents | -Built-in extensions add two more tools. They are off by default; the MCP extension turns them on when an MCP server needs them (see [MCP](mcp.md#exposure)). To enable them yourself, name them in `--tools` or `defaultTools`. +Built-in extensions add two more tools. They are off by default; the MCP extension turns them on when an MCP server needs them (see [MCP](mcp.md#control-tool-exposure)). To enable them yourself, name them in `--tools` or `defaultTools`. | Built-in extension | Purpose | |---|---| @@ -328,6 +328,6 @@ These commands work outside a session, so agents can run them through `bash`. Se | `pi mcp login [--timeout ]` | Sign in to an OAuth server: open the authorization page and wait for the browser; a terminal also accepts the pasted redirect URL. `--timeout` limits the whole sign-in, including requests to the authorization server (default 300 seconds) | | `pi mcp logout ` | Delete the stored OAuth credentials of a server | -`add` and `remove` change `~/.pi/agent/mcp.json`, or `.pi/mcp.json` in the current directory with `--local` (`-l`). `add` also takes `--exposure ` (see [Exposure](mcp.md#exposure)) and `--description ` and does not connect; run `pi mcp list` to check the server. +`add` and `remove` change `~/.pi/agent/mcp.json`, or `.pi/mcp.json` in the current directory with `--local` (`-l`). `add` also takes `--exposure ` (see [Exposure](mcp.md#control-tool-exposure)) and `--description ` and does not connect; run `pi mcp list` to check the server. Project `.pi/mcp.json` files are only read for projects that are already trusted. diff --git a/packages/coding-agent/docs/environment-variables.md b/packages/coding-agent/docs/environment-variables.md index 828d617dd..b485d6051 100644 --- a/packages/coding-agent/docs/environment-variables.md +++ b/packages/coding-agent/docs/environment-variables.md @@ -88,6 +88,7 @@ These variables are read by Pi itself: | `PI_CACHE_RETENTION` | Set to `long` for extended provider prompt caching where supported | | `PI_SHARE_VIEWER_URL` | Override the base URL used by `/share` | | `PI_RADIUS_GATEWAY` | Override the Radius gateway origin used by `/bug` uploads | +| `PI_CLEAR_ON_SHRINK` | Set to `1` to clear empty rows when rendered content shrinks; the `terminal.clearOnShrink` setting takes precedence. See [Settings](settings.md) | | `PI_HARDWARE_CURSOR` | Set to `1` to show the hardware cursor; see [Terminal setup](terminal-setup.md) | | `PI_CLIPBOARD_OSC52` | Set to `1` to also send copied text to the terminal with OSC 52 after the local clipboard write, as Pi does over SSH. Use it when a remote client reaches Pi without SSH variables, such as a multiplexer attached from another machine or a remote IDE daemon | | `PI_HYPERLINKS` | Override OSC 8 hyperlink detection with `1`, `0`, or `auto` | diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 5cdb83b43..7580137f6 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -315,7 +315,7 @@ pi.unregisterMcpServer("jira"); Servers registered while the extension loads connect when the session starts, together with the `mcp.json` servers; servers registered later connect right away, and `pi.unregisterMcpServer()` closes the connection and makes the server's tools unreachable. Registrations are not saved: register again on every load, for example based on the extension's own settings. A server in `mcp.json` with the same name takes precedence, and `/mcp` shows the override. Registering the same name again replaces the extension's earlier registration; names registered by another extension, invalid names, and invalid configs throw. -The built-in MCP support connects registered servers. When nothing does, because another extension replaced it (see [MCP](mcp.md#other-mcp-extensions)), each registration is reported as an extension error. Other MCP extensions can connect registered servers too: read them with `pi.getMcpServers()` on `session_start` and handle the `mcp_servers_change` event for later changes. +The built-in MCP support connects registered servers. When nothing does, because another extension replaced it (see [MCP](mcp.md#replace-the-built-in-mcp-support)), each registration is reported as an extension error. Other MCP extensions can connect registered servers too: read them with `pi.getMcpServers()` on `session_start` and handle the `mcp_servers_change` event for later changes. diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 7e2898525..150eaf4d7 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -105,7 +105,7 @@ See [Compaction Reference](compaction.md) for trigger, summarization, and valida | `showHardwareCursor` | boolean | `false` | Use the terminal cursor instead of Pi's drawn cursor. Pi still positions it for input methods. | | `terminal.showImages` | boolean | `true` | Display inline images when supported. | | `terminal.imageWidthCells` | number | `60` | Preferred inline image width in terminal cells. | -| `terminal.clearOnShrink` | boolean | `false` | Clear empty rows when rendered content shrinks. | +| `terminal.clearOnShrink` | boolean | `false` | Clear empty rows when rendered content shrinks. When unset, `PI_CLEAR_ON_SHRINK=1` enables it. | | `terminal.showTerminalProgress` | boolean | `false` | Show OSC 9;4 progress in the terminal tab. | | `terminal.hyperlinks` | `boolean \| "auto"` | `"auto"` | Override OSC 8 hyperlink detection. | | `terminal.images` | `"kitty" \| "iterm2" \| "auto" \| false` | `"auto"` | Override inline-image protocol detection. | @@ -123,8 +123,8 @@ See [Themes](themes.md) and [Terminal Setup](terminal-setup.md) for format and p |---|---|---|---| | `transport` | `"auto" \| "sse" \| "websocket" \| "websocket-cached"` | `"auto"` | Preferred transport for AI providers that support multiple transports. | | `httpProxy` | string | None | Proxy URL applied as `HTTP_PROXY` and `HTTPS_PROXY` for Pi-managed HTTP clients. **Can only be set in agent-directory settings.** | -| `httpIdleTimeoutMs` | number | `300000` | HTTP header and body idle timeout in milliseconds. Set to `0` to disable. | -| `websocketConnectTimeoutMs` | number | `15000` | WebSocket connection timeout in milliseconds. Set to `0` to disable. | +| `httpIdleTimeoutMs` | number \| `"disabled"` | `300000` | HTTP header and body idle timeout in milliseconds. Set to `0` or `"disabled"` to disable. | +| `websocketConnectTimeoutMs` | number \| `"disabled"` | `15000` | WebSocket connection timeout in milliseconds. Set to `0` or `"disabled"` to disable. | | `retry.enabled` | boolean | `true` | Enable automatic agent-level retry for transient failures. | | `retry.maxRetries` | number | `3` | Maximum agent-level retry attempts. | | `retry.baseDelayMs` | number | `2000` | Initial exponential-backoff delay in milliseconds. | diff --git a/packages/coding-agent/docs/slash-commands.md b/packages/coding-agent/docs/slash-commands.md index a55245f09..f2b5f3bac 100644 --- a/packages/coding-agent/docs/slash-commands.md +++ b/packages/coding-agent/docs/slash-commands.md @@ -46,6 +46,7 @@ Review a session before exporting or sharing it. Sessions can contain prompts, t | Command | Description | |---|---| | `/trust` | Save a project trust decision for future Pi processes | +| `/mcp` | Manage MCP servers: sign in, reconnect, enable or disable, and change exposure | | `/reload` | Reload keybindings, extensions, skills, templates, themes, and context files | | `/hotkeys` | Show active keyboard shortcuts | | `/changelog` | Show changelog entries | diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 38d268a9f..1cbfc228b 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -294,7 +294,7 @@ ${chalk.bold("Commands:")} ${APP_NAME} list List installed extensions from settings ${APP_NAME} config [-l] Open TUI to enable/disable package resources (Tab switches scope) ${APP_NAME} auth Print credentials or check provider readiness - ${APP_NAME} mcp Check MCP servers, sign in to or out of OAuth servers + ${APP_NAME} mcp Manage MCP servers ${APP_NAME} --help Show help for install/remove/uninstall/update/list/config/auth/mcp ${chalk.bold("Options:")} @@ -334,7 +334,7 @@ ${chalk.bold("Options:")} --use-theme Set the initial interactive theme for this run --no-themes Disable theme discovery and loading --no-context-files, -nc Disable AGENTS.md and CLAUDE.md discovery and loading - --export Export session file to HTML and exit + --export [output] Export session file to HTML and exit --list-models [search] List available models (with optional fuzzy search) --verbose Force verbose startup (overrides quietStartup setting) --tui-mode TUI mode: fullscreen (default) or regular From bc73eb8b1584e43dca3ffaaca7a509fabedd491c Mon Sep 17 00:00:00 2001 From: Nihaal Sangha Date: Fri, 23 Oct 2026 12:36:00 +0100 Subject: [PATCH 11/33] docs(tui): remove stale comment about Rex's Kitty images Rex now fully supports the Kitty graphics protocol. --- packages/tui/src/terminal-image.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/tui/src/terminal-image.ts b/packages/tui/src/terminal-image.ts index 143123f91..cf1ca29ae 100644 --- a/packages/tui/src/terminal-image.ts +++ b/packages/tui/src/terminal-image.ts @@ -92,8 +92,6 @@ function detectCapabilitiesFromEnvironment(tmuxForwardsHyperlink: () => boolean) return { images: "kitty", trueColor: hasTrueColorHint, hyperlinks: true }; } - // Rex supports OSC 8 hyperlinks and only partially implements the Kitty graphics protocol - // (no Unicode placeholders), but enough to support Pi. if (termProgram === "rex") { return { images: "kitty", trueColor: true, hyperlinks: true }; } From ff689647155ce7b852e6a318301c7a54fa2687ff Mon Sep 17 00:00:00 2001 From: Ramiz Wachtler Date: Fri, 23 Oct 2026 19:32:30 +0200 Subject: [PATCH 12/33] fix(tui): use iterm2 word characters for fullscreen double-click selection (#11340) - add iterm2's default word characters (`+ \ ~ _ .`) to fullscreen double-click selection ([source](https://github.com/gnachman/iTerm2/blob/7f4ff739721f95d0b93003d8701c4523a7edf261/sources/Settings/iTermPreferences.m#L743)) - `~/...`, `~/.pi/...`, `~\...`, `foo+bar` now select whole - add regression cases for `~`, `.`, `\`, `+` - fixes #10980 --- packages/tui/CHANGELOG.md | 4 ++++ packages/tui/src/tui-alt-screen.ts | 5 +++-- packages/tui/test/tui-alt-screen.test.ts | 3 +++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index da21edae9..0f53f56f4 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed fullscreen double-click selection splitting paths and tokens at `~`, `.`, `\`, and `+`. Word characters now match iTerm2's defaults ([#10980](https://github.com/earendil-works/pi/issues/10980)) + ## [1.4.0] - 2026-10-21 ### Added diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index 5e6d76864..9596146a6 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -81,8 +81,9 @@ const MAX_CACHED_OFFSCREEN_KITTY_DECODED_BYTES = 64 * 1024 * 1024; const DOUBLE_CLICK_INTERVAL_MS = 500; const COPY_ERROR_FLASH_DURATION_MS = 5000; // Regular mode delegates double-click selection to the terminal emulator. Fullscreen owns mouse selection, -// so mirror common terminal word-selection behavior by keeping paths and kebab-case tokens whole. -const TERMINAL_WORD_SELECTION_JOINERS = new Set(["/", "-"]); +// so it uses iTerm2's default "characters considered part of a word" set: +// https://github.com/gnachman/iTerm2/blob/7f4ff739721f95d0b93003d8701c4523a7edf261/sources/Settings/iTermPreferences.m#L743 +const TERMINAL_WORD_SELECTION_JOINERS = new Set(["/", "-", "+", "\\", "~", "_", "."]); const wordSegmenter = getWordSegmenter(); interface CachedKittyImage { diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index 25772b54c..acead60ce 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -1544,6 +1544,9 @@ describe("TuiAltScreen", () => { for (const { line, needle } of [ { line: "extensions/starline/fixed-editor/compositor.ts", needle: "starline" }, { line: "earendil-works/pi-tui", needle: "works" }, + { line: "~/.pi/agent/settings.json", needle: "agent" }, + { line: "~\\Documents\\pi", needle: "Documents" }, + { line: "foo+bar", needle: "bar" }, ]) { const copied: string[] = []; const terminal = new RecordingTerminal(80, 1); From c8ec752cf92a612c340249f1d0abd1bdecaf5b9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Hou=C5=A1ka?= Date: Fri, 23 Oct 2026 19:23:55 +0200 Subject: [PATCH 13/33] fix(coding-agent): skip fd/rg downloads on unsupported architectures Only x64 and arm64 map to prebuilt release archives. ARM32 and ia32 previously fell back to x86-64 binaries; now pi warns and recommends the system package manager. fixes #11209 --- .../coding-agent/src/utils/tools-manager.ts | 62 +++++++++---------- .../coding-agent/test/tools-manager.test.ts | 33 ++++++++++ 2 files changed, 62 insertions(+), 33 deletions(-) diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts index 4c5009921..a57c4de36 100644 --- a/packages/coding-agent/src/utils/tools-manager.ts +++ b/packages/coding-agent/src/utils/tools-manager.ts @@ -24,7 +24,19 @@ interface ToolConfig { binaryName: string; // Name of the binary inside the archive systemBinaryNames?: string[]; // Alternative system command names to try before downloading tagPrefix: string; // Prefix for tags (e.g., "v" for v1.0.0, "" for 1.0.0) - getAssetName: (version: string, plat: string, architecture: string) => string | null; + getAssetName: (version: string, target: string) => string; +} + +// Map Node's platform/arch to the release target suffix shared by fd and ripgrep. +// Only x64 and arm64 are mapped. Other architectures (e.g. ARM32 `arm`, `ia32`) +// must not fall back to x86-64 binaries (#11209). +function getReleaseTarget(plat: string, architecture: string): string | undefined { + const archStr = architecture === "arm64" ? "aarch64" : architecture === "x64" ? "x86_64" : undefined; + if (!archStr) return undefined; + if (plat === "darwin") return `${archStr}-apple-darwin.tar.gz`; + if (plat === "linux") return `${archStr}-unknown-linux-musl.tar.gz`; + if (plat === "win32") return `${archStr}-pc-windows-msvc.zip`; + return undefined; } const TOOLS: Record = { @@ -34,38 +46,14 @@ const TOOLS: Record = { binaryName: "fd", systemBinaryNames: ["fd", "fdfind"], tagPrefix: "v", - getAssetName: (version, plat, architecture) => { - if (plat === "darwin") { - const archStr = architecture === "arm64" ? "aarch64" : "x86_64"; - return `fd-v${version}-${archStr}-apple-darwin.tar.gz`; - } else if (plat === "linux") { - const archStr = architecture === "arm64" ? "aarch64" : "x86_64"; - return `fd-v${version}-${archStr}-unknown-linux-musl.tar.gz`; - } else if (plat === "win32") { - const archStr = architecture === "arm64" ? "aarch64" : "x86_64"; - return `fd-v${version}-${archStr}-pc-windows-msvc.zip`; - } - return null; - }, + getAssetName: (version, target) => `fd-v${version}-${target}`, }, rg: { name: "ripgrep", repo: "BurntSushi/ripgrep", binaryName: "rg", tagPrefix: "", - getAssetName: (version, plat, architecture) => { - if (plat === "darwin") { - const archStr = architecture === "arm64" ? "aarch64" : "x86_64"; - return `ripgrep-${version}-${archStr}-apple-darwin.tar.gz`; - } else if (plat === "linux") { - const archStr = architecture === "arm64" ? "aarch64" : "x86_64"; - return `ripgrep-${version}-${archStr}-unknown-linux-musl.tar.gz`; - } else if (plat === "win32") { - const archStr = architecture === "arm64" ? "aarch64" : "x86_64"; - return `ripgrep-${version}-${archStr}-pc-windows-msvc.zip`; - } - return null; - }, + getAssetName: (version, target) => `ripgrep-${version}-${target}`, }, }; @@ -262,16 +250,15 @@ async function downloadTool(tool: "fd" | "rg"): Promise { const plat = platform(); const architecture = arch(); + const target = getReleaseTarget(plat, architecture); + if (!target) { + throw new Error(`Unsupported platform: ${plat}/${architecture}`); + } // fd is pinned on darwin/x64, so skip the version lookup there. const version = tool === "fd" && plat === "darwin" && architecture === "x64" ? "10.3.0" : await getLatestVersion(config.repo); - - // Get asset name for this platform - const assetName = config.getAssetName(version, plat, architecture); - if (!assetName) { - throw new Error(`Unsupported platform: ${plat}/${architecture}`); - } + const assetName = config.getAssetName(version, target); // Create tools directory mkdirSync(TOOLS_DIR, { recursive: true }); @@ -372,6 +359,15 @@ export async function ensureTool( return undefined; } + // Prebuilt downloads only exist for the targets mapped in getReleaseTarget. + if (!getReleaseTarget(platform(), arch())) { + onStatus?.({ + type: "warning", + message: `${config.name} not found. Automatic download is not supported on ${platform()}/${arch()}. Install ${config.name} with your system package manager.`, + }); + return undefined; + } + // Tool not found - download it onStatus?.({ type: "info", message: `${config.name} not found. Downloading...` }); diff --git a/packages/coding-agent/test/tools-manager.test.ts b/packages/coding-agent/test/tools-manager.test.ts index d57de4a2d..7809fe8be 100644 --- a/packages/coding-agent/test/tools-manager.test.ts +++ b/packages/coding-agent/test/tools-manager.test.ts @@ -1,5 +1,7 @@ import type * as ChildProcess from "node:child_process"; import type * as Fs from "node:fs"; +import type * as Os from "node:os"; +import { arch, platform } from "os"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ensureTool, getLatestVersion, type ToolStatus } from "../src/utils/tools-manager.ts"; @@ -13,6 +15,15 @@ vi.mock("fs", async (importOriginal) => { }; }); +vi.mock("os", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + arch: vi.fn(() => "x64"), + platform: vi.fn(() => "linux"), + }; +}); + vi.mock("child_process", async (importOriginal) => { const actual = await importOriginal(); return { @@ -25,6 +36,8 @@ afterEach(() => { if (originalOffline === undefined) delete process.env.PI_OFFLINE; else process.env.PI_OFFLINE = originalOffline; vi.unstubAllGlobals(); + vi.mocked(arch).mockReturnValue("x64"); + vi.mocked(platform).mockReturnValue("linux"); }); function redirectResponse(location: string): Response { @@ -139,4 +152,24 @@ describe("ensureTool", () => { }, ]); }); + + // #11209: ARM32 previously fell back to x86-64 release archives. + it.each(["arm", "ia32"])("does not download x86-64 binaries on %s", async (architecture) => { + delete process.env.PI_OFFLINE; + vi.mocked(arch).mockReturnValue(architecture as NodeJS.Architecture); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const statuses: ToolStatus[] = []; + + const result = await ensureTool("rg", (status) => statuses.push(status)); + + expect(result).toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(statuses).toEqual([ + { + type: "warning", + message: `ripgrep not found. Automatic download is not supported on linux/${architecture}. Install ripgrep with your system package manager.`, + }, + ]); + }); }); From c55972086d5642593c19c331f9c4eaf02054c530 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Hou=C5=A1ka?= Date: Fri, 23 Oct 2026 19:33:23 +0200 Subject: [PATCH 14/33] docs(coding-agent): add changelog entry for #11209 --- packages/coding-agent/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ed3a38eaa..50321ef8a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed - MCP sign-in now rejects authorization endpoints and other OAuth metadata URLs that are not `http:` or `https:`. Before, a malicious MCP server could supply a `file:` or custom protocol URL, which pi passed to the platform URL handler. +- Automatic `fd` and `rg` downloads no longer fetch x86-64 binaries on ARM32 and other architectures without a prebuilt release. pi now warns and recommends installing the tool with the system package manager ([#11209](https://github.com/earendil-works/pi/issues/11209)) ## [1.4.0] - 2026-10-21 From 3953e9ef497024d13c6f4aa646d0adda2e37b0f8 Mon Sep 17 00:00:00 2001 From: yzchen Date: Thu, 13 Aug 2026 12:17:37 +0800 Subject: [PATCH 15/33] fix(examples): todo renderResult returns undefined on validation errors When a todo call fails schema validation, createErrorToolResult produces details: {} (a truthy empty object), so renderResult skips both the !details and details.error branches, then switch (details.action) has no default and returns undefined. ToolExecutionComponent.updateDisplay() then calls addChild(undefined), and the next Box.render pass crashes the whole interactive TUI. Add a default case that renders the validation error text. --- packages/coding-agent/examples/extensions/todo.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/coding-agent/examples/extensions/todo.ts b/packages/coding-agent/examples/extensions/todo.ts index 67cba7424..71b7aae87 100644 --- a/packages/coding-agent/examples/extensions/todo.ts +++ b/packages/coding-agent/examples/extensions/todo.ts @@ -276,6 +276,13 @@ export default function (pi: ExtensionAPI) { case "clear": return new Text(theme.fg("success", "✓ ") + theme.fg("muted", "Cleared all todos"), 0, 0); + + default: + return new Text( + result.content[0]?.type === "text" ? theme.fg("error", result.content[0].text) : "", + 0, + 0, + ); } }, }); From 5e106ab1ca357e4e9f19c5ac909f095381a50d6e Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 23 Oct 2026 19:03:58 +0000 Subject: [PATCH 16/33] feat(chord): NativeWatchable, the in-process counterpart of Watchable --- packages/chord/CHANGELOG.md | 1 + packages/chord/src/index.ts | 1 + packages/chord/src/watch.ts | 11 +++++++++++ 3 files changed, 13 insertions(+) diff --git a/packages/chord/CHANGELOG.md b/packages/chord/CHANGELOG.md index 973cf7f2e..92189ad9d 100644 --- a/packages/chord/CHANGELOG.md +++ b/packages/chord/CHANGELOG.md @@ -8,6 +8,7 @@ ### Added +- `NativeWatchable`: an in-process value that changes and can be watched (`value`, `watch(context)` returning a `NativeWatchHandle`), the in-process counterpart of `Watchable`. - `WatchHandle` 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`, 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. - `Watchable`: a value that changes and can be watched (`value`, `watch(context)` returning a `WatchHandle`), the common interface of documents, derives and other observable values. diff --git a/packages/chord/src/index.ts b/packages/chord/src/index.ts index 3523e27f1..8b8bd47d6 100644 --- a/packages/chord/src/index.ts +++ b/packages/chord/src/index.ts @@ -80,6 +80,7 @@ export { export type { Ctx, GetOptions, Readable, RefStatus, Shape, StateEvent } from "./types.ts"; export { NativeWatch, + type NativeWatchable, type NativeWatchHandle, Watch, type Watchable, diff --git a/packages/chord/src/watch.ts b/packages/chord/src/watch.ts index d0bbcc6a9..f6f5f51a5 100644 --- a/packages/chord/src/watch.ts +++ b/packages/chord/src/watch.ts @@ -41,6 +41,17 @@ export interface Watchable { watch(context: Context): Promise>; } +/** + * An in-process value that changes and can be watched (it may hold anything, errors and functions included): a change + * is a new value by identity, delivered without ops. + */ +export interface NativeWatchable { + /** The current value. */ + readonly value: T; + /** A watch holding the current value, then one frame per new value; the context's cancellation ends it. */ + watch(context: Context): Promise>; +} + /** Serialized observation of an in-process value: as `WatchHandle`, but a change is a new value without ops. */ export interface NativeWatchHandle { /** Acquisition value before start; latest delivered value afterward. */ From 606af8662ec4c3f5b9047c0593b4138f3db5c30d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Hou=C5=A1ka?= Date: Fri, 23 Oct 2026 21:10:01 +0200 Subject: [PATCH 17/33] feat(coding-agent): show model names in model selectors when they add information /model and /scoped-models listed only `id [provider]`, so renamed overrides from models.json were invisible in the list. Rows now show the name in parentheses when it has words the id lacks, e.g. `minimax/minimax-m3 (MiniMax M3 [parasail]) [openrouter]`. Names that only restate the id with different casing, separators, or a dropped namespace/version suffix stay hidden. closes #5916 --- .../interactive/components/model-selector.ts | 6 +++-- .../components/scoped-models-selector.ts | 6 +++-- .../src/modes/interactive/model-search.ts | 23 ++++++++++++++++ .../coding-agent/test/model-search.test.ts | 27 ++++++++++++++++++- .../coding-agent/test/model-selector.test.ts | 26 ++++++++++++++++++ .../test/scoped-models-selector.test.ts | 15 +++++++++++ .../3217-scoped-model-order.test.ts | 4 +-- ...l-selector-filter-resets-selection.test.ts | 3 +-- 8 files changed, 101 insertions(+), 9 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/model-selector.ts b/packages/coding-agent/src/modes/interactive/components/model-selector.ts index c8489c845..d603b8bef 100644 --- a/packages/coding-agent/src/modes/interactive/components/model-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/model-selector.ts @@ -11,7 +11,7 @@ import { } from "@earendil-works/pi-tui"; import type { ModelRuntime } from "../../../core/model-runtime.ts"; import { refreshModelCatalogs } from "../model-catalog-refresh.ts"; -import { getModelSelectorSearchText } from "../model-search.ts"; +import { getDistinctModelName, getModelSelectorSearchText } from "../model-search.ts"; import { theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; import { keyDisplayText, keyHint } from "./keybinding-hints.ts"; @@ -325,8 +325,10 @@ export class ModelSelectorComponent extends Container implements Focusable { const cursor = isSelected ? theme.fg("accent", "→ ") : " "; const currentMarker = isCurrent ? theme.fg("accent", "✓ ") : " "; const modelText = isSelected ? theme.fg("accent", item.id) : item.id; + const name = getDistinctModelName({ id: item.id, name: item.model.name }); + const nameText = name ? theme.fg("muted", ` (${name})`) : ""; const providerBadge = theme.fg("muted", `[${item.provider}]`); - const line = `${cursor}${currentMarker}${modelText} ${providerBadge}${defaultBadge}`; + const line = `${cursor}${currentMarker}${modelText}${nameText} ${providerBadge}${defaultBadge}`; this.listContainer.addChild(new Text(line, 0, 0)); } diff --git a/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts b/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts index 89c486de6..54a6f97cc 100644 --- a/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts @@ -10,7 +10,7 @@ import { Spacer, Text, } from "@earendil-works/pi-tui"; -import { getModelSearchText } from "../model-search.ts"; +import { getDistinctModelName, getModelSearchText } from "../model-search.ts"; import { theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; import { keyDisplayText } from "./keybinding-hints.ts"; @@ -252,9 +252,11 @@ export class ScopedModelsSelectorComponent extends Container implements Focusabl const id = item.model?.id ?? item.fullId; const styledId = item.model ? id : theme.strikethrough(id); const modelText = isSelected ? theme.fg("accent", styledId) : styledId; + const name = item.model ? getDistinctModelName(item.model) : undefined; + const nameText = name ? theme.fg("muted", ` (${name})`) : ""; const providerBadge = theme.fg("muted", item.model ? ` [${item.model.provider}]` : " [unavailable]"); const status = item.model && item.enabled ? theme.fg("accent", "✓ ") : " "; - this.listContainer.addChild(new Text(`${prefix}${status}${modelText}${providerBadge}`, 0, 0)); + this.listContainer.addChild(new Text(`${prefix}${status}${modelText}${nameText}${providerBadge}`, 0, 0)); } // Add scroll indicator if needed diff --git a/packages/coding-agent/src/modes/interactive/model-search.ts b/packages/coding-agent/src/modes/interactive/model-search.ts index bab9c5a5b..fc183168f 100644 --- a/packages/coding-agent/src/modes/interactive/model-search.ts +++ b/packages/coding-agent/src/modes/interactive/model-search.ts @@ -10,6 +10,29 @@ export function getModelSearchText(item: ModelSearchItem): string { return `${id} ${provider} ${provider}/${id} ${provider} ${id}${name}`; } +const splitModelLabel = (value: string): string[] => + value + .toLowerCase() + .split(/[^\p{L}\p{N}]+/u) + .filter(Boolean); + +/** + * Returns the model name when it has words the ID does not, e.g. a renamed override like + * "MiniMax M3 [rasmus@openrouter, parasail]" for "minimax/minimax-m3". Names that only + * restate the ID with different casing, separators, or a dropped namespace/version suffix + * ("Claude Sonnet 4.5 (EU)" for "eu.anthropic.claude-sonnet-4-5-20250929-v1:0") return undefined. + */ +export function getDistinctModelName(item: { id: string; name?: string }): string | undefined { + const name = item.name?.trim(); + if (!name) return undefined; + const idWords = splitModelLabel(item.id); + const idTokens = new Set(idWords); + const compactId = idWords.join(""); + // Single-character words must match a whole ID token: "4" is not covered by "mistral-small-2603". + const isCovered = (word: string) => idTokens.has(word) || (word.length > 1 && compactId.includes(word)); + return splitModelLabel(name).every(isCovered) ? undefined : name; +} + /** * The /model selector search should rank exact provider-prefixed queries before proxy-provider IDs * like openrouter/openai/gpt-5, so keep the bare model ID out of the leading position. diff --git a/packages/coding-agent/test/model-search.test.ts b/packages/coding-agent/test/model-search.test.ts index 08465dbe7..61b5b0a08 100644 --- a/packages/coding-agent/test/model-search.test.ts +++ b/packages/coding-agent/test/model-search.test.ts @@ -1,6 +1,31 @@ import { fuzzyFilter } from "@earendil-works/pi-tui"; import { describe, expect, it } from "vitest"; -import { getModelSelectorSearchText } from "../src/modes/interactive/model-search.ts"; +import { getDistinctModelName, getModelSelectorSearchText } from "../src/modes/interactive/model-search.ts"; + +// #5916 +describe("getDistinctModelName", () => { + it("hides names that restate the ID", () => { + expect(getDistinctModelName({ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" })).toBeUndefined(); + expect(getDistinctModelName({ id: "openai/gpt-oss-120b", name: "OpenAI: gpt-oss-120b" })).toBeUndefined(); + expect( + getDistinctModelName({ id: "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", name: "Claude Sonnet 4.5 (EU)" }), + ).toBeUndefined(); + expect(getDistinctModelName({ id: "gpt-5", name: "" })).toBeUndefined(); + expect(getDistinctModelName({ id: "gpt-5" })).toBeUndefined(); + }); + + it("shows names with words the ID lacks", () => { + const name = "MiniMax M3 [rasmus@openrouter, parasail]"; + expect(getDistinctModelName({ id: "minimax/minimax-m3", name })).toBe(name); + expect(getDistinctModelName({ id: "gemini-3.1-flash-lite-image", name: "Nano Banana 2 Lite" })).toBe( + "Nano Banana 2 Lite", + ); + }); + + it("requires single-character words to match a whole ID token", () => { + expect(getDistinctModelName({ id: "mistral-small-2603", name: "Mistral Small 4" })).toBe("Mistral Small 4"); + }); +}); describe("model selector search ranking", () => { // #11220 diff --git a/packages/coding-agent/test/model-selector.test.ts b/packages/coding-agent/test/model-selector.test.ts index ac011e309..7ba204ac7 100644 --- a/packages/coding-agent/test/model-selector.test.ts +++ b/packages/coding-agent/test/model-selector.test.ts @@ -56,6 +56,32 @@ describe("model selector", () => { selector.dispose(); }); + // #5916 + it("shows model names that differ from the ID", async () => { + harness = await createHarness({ + models: [ + { id: "minimax/minimax-m3", name: "MiniMax M3 [parasail]" }, + { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, + ], + }); + const provider = harness.getModel("minimax/minimax-m3")!.provider; + const selector = new ModelSelectorComponent( + createFakeTui(), + undefined, + harness.session.modelRuntime, + [], + () => {}, + () => {}, + ); + + const rows = stripAnsi(selector.render(120).join("\n")) + .split("\n") + .map((line) => line.slice(4).trimEnd()); + expect(rows).toContain(`minimax/minimax-m3 (MiniMax M3 [parasail]) [${provider}]`); + expect(rows).toContain(`claude-sonnet-4-5 [${provider}]`); + selector.dispose(); + }); + it("uses the configured save binding", async () => { setKeybindings(new KeybindingsManager({ "app.models.save": "ctrl+r" })); harness = await createHarness(); diff --git a/packages/coding-agent/test/scoped-models-selector.test.ts b/packages/coding-agent/test/scoped-models-selector.test.ts index 80c22cfa5..aac0d370b 100644 --- a/packages/coding-agent/test/scoped-models-selector.test.ts +++ b/packages/coding-agent/test/scoped-models-selector.test.ts @@ -58,6 +58,21 @@ describe("scoped models selector", () => { return selector; } + // #5916 + it("shows model names that differ from the ID", async () => { + const selector = await createSelector([ + { id: "model-a", name: "Model A", enabled: true }, + { id: "model-b", name: "Renamed B", enabled: true }, + ]); + const provider = harness!.models[0].provider; + + const rows = stripAnsi(selector.render(120).join("\n")) + .split("\n") + .map((line) => line.slice(4).trimEnd()); + expect(rows).toContain(`model-a [${provider}]`); + expect(rows).toContain(`model-b (Renamed B) [${provider}]`); + }); + it("marks every model after enabling all", async () => { const models = [ { id: "model-a", name: "Model A", enabled: true }, diff --git a/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts b/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts index 6553e8fad..4273ff070 100644 --- a/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts +++ b/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts @@ -94,8 +94,8 @@ describe("issue #3217 scoped model ordering", () => { .split("\n") .filter((line) => line.includes(`[${modelOne.provider}]`)); const orderedIds = renderedLines.slice(0, 3).map((line) => { - const [modelId] = line.trim().replace(/^→\s*/, "").split(" ["); - return modelId?.replace(/^✓\s*/, "").trim() ?? ""; + const [modelId] = line.trim().replace(/^→\s*/, "").replace(/^✓\s*/, "").split(" "); + return modelId ?? ""; }); expect(orderedIds).toEqual([modelTwo.id, modelOne.id, modelThree.id]); diff --git a/packages/coding-agent/test/suite/regressions/7209-model-selector-filter-resets-selection.test.ts b/packages/coding-agent/test/suite/regressions/7209-model-selector-filter-resets-selection.test.ts index 50b49be54..6c86b1d19 100644 --- a/packages/coding-agent/test/suite/regressions/7209-model-selector-filter-resets-selection.test.ts +++ b/packages/coding-agent/test/suite/regressions/7209-model-selector-filter-resets-selection.test.ts @@ -15,8 +15,7 @@ function selectedModelId(rendered: string): string | undefined { const line = rendered.split("\n").find((l) => l.startsWith("→ ")); if (!line) return undefined; const rest = line.replace(/^→\s*/, ""); - const id = rest.split(" [")[0]?.replace(/^✓\s*/, ""); - return id?.trim() || undefined; + return rest.replace(/^✓\s*/, "").split(" ")[0] || undefined; } describe("model selector filter resets selection to top", () => { From f3fa8bd92430a2bf443681027a01c58586f35464 Mon Sep 17 00:00:00 2001 From: triagent Date: Thu, 22 Oct 2026 23:34:02 +0000 Subject: [PATCH 18/33] fix(ai): do not count faux cache writes as input tokens With a sessionId, the faux provider reported newly cached prompt tokens both as input and as cacheWrite, so input + cacheRead + cacheWrite exceeded the estimated prompt. Every prompt token now goes either to cacheRead or to cacheWrite, and input is 0 while caching is simulated. --- packages/ai/src/providers/faux.ts | 5 +++-- packages/ai/test/faux-provider.test.ts | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/ai/src/providers/faux.ts b/packages/ai/src/providers/faux.ts index c3771cc2c..0f4188e60 100644 --- a/packages/ai/src/providers/faux.ts +++ b/packages/ai/src/providers/faux.ts @@ -263,11 +263,12 @@ function withUsageEstimate( if (previousPrompt) { const cachedChars = commonPromptPrefixLength(previousPrompt, prompt); cacheRead = Math.ceil(cachedChars / 4); - cacheWrite = Math.ceil((promptLength - cachedChars) / 4); - input = Math.max(0, promptTokens - cacheRead); + cacheWrite = Math.max(0, promptTokens - cacheRead); } else { cacheWrite = promptTokens; } + // Every prompt token is either read from or written to the simulated cache. + input = 0; promptCache.set(sessionId, prompt); } diff --git a/packages/ai/test/faux-provider.test.ts b/packages/ai/test/faux-provider.test.ts index 6f4178111..ad23ed938 100644 --- a/packages/ai/test/faux-provider.test.ts +++ b/packages/ai/test/faux-provider.test.ts @@ -332,17 +332,18 @@ describe("faux provider", () => { const user = (content: string) => ({ role: "user" as const, content, timestamp: 1 }); // Prompt texts: "user:hello world" (16 characters), then 16 + 2 + "user:next" (9) = 27. - await complete(registration.getModel(), { messages: [user("hello world")] }, options); + const first = await complete(registration.getModel(), { messages: [user("hello world")] }, options); + expect(first.usage).toMatchObject({ input: 0, cacheRead: 0, cacheWrite: 4 }); const extended = await complete( registration.getModel(), { messages: [user("hello world"), user("next")] }, options, ); - expect(extended.usage).toMatchObject({ input: 3, cacheRead: 4, cacheWrite: 3 }); + expect(extended.usage).toMatchObject({ input: 0, cacheRead: 4, cacheWrite: 3 }); // The first message now differs after "user:hello w" (12 characters): "user:hello wide" + 2 + 9 = 26. const edited = await complete(registration.getModel(), { messages: [user("hello wide"), user("next")] }, options); - expect(edited.usage).toMatchObject({ input: 4, cacheRead: 3, cacheWrite: 4 }); + expect(edited.usage).toMatchObject({ input: 0, cacheRead: 3, cacheWrite: 4 }); }); it("does not simulate caching when cacheRetention is none", async () => { From 8ddc44ec8bccf74afb89bb92e8ceb41bcc19305b Mon Sep 17 00:00:00 2001 From: triagent Date: Thu, 22 Oct 2026 17:52:48 +0000 Subject: [PATCH 19/33] fix(coding-agent): write forked session through one fd forkFrom reopened the target file with appendFileSync for every entry, which is slow where open/close is expensive. fixes #11253 --- .../coding-agent/src/core/session-manager.ts | 17 +++-- .../test/session-manager/fork-from.test.ts | 66 +++++++++++++++++++ 2 files changed, 77 insertions(+), 6 deletions(-) create mode 100644 packages/coding-agent/test/session-manager/fork-from.test.ts diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index df5281a0a..57f9655ba 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -1853,13 +1853,18 @@ export class SessionManager { cwd: resolvedTargetCwd, parentSession: resolvedSourcePath, }; - writeFileSync(newSessionFile, `${JSON.stringify(newHeader)}\n`, { flag: "wx" }); - - // Copy all non-header entries from source - for (const entry of sourceEntries) { - if (entry.type !== "session") { - appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`); + // Write the header and all non-header entries through one fd. Reopening the file per entry + // is slow where open/close is expensive (e.g. filesystem security monitors). + const fd = openSync(newSessionFile, "wx"); + try { + writeFileSync(fd, `${JSON.stringify(newHeader)}\n`); + for (const entry of sourceEntries) { + if (entry.type !== "session") { + writeFileSync(fd, `${JSON.stringify(entry)}\n`); + } } + } finally { + closeSync(fd); } return new SessionManager(resolvedTargetCwd, dir, newSessionFile, true); diff --git a/packages/coding-agent/test/session-manager/fork-from.test.ts b/packages/coding-agent/test/session-manager/fork-from.test.ts new file mode 100644 index 000000000..abfe959f3 --- /dev/null +++ b/packages/coding-agent/test/session-manager/fork-from.test.ts @@ -0,0 +1,66 @@ +import type * as Fs from "node:fs"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { SessionManager } from "../../src/core/session-manager.ts"; + +const { openSyncSpy, appendFileSyncSpy } = vi.hoisted(() => ({ + openSyncSpy: vi.fn(), + appendFileSyncSpy: vi.fn(), +})); + +vi.mock("fs", async (importOriginal) => { + const actual = await importOriginal(); + openSyncSpy.mockImplementation(actual.openSync); + appendFileSyncSpy.mockImplementation(actual.appendFileSync); + return { ...actual, openSync: openSyncSpy, appendFileSync: appendFileSyncSpy }; +}); + +describe("SessionManager.forkFrom", () => { + // Regression test for #11253: forking reopened the target file once per entry. + it("copies all entries while opening the new session file only once for writing", () => { + const tempDir = mkdtempSync(join(tmpdir(), "pi-fork-from-")); + const sourcePath = join(tempDir, "source.jsonl"); + const lines = [ + JSON.stringify({ + type: "session", + version: 3, + id: "source-id", + timestamp: new Date().toISOString(), + cwd: tempDir, + }), + ]; + let parentId: string | null = null; + for (let i = 0; i < 50; i++) { + const id = `e${i}`; + lines.push( + JSON.stringify({ + type: "message", + id, + parentId, + timestamp: new Date().toISOString(), + message: { role: "user", content: `message ${i}`, timestamp: 0 }, + }), + ); + parentId = id; + } + writeFileSync(sourcePath, `${lines.join("\n")}\n`); + + const targetDir = join(tempDir, "sessions"); + openSyncSpy.mockClear(); + appendFileSyncSpy.mockClear(); + const forked = SessionManager.forkFrom(sourcePath, tempDir, targetDir); + const newFile = forked.getSessionFile()!; + + const writeOpens = openSyncSpy.mock.calls.filter(([path, flags]) => path === newFile && flags !== "r"); + expect(writeOpens).toEqual([[newFile, "wx"]]); + expect(appendFileSyncSpy.mock.calls.filter(([path]) => path === newFile)).toHaveLength(0); + + const written = readFileSync(newFile, "utf8").trimEnd().split("\n"); + expect(written).toHaveLength(51); + expect(JSON.parse(written[0]).parentSession).toBe(sourcePath); + expect(written.slice(1)).toEqual(lines.slice(1)); + expect(forked.getEntries()).toHaveLength(50); + }); +}); From ed76cca69b136931fd93198b4d44f5c3b59e684c Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Thu, 22 Oct 2026 20:02:59 +0200 Subject: [PATCH 20/33] fix(coding-agent): test forkFrom copied content instead of fs calls Move the forkFrom test into custom-session-id.test.ts, drop the fs mock and call-count checks, and clean up the temp directory. --- .../session-manager/custom-session-id.test.ts | 45 ++++++++++++- .../test/session-manager/fork-from.test.ts | 66 ------------------- 2 files changed, 44 insertions(+), 67 deletions(-) delete mode 100644 packages/coding-agent/test/session-manager/fork-from.test.ts diff --git a/packages/coding-agent/test/session-manager/custom-session-id.test.ts b/packages/coding-agent/test/session-manager/custom-session-id.test.ts index 476f6d469..8ae0cb4f2 100644 --- a/packages/coding-agent/test/session-manager/custom-session-id.test.ts +++ b/packages/coding-agent/test/session-manager/custom-session-id.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -167,3 +167,46 @@ describe("SessionManager.newSession with custom id", () => { expect(basename(sessionFile)).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z_forked-session-id\.jsonl$/); }); }); + +describe("SessionManager.forkFrom", () => { + it("copies all entries unchanged into the forked session", () => { + const tempDir = mkdtempSync(join(tmpdir(), "pi-session-manager-")); + try { + const sourcePath = join(tempDir, "source.jsonl"); + const lines = [ + JSON.stringify({ + type: "session", + version: 3, + id: "source-id", + timestamp: new Date().toISOString(), + cwd: tempDir, + }), + ]; + let parentId: string | null = null; + for (let i = 0; i < 50; i++) { + const id = `e${i}`; + lines.push( + JSON.stringify({ + type: "message", + id, + parentId, + timestamp: new Date().toISOString(), + message: { role: "user", content: `message ${i}`, timestamp: 0 }, + }), + ); + parentId = id; + } + writeFileSync(sourcePath, `${lines.join("\n")}\n`); + + const forked = SessionManager.forkFrom(sourcePath, tempDir, join(tempDir, "sessions")); + + const written = readFileSync(forked.getSessionFile()!, "utf8").trimEnd().split("\n"); + expect(written).toHaveLength(51); + expect(JSON.parse(written[0]).parentSession).toBe(sourcePath); + expect(written.slice(1)).toEqual(lines.slice(1)); + expect(forked.getEntries()).toHaveLength(50); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/coding-agent/test/session-manager/fork-from.test.ts b/packages/coding-agent/test/session-manager/fork-from.test.ts deleted file mode 100644 index abfe959f3..000000000 --- a/packages/coding-agent/test/session-manager/fork-from.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type * as Fs from "node:fs"; -import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, expect, it, vi } from "vitest"; -import { SessionManager } from "../../src/core/session-manager.ts"; - -const { openSyncSpy, appendFileSyncSpy } = vi.hoisted(() => ({ - openSyncSpy: vi.fn(), - appendFileSyncSpy: vi.fn(), -})); - -vi.mock("fs", async (importOriginal) => { - const actual = await importOriginal(); - openSyncSpy.mockImplementation(actual.openSync); - appendFileSyncSpy.mockImplementation(actual.appendFileSync); - return { ...actual, openSync: openSyncSpy, appendFileSync: appendFileSyncSpy }; -}); - -describe("SessionManager.forkFrom", () => { - // Regression test for #11253: forking reopened the target file once per entry. - it("copies all entries while opening the new session file only once for writing", () => { - const tempDir = mkdtempSync(join(tmpdir(), "pi-fork-from-")); - const sourcePath = join(tempDir, "source.jsonl"); - const lines = [ - JSON.stringify({ - type: "session", - version: 3, - id: "source-id", - timestamp: new Date().toISOString(), - cwd: tempDir, - }), - ]; - let parentId: string | null = null; - for (let i = 0; i < 50; i++) { - const id = `e${i}`; - lines.push( - JSON.stringify({ - type: "message", - id, - parentId, - timestamp: new Date().toISOString(), - message: { role: "user", content: `message ${i}`, timestamp: 0 }, - }), - ); - parentId = id; - } - writeFileSync(sourcePath, `${lines.join("\n")}\n`); - - const targetDir = join(tempDir, "sessions"); - openSyncSpy.mockClear(); - appendFileSyncSpy.mockClear(); - const forked = SessionManager.forkFrom(sourcePath, tempDir, targetDir); - const newFile = forked.getSessionFile()!; - - const writeOpens = openSyncSpy.mock.calls.filter(([path, flags]) => path === newFile && flags !== "r"); - expect(writeOpens).toEqual([[newFile, "wx"]]); - expect(appendFileSyncSpy.mock.calls.filter(([path]) => path === newFile)).toHaveLength(0); - - const written = readFileSync(newFile, "utf8").trimEnd().split("\n"); - expect(written).toHaveLength(51); - expect(JSON.parse(written[0]).parentSession).toBe(sourcePath); - expect(written.slice(1)).toEqual(lines.slice(1)); - expect(forked.getEntries()).toHaveLength(50); - }); -}); From 7314819f0fe30fa616f32fa3a1fc8d1a856a1446 Mon Sep 17 00:00:00 2001 From: triagent Date: Fri, 23 Oct 2026 05:30:25 +0000 Subject: [PATCH 21/33] fix(ai): send const as enum in Gemini tool schemas --- packages/ai/src/api/google-shared.ts | 30 ++++++++++++- .../test/google-shared-convert-tools.test.ts | 45 +++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/packages/ai/src/api/google-shared.ts b/packages/ai/src/api/google-shared.ts index 129812344..3f889170a 100644 --- a/packages/ai/src/api/google-shared.ts +++ b/packages/ai/src/api/google-shared.ts @@ -371,11 +371,37 @@ function sanitizeForOpenApi(schema: unknown): unknown { return result; } +/** Schema keywords whose values map names to subschemas, so their keys are not schema keywords. */ +const JSON_SCHEMA_NAME_MAPS = new Set(["properties", "patternProperties", "$defs", "definitions", "dependentSchemas"]); + +/** + * Rewrite string and number `const` values to single-value `enum`s. + * Gemini ignores `const` in `parametersJsonSchema` but honors `enum`. + */ +function rewriteConstAsEnum(schema: unknown, isNameMap = false): unknown { + if (Array.isArray(schema)) return schema.map((item) => rewriteConstAsEnum(item)); + if (typeof schema !== "object" || schema === null) return schema; + + const result: Record = {}; + for (const [key, value] of Object.entries(schema)) { + result[key] = isNameMap ? rewriteConstAsEnum(value) : rewriteConstAsEnum(value, JSON_SCHEMA_NAME_MAPS.has(key)); + } + if (!isNameMap && "const" in result && result.enum === undefined) { + const value = result.const; + if (typeof value === "string" || typeof value === "number") { + delete result.const; + result.enum = [value]; + } + } + return result; +} + /** * Convert tools to Gemini function declarations format. * * By default uses `parametersJsonSchema` which supports full JSON Schema (including - * anyOf, oneOf, const, etc.). Set `useParameters` to true to use the legacy `parameters` + * anyOf, oneOf, etc.). Gemini ignores `const` there, so string and number `const` + * values are sent as single-value `enum`s. Set `useParameters` to true to use the legacy `parameters` * field instead (OpenAPI 3.03 Schema). This is needed for Cloud Code Assist with Claude * models, where the API translates `parameters` into Anthropic's `input_schema`. */ @@ -395,7 +421,7 @@ export function convertTools( description: tool.description, ...(useParameters ? { parameters: sanitizeForOpenApi(parameters as unknown) } - : { parametersJsonSchema: parameters }), + : { parametersJsonSchema: rewriteConstAsEnum(parameters) }), }; }), }, diff --git a/packages/ai/test/google-shared-convert-tools.test.ts b/packages/ai/test/google-shared-convert-tools.test.ts index 82876e5fe..d9d4d8b36 100644 --- a/packages/ai/test/google-shared-convert-tools.test.ts +++ b/packages/ai/test/google-shared-convert-tools.test.ts @@ -201,3 +201,48 @@ describe("google-shared convertTools", () => { expect(convertTools([], true)).toBeUndefined(); }); }); + +describe("google-shared convertTools const handling", () => { + it("rewrites string and number const values to single-value enums in parametersJsonSchema", () => { + const parameters = { + type: "object", + properties: { + origin: { type: "string", const: "zq-src-7" }, + mode: { + anyOf: [ + { type: "string", const: "a" }, + { type: "string", const: "b" }, + ], + }, + level: { oneOf: [{ const: 1 }, { const: 2 }] }, + flag: { type: "boolean", const: true }, + list: { type: "array", items: { const: "x" } }, + const: { type: "string" }, + }, + required: ["origin"], + }; + const tools = [makeTool(parameters)]; + + const result = convertTools(tools); + const decl = result?.[0]?.functionDeclarations?.[0]; + + expect(decl?.parametersJsonSchema).toEqual({ + type: "object", + properties: { + origin: { type: "string", enum: ["zq-src-7"] }, + mode: { + anyOf: [ + { type: "string", enum: ["a"] }, + { type: "string", enum: ["b"] }, + ], + }, + level: { oneOf: [{ enum: [1] }, { enum: [2] }] }, + flag: { type: "boolean", const: true }, + list: { type: "array", items: { enum: ["x"] } }, + const: { type: "string" }, + }, + required: ["origin"], + }); + expect(parameters.properties.origin).toEqual({ type: "string", const: "zq-src-7" }); + }); +}); From a7b94a03f09f20f4937fa1a76339014c60d261ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Hou=C5=A1ka?= Date: Fri, 23 Oct 2026 21:42:51 +0200 Subject: [PATCH 22/33] fix(ai): preserve literal data in Gemini tool schemas Restrict const rewriting to schema-bearing keywords and preserve __proto__ names when copying schema maps. Add regression coverage. Closes #11308 --- packages/ai/src/api/google-shared.ts | 41 +++++++-- .../test/google-shared-convert-tools.test.ts | 85 +++++++++++++++++++ 2 files changed, 121 insertions(+), 5 deletions(-) diff --git a/packages/ai/src/api/google-shared.ts b/packages/ai/src/api/google-shared.ts index 3f889170a..d68d1c4f2 100644 --- a/packages/ai/src/api/google-shared.ts +++ b/packages/ai/src/api/google-shared.ts @@ -372,7 +372,34 @@ function sanitizeForOpenApi(schema: unknown): unknown { } /** Schema keywords whose values map names to subschemas, so their keys are not schema keywords. */ -const JSON_SCHEMA_NAME_MAPS = new Set(["properties", "patternProperties", "$defs", "definitions", "dependentSchemas"]); +const JSON_SCHEMA_NAME_MAPS = new Set([ + "properties", + "patternProperties", + "$defs", + "definitions", + "dependentSchemas", + "dependencies", +]); + +/** Keywords containing a subschema or an array of subschemas, not literal data. */ +const JSON_SCHEMA_SUBSCHEMAS = new Set([ + "additionalProperties", + "unevaluatedProperties", + "propertyNames", + "items", + "prefixItems", + "additionalItems", + "unevaluatedItems", + "contains", + "allOf", + "anyOf", + "oneOf", + "not", + "if", + "then", + "else", + "contentSchema", +]); /** * Rewrite string and number `const` values to single-value `enum`s. @@ -382,10 +409,14 @@ function rewriteConstAsEnum(schema: unknown, isNameMap = false): unknown { if (Array.isArray(schema)) return schema.map((item) => rewriteConstAsEnum(item)); if (typeof schema !== "object" || schema === null) return schema; - const result: Record = {}; - for (const [key, value] of Object.entries(schema)) { - result[key] = isNameMap ? rewriteConstAsEnum(value) : rewriteConstAsEnum(value, JSON_SCHEMA_NAME_MAPS.has(key)); - } + // fromEntries preserves names such as __proto__ as own properties. + const result: Record = Object.fromEntries( + Object.entries(schema).map(([key, value]) => { + if (isNameMap || JSON_SCHEMA_SUBSCHEMAS.has(key)) return [key, rewriteConstAsEnum(value)]; + if (JSON_SCHEMA_NAME_MAPS.has(key)) return [key, rewriteConstAsEnum(value, true)]; + return [key, value]; + }), + ); if (!isNameMap && "const" in result && result.enum === undefined) { const value = result.const; if (typeof value === "string" || typeof value === "number") { diff --git a/packages/ai/test/google-shared-convert-tools.test.ts b/packages/ai/test/google-shared-convert-tools.test.ts index d9d4d8b36..02ba47d0a 100644 --- a/packages/ai/test/google-shared-convert-tools.test.ts +++ b/packages/ai/test/google-shared-convert-tools.test.ts @@ -202,7 +202,92 @@ describe("google-shared convertTools", () => { }); }); +// Regression coverage for #11308. describe("google-shared convertTools const handling", () => { + it.each(["default", "examples", "enum", "const", "x-custom"])("preserves literal data under %s", (key) => { + const literal = { const: "literal-data", properties: { nested: { const: 1 } } }; + const parameters = { + type: "object", + properties: { value: { type: "object", [key]: key === "enum" || key === "examples" ? [literal] : literal } }, + }; + const original = structuredClone(parameters); + + expect(convertTools([makeTool(parameters)])?.[0]?.functionDeclarations?.[0]?.parametersJsonSchema).toEqual( + original, + ); + expect(parameters).toEqual(original); + }); + + it.each(["properties", "patternProperties", "$defs", "definitions", "dependentSchemas", "dependencies"])( + "preserves names while rewriting schemas in %s", + (key) => { + const parameters = { + type: "object", + [key]: { + ["__proto__"]: { const: "x" }, + const: { const: "y" }, + properties: { const: "z" }, + }, + }; + const original = structuredClone(parameters); + const expected = { + type: "object", + [key]: { + ["__proto__"]: { enum: ["x"] }, + const: { enum: ["y"] }, + properties: { enum: ["z"] }, + }, + }; + + const result = convertTools([makeTool(parameters)])?.[0]?.functionDeclarations?.[0]?.parametersJsonSchema; + expect(result).toEqual(expected); + expect(JSON.stringify(result)).toBe(JSON.stringify(expected)); + expect(parameters).toEqual(original); + }, + ); + + it("rewrites only schema-bearing keywords", () => { + const parameters = { + type: "object", + additionalProperties: { const: "x" }, + unevaluatedProperties: false, + propertyNames: { const: "name" }, + // biome-ignore lint/suspicious/noThenProperty: JSON Schema conditional keyword, not a thenable. + allOf: [{ if: { const: "x" }, then: { const: "y" }, else: { not: { const: "z" } } }], + properties: { + list: { + type: "array", + items: [{ const: 1 }], + prefixItems: [{ const: 2 }], + additionalItems: { const: 3 }, + contains: { const: 4 }, + unevaluatedItems: { const: 5 }, + }, + }, + dependencies: { list: ["other"] }, + }; + + expect(convertTools([makeTool(parameters)])?.[0]?.functionDeclarations?.[0]?.parametersJsonSchema).toEqual({ + type: "object", + additionalProperties: { enum: ["x"] }, + unevaluatedProperties: false, + propertyNames: { enum: ["name"] }, + // biome-ignore lint/suspicious/noThenProperty: JSON Schema conditional keyword, not a thenable. + allOf: [{ if: { enum: ["x"] }, then: { enum: ["y"] }, else: { not: { enum: ["z"] } } }], + properties: { + list: { + type: "array", + items: [{ enum: [1] }], + prefixItems: [{ enum: [2] }], + additionalItems: { enum: [3] }, + contains: { enum: [4] }, + unevaluatedItems: { enum: [5] }, + }, + }, + dependencies: { list: ["other"] }, + }); + }); + it("rewrites string and number const values to single-value enums in parametersJsonSchema", () => { const parameters = { type: "object", From 4464442f95edc25f511772a417986c8f334aba78 Mon Sep 17 00:00:00 2001 From: triagent Date: Fri, 23 Oct 2026 07:59:59 +0000 Subject: [PATCH 23/33] fix(tui): crop boxed images scrolled above the fullscreen viewport Image placeholder rows inside padded or colored boxes contain spaces and SGR codes, which stopped the backward search for the image line, so the whole image disappeared once its first row scrolled out of view. --- packages/tui/src/layout.ts | 4 +++- packages/tui/test/tui-alt-screen.test.ts | 25 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/layout.ts b/packages/tui/src/layout.ts index 897c51de2..5e089078c 100644 --- a/packages/tui/src/layout.ts +++ b/packages/tui/src/layout.ts @@ -369,7 +369,9 @@ function paintBox(box: LayoutBox, screen: string[], totalWidth: number): void { } break; } - if (imageLine !== "") break; + // Image placeholder rows inside padded or colored boxes contain only + // spaces and SGR codes; keep searching past them for the image line. + if (imageLine.replace(/\x1b\[[0-9;:]*m/g, "").trim() !== "") break; } } diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index acead60ce..412d662d0 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -5,6 +5,7 @@ import { AltScreenSearchIndex, findAltScreenSearchMatches, } from "../src/alt-screen-search.ts"; +import { Box } from "../src/components/box.ts"; import { HStack } from "../src/components/h-stack.ts"; import { Image } from "../src/components/image.ts"; import { MouseRegion } from "../src/components/mouse-region.ts"; @@ -1074,6 +1075,30 @@ describe("TuiAltScreen", () => { tui.stop(); }); + it("crops a Kitty image in a padded, colored box whose first line is above the viewport", async () => { + const terminal = new RecordingTerminal(20, 3); + const tui = new TuiAltScreen(terminal); + const imageId = 11271; + const imageLine = encodeKitty("AAAA", { columns: 2, rows: 3, imageId, moveCursor: false }); + registerKittyImageMetadata({ imageId, columns: 2, rows: 3, widthPx: 100, heightPx: 100 }); + const box = new Box(1, 0, (text: string) => `\x1b[44m${text}\x1b[49m`); + box.addChild({ render: () => [imageLine, "", ""], invalidate: () => {} }); + tui.addChild({ render: () => ["before"], invalidate: () => {} }); + tui.addChild(box); + tui.addChild({ render: () => ["after", "end"], invalidate: () => {} }); + tui.start(); + await terminal.waitForRender(); + + assert.strictEqual(tui.viewportTop, 3); + assert.ok( + terminal.events.some( + (event) => event.type === "write" && event.data.includes("i=11271") && event.data.includes("y=66,h=34,r=1"), + ), + ); + + tui.stop(); + }); + it("redraws WezTerm Kitty images after writes to covered rows", async () => { // Regression test for #10319: a scrollbar update below an unchanged image anchor erased its cells. const weztermPane = process.env.WEZTERM_PANE; From 1c8cb1781785f3baf66f3ba1edf8eb239fe35ba2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Hou=C5=A1ka?= Date: Fri, 23 Oct 2026 21:51:17 +0200 Subject: [PATCH 24/33] fix(tui): bound image line search by tallest registered image Placeholder rows below a Kitty image line can contain box padding, borders, or side-by-side text. Stopping the backward search at the first non-blank row dropped those images once their first row scrolled above the viewport. Search back at most the tallest registered image height instead; the existing hiddenRows check keeps results correct. --- packages/tui/src/layout.ts | 28 +++++++++++++----------- packages/tui/src/terminal-image.ts | 9 ++++++++ packages/tui/test/tui-alt-screen.test.ts | 22 +++++++++++++++++++ 3 files changed, 46 insertions(+), 13 deletions(-) diff --git a/packages/tui/src/layout.ts b/packages/tui/src/layout.ts index 5e089078c..a64c262c1 100644 --- a/packages/tui/src/layout.ts +++ b/packages/tui/src/layout.ts @@ -1,7 +1,7 @@ import type { ScrollView } from "./components/scroll-view.ts"; import { allocateStackSizes, visibleStackEntries } from "./components/stack.ts"; import { getLayoutNode } from "./layout-node.ts"; -import { cropKittyImageLine, getKittyImageMetadata, isImageLine } from "./terminal-image.ts"; +import { cropKittyImageLine, getKittyImageMetadata, getMaxKittyImageRows, isImageLine } from "./terminal-image.ts"; import { type Component, CURSOR_MARKER, compositeTuiLine } from "./tui.ts"; import { extractAnsiCode, @@ -357,21 +357,23 @@ function paintBox(box: LayoutBox, screen: string[], totalWidth: number): void { for (const child of box.children) paintBox(child, screen, totalWidth); if (box.scrollView && box.scrollContentLines && box.scrollView.scrollTop > 0 && box.rect.height > 0) { - for (let imageRow = box.scrollView.scrollTop - 1; imageRow >= 0; imageRow--) { + // Find the nearest image line above the viewport. Placeholder rows below an + // image line may contain padding, borders, or side-by-side text, so the search + // is bounded by the tallest registered image instead of by row content. + const scrollTop = box.scrollView.scrollTop; + const minRow = Math.max(0, scrollTop - getMaxKittyImageRows()); + for (let imageRow = scrollTop - 1; imageRow >= minRow; imageRow--) { const imageLine = box.scrollContentLines[imageRow] ?? ""; + if (!isImageLine(imageLine)) continue; const metadata = getKittyImageMetadata(imageLine); - if (metadata) { - const hiddenRows = box.scrollView.scrollTop - imageRow; - if (hiddenRows < metadata.rows) { - const visibleRows = Math.min(box.rect.height, metadata.rows - hiddenRows); - const cropped = cropKittyImageLine(imageLine, hiddenRows, visibleRows); - if (box.rect.x === 0 && box.rect.width >= totalWidth) screen[box.rect.y] = cropped; - } - break; + if (!metadata) continue; + const hiddenRows = scrollTop - imageRow; + if (hiddenRows < metadata.rows) { + const visibleRows = Math.min(box.rect.height, metadata.rows - hiddenRows); + const cropped = cropKittyImageLine(imageLine, hiddenRows, visibleRows); + if (box.rect.x === 0 && box.rect.width >= totalWidth) screen[box.rect.y] = cropped; } - // Image placeholder rows inside padded or colored boxes contain only - // spaces and SGR codes; keep searching past them for the image line. - if (imageLine.replace(/\x1b\[[0-9;:]*m/g, "").trim() !== "") break; + break; } } diff --git a/packages/tui/src/terminal-image.ts b/packages/tui/src/terminal-image.ts index cf1ca29ae..201a40603 100644 --- a/packages/tui/src/terminal-image.ts +++ b/packages/tui/src/terminal-image.ts @@ -349,9 +349,13 @@ export interface KittyImagePlacement { const kittyImageMetadata = new Map(); let kittyTransmissionGeneration = 0; +// Tallest registered image. Bounds backward searches for image lines above a +// viewport: no image line further up than this can reach into it. +let maxKittyImageRows = 0; export function registerKittyImageMetadata(metadata: KittyImageMetadata): void { kittyTransmissionGeneration += 1; + maxKittyImageRows = Math.max(maxKittyImageRows, metadata.rows); kittyImageMetadata.delete(metadata.imageId); kittyImageMetadata.set(metadata.imageId, { ...metadata, transmissionGeneration: kittyTransmissionGeneration }); if (kittyImageMetadata.size > 1000) { @@ -360,6 +364,11 @@ export function registerKittyImageMetadata(metadata: KittyImageMetadata): void { } } +/** Return the row height of the tallest Kitty image registered so far. */ +export function getMaxKittyImageRows(): number { + return maxKittyImageRows; +} + function getRegisteredKittyImageMetadataFromControls(controls: string): RegisteredKittyImageMetadata | undefined { const imageId = /(?:^|,)i=(\d+)(?:,|$)/.exec(controls)?.[1]; return imageId === undefined ? undefined : kittyImageMetadata.get(Number.parseInt(imageId, 10)); diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index 412d662d0..41402d5c7 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -1099,6 +1099,28 @@ describe("TuiAltScreen", () => { tui.stop(); }); + it("crops a Kitty image whose placeholder rows contain text when its first line is above the viewport", async () => { + // Regression test for #11271: borders or side-by-side text on placeholder rows stopped the search. + const terminal = new RecordingTerminal(20, 3); + const tui = new TuiAltScreen(terminal); + const imageId = 11272; + const imageLine = encodeKitty("AAAA", { columns: 2, rows: 3, imageId, moveCursor: false }); + registerKittyImageMetadata({ imageId, columns: 2, rows: 3, widthPx: 100, heightPx: 100 }); + tui.addChild({ render: () => ["before", imageLine, " | side", " | side"], invalidate: () => {} }); + tui.addChild({ render: () => ["after", "end"], invalidate: () => {} }); + tui.start(); + await terminal.waitForRender(); + + assert.strictEqual(tui.viewportTop, 3); + assert.ok( + terminal.events.some( + (event) => event.type === "write" && event.data.includes("i=11272") && event.data.includes("y=66,h=34,r=1"), + ), + ); + + tui.stop(); + }); + it("redraws WezTerm Kitty images after writes to covered rows", async () => { // Regression test for #10319: a scrollbar update below an unchanged image anchor erased its cells. const weztermPane = process.env.WEZTERM_PANE; From a8fe8e0adc8b958425a43d1a7b2cc045f68be53b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Hou=C5=A1ka?= Date: Fri, 23 Oct 2026 22:06:03 +0200 Subject: [PATCH 25/33] docs: add missing changelog entries for #9301, #11289, #11290, #11293, #11296, #11347 --- packages/ai/CHANGELOG.md | 4 ++++ packages/coding-agent/CHANGELOG.md | 10 ++++++++++ packages/tui/CHANGELOG.md | 1 + 3 files changed, 15 insertions(+) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 30b17a788..00c897321 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added optional `openBrowserOnConfirm` and `copyCodeOnConfirm` to `device_code` auth events and `OAuthDeviceCodeInfo`. Both default to `true`; set them to `false` to stop clients from offering to open the verification page or copy the code ([#9282](https://github.com/earendil-works/pi/issues/9282), [#9301](https://github.com/earendil-works/pi/pull/9301) by [@petrroll](https://github.com/petrroll)) + ### 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)) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 50321ef8a..9b07b9e57 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,14 +2,24 @@ ## [Unreleased] +### Added + +- Device-code login (for example GitHub Copilot) now opens the verification page and copies the code when you press Enter. Providers can turn either action off with `openBrowserOnConfirm: false` or `copyCodeOnConfirm: false` ([#9282](https://github.com/earendil-works/pi/issues/9282), [#9301](https://github.com/earendil-works/pi/pull/9301) by [@petrroll](https://github.com/petrroll)) + ### 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)) +- `/model` and `/scoped-models` now show a model's name next to its ID when the name adds words the ID lacks, such as a renamed `models.json` override ([#5916](https://github.com/earendil-works/pi/issues/5916), [#11347](https://github.com/earendil-works/pi/pull/11347) by [@petrroll](https://github.com/petrroll)) ### Fixed - MCP sign-in now rejects authorization endpoints and other OAuth metadata URLs that are not `http:` or `https:`. Before, a malicious MCP server could supply a `file:` or custom protocol URL, which pi passed to the platform URL handler. - Automatic `fd` and `rg` downloads no longer fetch x86-64 binaries on ARM32 and other architectures without a prebuilt release. pi now warns and recommends installing the tool with the system package manager ([#11209](https://github.com/earendil-works/pi/issues/11209)) +- Fixed `/model` search ranking letters scattered over an earlier part of an entry above a real match, for example `opus` listing `gpt-oss-120b` and `claude-sonnet-4.5` before `claude-opus-4.1` ([#11220](https://github.com/earendil-works/pi/issues/11220), [#11296](https://github.com/earendil-works/pi/pull/11296) by [@petrroll](https://github.com/petrroll)) +- Fixed `/resume` search matching scattered letters anywhere in session messages and IDs, for example `1024` matching inside a session UUID. Session names and working directories still match fuzzily; IDs and message text now match only as exact substrings ([#11220](https://github.com/earendil-works/pi/issues/11220), [#11024](https://github.com/earendil-works/pi/issues/11024), [#11296](https://github.com/earendil-works/pi/pull/11296) by [@petrroll](https://github.com/petrroll)) +- Fixed HTML export showing a blank page for sessions with thousands of entries ([#11225](https://github.com/earendil-works/pi/issues/11225), [#11293](https://github.com/earendil-works/pi/pull/11293) by [@petrroll](https://github.com/petrroll)) +- Fixed HTML export not showing system messages: the system prompt and tools, and later section updates such as after `/reload`. `pi --export` now includes the system prompt and tools too ([#11261](https://github.com/earendil-works/pi/issues/11261), [#3464](https://github.com/earendil-works/pi/issues/3464), [#11290](https://github.com/earendil-works/pi/pull/11290) by [@petrroll](https://github.com/petrroll)) +- Fixed aborting a bash command on macOS sometimes running the next command of a list such as `long_step; next_step`. pi now stops the process group before killing it ([#11252](https://github.com/earendil-works/pi/issues/11252), [#11289](https://github.com/earendil-works/pi/pull/11289) by [@petrroll](https://github.com/petrroll)) ## [1.4.0] - 2026-10-21 diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 0f53f56f4..7b2708fd9 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Fixed fullscreen double-click selection splitting paths and tokens at `~`, `.`, `\`, and `+`. Word characters now match iTerm2's defaults ([#10980](https://github.com/earendil-works/pi/issues/10980)) +- Fixed `fuzzyMatch()` ranking a query scattered over an earlier part of the text above a contiguous match later in it. It now also scores each contiguous occurrence of the query and keeps the best alignment ([#11220](https://github.com/earendil-works/pi/issues/11220), [#11296](https://github.com/earendil-works/pi/pull/11296) by [@petrroll](https://github.com/petrroll)) ## [1.4.0] - 2026-10-21 From e9c3ac9d9860154f4cb712f043485ae806b21809 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Hou=C5=A1ka?= Date: Fri, 23 Oct 2026 22:08:22 +0200 Subject: [PATCH 26/33] docs: drop author attribution from backfilled changelog entries --- packages/ai/CHANGELOG.md | 16 +++++------ packages/coding-agent/CHANGELOG.md | 46 +++++++++++++++--------------- packages/tui/CHANGELOG.md | 6 ++-- 3 files changed, 34 insertions(+), 34 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 00c897321..23b92fd95 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- Added optional `openBrowserOnConfirm` and `copyCodeOnConfirm` to `device_code` auth events and `OAuthDeviceCodeInfo`. Both default to `true`; set them to `false` to stop clients from offering to open the verification page or copy the code ([#9282](https://github.com/earendil-works/pi/issues/9282), [#9301](https://github.com/earendil-works/pi/pull/9301) by [@petrroll](https://github.com/petrroll)) +- Added optional `openBrowserOnConfirm` and `copyCodeOnConfirm` to `device_code` auth events and `OAuthDeviceCodeInfo`. Both default to `true`; set them to `false` to stop clients from offering to open the verification page or copy the code ([#9282](https://github.com/earendil-works/pi/issues/9282), [#9301](https://github.com/earendil-works/pi/pull/9301)) ### Fixed @@ -45,7 +45,7 @@ - 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 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)) - 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 @@ -300,7 +300,7 @@ ### Fixed -- Fixed GitHub Copilot GPT models, including GPT-6 Astra, using the Chat Completions adapter instead of the required Responses adapter ([#9253](https://github.com/earendil-works/pi/pull/9253) by [@petrroll](https://github.com/petrroll)). +- Fixed GitHub Copilot GPT models, including GPT-6 Astra, using the Chat Completions adapter instead of the required Responses adapter ([#9253](https://github.com/earendil-works/pi/pull/9253)). - Fixed DeepSeek V4.1 thinking levels on OpenRouter and OpenCode Go preserving provider effort metadata ([#9485](https://github.com/earendil-works/pi/issues/9485)). - Fixed bodyless HTTP 400/413 errors from non-Cerebras providers being misclassified as context overflow ([#9482](https://github.com/earendil-works/pi/issues/9482)). - Fixed Vercel AI Gateway replaying unsigned thinking as assistant text ([#9676](https://github.com/earendil-works/pi/issues/9676)). @@ -551,7 +551,7 @@ ### Breaking Changes -- Upgraded the exported TypeBox dependency to 1.3.7, removing deprecated APIs including `Type.Base`, `Type.Awaited`, `Type.Promise`, `Type.AsyncIterator`, `Type.Iterator`, `Type.Options`, and `Value.Mutate`, while fixing compiled validation of nullable array tool arguments. Consumers using removed APIs must migrate to supported TypeBox APIs ([#7243](https://github.com/earendil-works/pi/pull/7243) by [@petrroll](https://github.com/petrroll)). +- Upgraded the exported TypeBox dependency to 1.3.7, removing deprecated APIs including `Type.Base`, `Type.Awaited`, `Type.Promise`, `Type.AsyncIterator`, `Type.Iterator`, `Type.Options`, and `Value.Mutate`, while fixing compiled validation of nullable array tool arguments. Consumers using removed APIs must migrate to supported TypeBox APIs ([#7243](https://github.com/earendil-works/pi/pull/7243)). ### Added @@ -610,7 +610,7 @@ - Fixed `cacheRetention: "none"` to disable implicit prompt-cache writes for supported OpenAI models and session-based caching for OpenAI Codex ([#6618](https://github.com/earendil-works/pi/pull/6618) by [@tmustier](https://github.com/tmustier)). - Fixed DNS lookup failures such as `getaddrinfo`, `ENOTFOUND`, and `EAI_AGAIN` to trigger automatic assistant retries ([#6946](https://github.com/earendil-works/pi/pull/6946) by [@christianklotz](https://github.com/christianklotz)). - Fixed OpenAI Codex WebSocket sessions to retry once without a missing previous-response continuation after `previous_response_not_found` errors ([#6955](https://github.com/earendil-works/pi/pull/6955) by [@davidbrai](https://github.com/davidbrai)). -- Fixed OpenAI and Anthropic provider retry waits to honor abort signals and configured delay limits ([#6980](https://github.com/earendil-works/pi/pull/6980) by [@petrroll](https://github.com/petrroll)). +- Fixed OpenAI and Anthropic provider retry waits to honor abort signals and configured delay limits ([#6980](https://github.com/earendil-works/pi/pull/6980)). - Fixed OpenRouter Anthropic cache breakpoints to advance through tool results and enabled cache control for `~anthropic/*-latest` aliases ([#6941](https://github.com/earendil-works/pi/pull/6941) by [@mteam88](https://github.com/mteam88)). ## [0.81.1] - 2026-07-21 @@ -712,7 +712,7 @@ ### Breaking Changes -- Removed the `OpenAIResponsesCompat.sendSessionIdHeader` flag. Session-affinity behavior is now controlled by `compat.sessionAffinityFormat` (`"openai"`, `"openai-nosession"`, or `"openrouter"`). Replace `sendSessionIdHeader: false` with `sessionAffinityFormat: "openai-nosession"` ([#6496](https://github.com/earendil-works/pi-mono/pull/6496) by [@petrroll](https://github.com/petrroll)). +- Removed the `OpenAIResponsesCompat.sendSessionIdHeader` flag. Session-affinity behavior is now controlled by `compat.sessionAffinityFormat` (`"openai"`, `"openai-nosession"`, or `"openrouter"`). Replace `sendSessionIdHeader: false` with `sessionAffinityFormat: "openai-nosession"` ([#6496](https://github.com/earendil-works/pi-mono/pull/6496)). ### Added @@ -723,9 +723,9 @@ ### Fixed - Fixed OpenRouter model context windows to use the top provider's actual context length ([#6481](https://github.com/earendil-works/pi-mono/pull/6481) by [@davidbrai](https://github.com/davidbrai)). -- Fixed the GitHub Copilot `mai-code-1-flash-picker` model to route through the `/responses` endpoint ([#6544](https://github.com/earendil-works/pi-mono/pull/6544) by [@petrroll](https://github.com/petrroll)). +- Fixed the GitHub Copilot `mai-code-1-flash-picker` model to route through the `/responses` endpoint ([#6544](https://github.com/earendil-works/pi-mono/pull/6544)). - Fixed Amazon Bedrock requests to use the generic `apiKey` stream option as a Bedrock bearer token. -- Fixed OpenRouter OpenAI-compatible session IDs to use the `x-session-id` header instead of OpenAI-specific session-affinity fields ([#6496](https://github.com/earendil-works/pi-mono/pull/6496) by [@petrroll](https://github.com/petrroll)). +- Fixed OpenRouter OpenAI-compatible session IDs to use the `x-session-id` header instead of OpenAI-specific session-affinity fields ([#6496](https://github.com/earendil-works/pi-mono/pull/6496)). - Fixed Amazon Bedrock ambient AWS credentials to keep using SigV4 authentication, including for custom model IDs ([#6532](https://github.com/earendil-works/pi-mono/pull/6532) by [@ribelo](https://github.com/ribelo)). - Fixed Cloudflare Workers AI and AI Gateway authentication to use ambient account and gateway IDs when stored credentials contain only an API key ([#6292](https://github.com/earendil-works/pi-mono/pull/6292) by [@markphelps](https://github.com/markphelps)). - Fixed Amazon Bedrock errors to report unhandled provider stop reasons instead of only `An unknown error occurred` ([#6598](https://github.com/earendil-works/pi-mono/pull/6598) by [@davidbrai](https://github.com/davidbrai)). diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9b07b9e57..44896a9df 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,22 +4,22 @@ ### Added -- Device-code login (for example GitHub Copilot) now opens the verification page and copies the code when you press Enter. Providers can turn either action off with `openBrowserOnConfirm: false` or `copyCodeOnConfirm: false` ([#9282](https://github.com/earendil-works/pi/issues/9282), [#9301](https://github.com/earendil-works/pi/pull/9301) by [@petrroll](https://github.com/petrroll)) +- Device-code login (for example GitHub Copilot) now opens the verification page and copies the code when you press Enter. Providers can turn either action off with `openBrowserOnConfirm: false` or `copyCodeOnConfirm: false` ([#9282](https://github.com/earendil-works/pi/issues/9282), [#9301](https://github.com/earendil-works/pi/pull/9301)) ### 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)) -- `/model` and `/scoped-models` now show a model's name next to its ID when the name adds words the ID lacks, such as a renamed `models.json` override ([#5916](https://github.com/earendil-works/pi/issues/5916), [#11347](https://github.com/earendil-works/pi/pull/11347) by [@petrroll](https://github.com/petrroll)) +- `/model` and `/scoped-models` now show a model's name next to its ID when the name adds words the ID lacks, such as a renamed `models.json` override ([#5916](https://github.com/earendil-works/pi/issues/5916), [#11347](https://github.com/earendil-works/pi/pull/11347)) ### Fixed - MCP sign-in now rejects authorization endpoints and other OAuth metadata URLs that are not `http:` or `https:`. Before, a malicious MCP server could supply a `file:` or custom protocol URL, which pi passed to the platform URL handler. - Automatic `fd` and `rg` downloads no longer fetch x86-64 binaries on ARM32 and other architectures without a prebuilt release. pi now warns and recommends installing the tool with the system package manager ([#11209](https://github.com/earendil-works/pi/issues/11209)) -- Fixed `/model` search ranking letters scattered over an earlier part of an entry above a real match, for example `opus` listing `gpt-oss-120b` and `claude-sonnet-4.5` before `claude-opus-4.1` ([#11220](https://github.com/earendil-works/pi/issues/11220), [#11296](https://github.com/earendil-works/pi/pull/11296) by [@petrroll](https://github.com/petrroll)) -- Fixed `/resume` search matching scattered letters anywhere in session messages and IDs, for example `1024` matching inside a session UUID. Session names and working directories still match fuzzily; IDs and message text now match only as exact substrings ([#11220](https://github.com/earendil-works/pi/issues/11220), [#11024](https://github.com/earendil-works/pi/issues/11024), [#11296](https://github.com/earendil-works/pi/pull/11296) by [@petrroll](https://github.com/petrroll)) -- Fixed HTML export showing a blank page for sessions with thousands of entries ([#11225](https://github.com/earendil-works/pi/issues/11225), [#11293](https://github.com/earendil-works/pi/pull/11293) by [@petrroll](https://github.com/petrroll)) -- Fixed HTML export not showing system messages: the system prompt and tools, and later section updates such as after `/reload`. `pi --export` now includes the system prompt and tools too ([#11261](https://github.com/earendil-works/pi/issues/11261), [#3464](https://github.com/earendil-works/pi/issues/3464), [#11290](https://github.com/earendil-works/pi/pull/11290) by [@petrroll](https://github.com/petrroll)) -- Fixed aborting a bash command on macOS sometimes running the next command of a list such as `long_step; next_step`. pi now stops the process group before killing it ([#11252](https://github.com/earendil-works/pi/issues/11252), [#11289](https://github.com/earendil-works/pi/pull/11289) by [@petrroll](https://github.com/petrroll)) +- Fixed `/model` search ranking letters scattered over an earlier part of an entry above a real match, for example `opus` listing `gpt-oss-120b` and `claude-sonnet-4.5` before `claude-opus-4.1` ([#11220](https://github.com/earendil-works/pi/issues/11220), [#11296](https://github.com/earendil-works/pi/pull/11296)) +- Fixed `/resume` search matching scattered letters anywhere in session messages and IDs, for example `1024` matching inside a session UUID. Session names and working directories still match fuzzily; IDs and message text now match only as exact substrings ([#11220](https://github.com/earendil-works/pi/issues/11220), [#11024](https://github.com/earendil-works/pi/issues/11024), [#11296](https://github.com/earendil-works/pi/pull/11296)) +- Fixed HTML export showing a blank page for sessions with thousands of entries ([#11225](https://github.com/earendil-works/pi/issues/11225), [#11293](https://github.com/earendil-works/pi/pull/11293)) +- Fixed HTML export not showing system messages: the system prompt and tools, and later section updates such as after `/reload`. `pi --export` now includes the system prompt and tools too ([#11261](https://github.com/earendil-works/pi/issues/11261), [#3464](https://github.com/earendil-works/pi/issues/3464), [#11290](https://github.com/earendil-works/pi/pull/11290)) +- Fixed aborting a bash command on macOS sometimes running the next command of a list such as `long_step; next_step`. pi now stops the process group before killing it ([#11252](https://github.com/earendil-works/pi/issues/11252), [#11289](https://github.com/earendil-works/pi/pull/11289)) ## [1.4.0] - 2026-10-21 @@ -37,7 +37,7 @@ - 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)) +- 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), [#11183](https://github.com/earendil-works/pi/pull/11183) by [@nihaals](https://github.com/nihaals)) ### Changed @@ -63,11 +63,11 @@ - 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 ` 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 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)) +- 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)) +- Fixed Windows shell detection rejecting runnable Microsoft Store shell aliases ([#9504](https://github.com/earendil-works/pi/pull/9504)) +- 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)) +- 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)) - 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)) @@ -653,7 +653,7 @@ ### Fixed -- Fixed GitHub Copilot GPT models, including GPT-6 Astra, using the Chat Completions adapter instead of the required Responses adapter ([#9253](https://github.com/earendil-works/pi/pull/9253) by [@petrroll](https://github.com/petrroll)). +- Fixed GitHub Copilot GPT models, including GPT-6 Astra, using the Chat Completions adapter instead of the required Responses adapter ([#9253](https://github.com/earendil-works/pi/pull/9253)). - Fixed inherited DeepSeek V4.1 thinking levels on OpenRouter and OpenCode Go preserving provider effort metadata ([#9485](https://github.com/earendil-works/pi/issues/9485)). - Fixed inherited bodyless HTTP 400/413 errors from non-Cerebras providers being misclassified as context overflow ([#9482](https://github.com/earendil-works/pi/issues/9482)). - Fixed inherited Vercel AI Gateway replaying unsigned thinking as assistant text ([#9676](https://github.com/earendil-works/pi/issues/9676)). @@ -851,7 +851,7 @@ ### Fixed - Fixed failed extension factories leaving event subscriptions, provider registrations, and default flag state active ([#8424](https://github.com/earendil-works/pi/pull/8424) by [@acmerfight](https://github.com/acmerfight)). -- Fixed `models.json` typings omitting the documented OpenAI-compatible `compat.supportsFinishReason` provider and model override ([#8487](https://github.com/earendil-works/pi/pull/8487) by [@petrroll](https://github.com/petrroll)). +- Fixed `models.json` typings omitting the documented OpenAI-compatible `compat.supportsFinishReason` provider and model override ([#8487](https://github.com/earendil-works/pi/pull/8487)). - Fixed `/model` and `/thinking` selections being persisted globally unless explicitly saved with Ctrl+S ([#5263](https://github.com/earendil-works/pi/issues/5263)). - Fixed JSON and RPC `toolcall_start` events omitting the tool call id and name ([#7953](https://github.com/earendil-works/pi/pull/7953) by [@christianklotz](https://github.com/christianklotz)). - Fixed extensions failing to load when the Node.js CLI runs as a single-executable application ([#8237](https://github.com/earendil-works/pi/issues/8237)). @@ -1149,8 +1149,8 @@ - Fixed manual compaction racing with threshold auto-compaction ([#7370](https://github.com/earendil-works/pi/pull/7370) by [@davidbrai](https://github.com/davidbrai)). - Fixed responses truncated below their intended output limit ending the run instead of compacting and retrying once ([#7540](https://github.com/earendil-works/pi/pull/7540) by [@davidbrai](https://github.com/davidbrai)). - Fixed Git package updates leaving dependencies missing when `git clean` cannot remove an ignored dependency directory ([#7570](https://github.com/earendil-works/pi/pull/7570) by [@mrexodia](https://github.com/mrexodia)). -- Fixed `find` results from POSIX and Windows filesystem roots losing the first path segment or gaining duplicate trailing separators ([#7569](https://github.com/earendil-works/pi/pull/7569) by [@petrroll](https://github.com/petrroll)). -- Fixed transient version-check, catalog, managed-tool, and package-management HTTP failures not being retried ([#7632](https://github.com/earendil-works/pi/pull/7632) by [@petrroll](https://github.com/petrroll)). +- Fixed `find` results from POSIX and Windows filesystem roots losing the first path segment or gaining duplicate trailing separators ([#7569](https://github.com/earendil-works/pi/pull/7569)). +- Fixed transient version-check, catalog, managed-tool, and package-management HTTP failures not being retried ([#7632](https://github.com/earendil-works/pi/pull/7632)). - Fixed interactive errors ignoring the configured output padding. - Fixed the inherited OpenCode Go provider display name. - Fixed inherited provider error normalization treating arrays and class instances as structured response bodies instead of preserving their original errors ([#7205](https://github.com/earendil-works/pi/pull/7205) by [@erikogenvik](https://github.com/erikogenvik)). @@ -1161,7 +1161,7 @@ - Fixed inherited Gemini 3 tool call ids being discarded during history conversion, breaking signed multi-turn replay ([#7494](https://github.com/earendil-works/pi/pull/7494) by [@muyiyr](https://github.com/muyiyr)). - Restored inherited GitHub Copilot models returned through account-specific policy responses ([#7672](https://github.com/earendil-works/pi/pull/7672) by [@muyiyr](https://github.com/muyiyr)). - Replaced the inherited retired Qwen Token Plan `qwen3.8-max-preview` model with `qwen3.8-max` ([#7670](https://github.com/earendil-works/pi/pull/7670) by [@QuintinShaw](https://github.com/QuintinShaw)). -- Fixed inherited terminal width accounting for Indic conjunct grapheme clusters ([#6987](https://github.com/earendil-works/pi/pull/6987) by [@petrroll](https://github.com/petrroll)). +- Fixed inherited terminal width accounting for Indic conjunct grapheme clusters ([#6987](https://github.com/earendil-works/pi/pull/6987)). - Fixed inherited nested fullscreen stack layouts ignoring child minimum sizes. - Fixed inherited batched terminal color-scheme reports being parsed as one malformed response ([#7550](https://github.com/earendil-works/pi/pull/7550)). - Fixed inherited terminal progress clearing to emit the complete OSC 9;4 sequence ([#7581](https://github.com/earendil-works/pi/pull/7581)). @@ -1187,7 +1187,7 @@ ### Breaking Changes -- Upgraded bundled TypeBox aliases to 1.3.7, removing deprecated APIs including `Type.Base`, `Type.Awaited`, `Type.Promise`, `Type.AsyncIterator`, `Type.Iterator`, `Type.Options`, and `Value.Mutate`, while fixing compiled validation of nullable array tool arguments. Extensions using removed APIs must migrate to supported TypeBox APIs. See [Package Dependencies](docs/packages.md#dependencies) ([#7243](https://github.com/earendil-works/pi/pull/7243) by [@petrroll](https://github.com/petrroll)). +- Upgraded bundled TypeBox aliases to 1.3.7, removing deprecated APIs including `Type.Base`, `Type.Awaited`, `Type.Promise`, `Type.AsyncIterator`, `Type.Iterator`, `Type.Options`, and `Value.Mutate`, while fixing compiled validation of nullable array tool arguments. Extensions using removed APIs must migrate to supported TypeBox APIs. See [Package Dependencies](docs/packages.md#dependencies) ([#7243](https://github.com/earendil-works/pi/pull/7243)). ### Added @@ -1281,7 +1281,7 @@ - Fixed compaction and branch-summary requests to use fresh routing session IDs with prompt caching disabled where supported ([#6618](https://github.com/earendil-works/pi/pull/6618) by [@tmustier](https://github.com/tmustier)). - Fixed explicit self-updates when `PI_SKIP_VERSION_CHECK` is set ([#6977](https://github.com/earendil-works/pi/issues/6977)). - Fixed scoped model IDs containing brackets to resolve as literal exact matches before glob matching ([#6210](https://github.com/earendil-works/pi/issues/6210)). -- Fixed inherited OpenAI and Anthropic provider retry waits to honor abort signals and configured delay limits ([#6980](https://github.com/earendil-works/pi/pull/6980) by [@petrroll](https://github.com/petrroll)). +- Fixed inherited OpenAI and Anthropic provider retry waits to honor abort signals and configured delay limits ([#6980](https://github.com/earendil-works/pi/pull/6980)). - Fixed fresh installs from preferring bundled model catalogs over newer remote catalogs because package file mtimes were newer ([#7016](https://github.com/earendil-works/pi/pull/7016) by [@davidbrai](https://github.com/davidbrai)). - Fixed inherited editor scroll indicators overflowing narrow terminals ([#7015](https://github.com/earendil-works/pi/pull/7015) by [@christianklotz](https://github.com/christianklotz)). - Fixed llama.cpp models to use the loaded context window as their output token limit instead of capping it at 16K ([#7034](https://github.com/earendil-works/pi/pull/7034) by [@christianklotz](https://github.com/christianklotz)). @@ -1431,7 +1431,7 @@ ### Breaking Changes -- Removed the `openai-responses` `compat.sendSessionIdHeader` flag from `models.json`. Session-affinity behavior is now controlled by `compat.sessionAffinityFormat` (`"openai"`, `"openai-nosession"`, or `"openrouter"`). Replace `sendSessionIdHeader: false` with `sessionAffinityFormat: "openai-nosession"` ([#6496](https://github.com/earendil-works/pi-mono/pull/6496) by [@petrroll](https://github.com/petrroll)). +- Removed the `openai-responses` `compat.sendSessionIdHeader` flag from `models.json`. Session-affinity behavior is now controlled by `compat.sessionAffinityFormat` (`"openai"`, `"openai-nosession"`, or `"openrouter"`). Replace `sendSessionIdHeader: false` with `sessionAffinityFormat: "openai-nosession"` ([#6496](https://github.com/earendil-works/pi-mono/pull/6496)). ### New Features @@ -1449,13 +1449,13 @@ ### Fixed - Fixed inherited OpenRouter model context windows to use the top provider's actual context length ([#6481](https://github.com/earendil-works/pi-mono/pull/6481) by [@davidbrai](https://github.com/davidbrai)). -- Fixed inherited OpenRouter OpenAI-compatible session IDs to use the `x-session-id` header instead of OpenAI-specific session-affinity fields ([#6496](https://github.com/earendil-works/pi-mono/pull/6496) by [@petrroll](https://github.com/petrroll)). +- Fixed inherited OpenRouter OpenAI-compatible session IDs to use the `x-session-id` header instead of OpenAI-specific session-affinity fields ([#6496](https://github.com/earendil-works/pi-mono/pull/6496)). - Fixed `Ctrl+V` to paste clipboard text when the pasteboard does not contain an image. - Fixed `/login amazon-bedrock` to prompt for and save a Bedrock API key instead of only displaying ambient AWS credential setup instructions. - Fixed inherited Amazon Bedrock ambient AWS credentials to keep using SigV4 authentication, including for custom model IDs ([#6532](https://github.com/earendil-works/pi-mono/pull/6532) by [@ribelo](https://github.com/ribelo)). - Fixed inherited Cloudflare Workers AI and AI Gateway authentication to use ambient account and gateway IDs when stored credentials contain only an API key ([#6292](https://github.com/earendil-works/pi-mono/pull/6292) by [@markphelps](https://github.com/markphelps)). - Fixed inherited legacy terminal decoding for Alt+symbol key combinations such as `Alt+,` and `Alt+.` ([#6523](https://github.com/earendil-works/pi-mono/pull/6523) by [@ribelo](https://github.com/ribelo)). -- Fixed the GitHub Copilot `mai-code-1-flash-picker` model to route through the `/responses` endpoint ([#6544](https://github.com/earendil-works/pi-mono/pull/6544) by [@petrroll](https://github.com/petrroll)). +- Fixed the GitHub Copilot `mai-code-1-flash-picker` model to route through the `/responses` endpoint ([#6544](https://github.com/earendil-works/pi-mono/pull/6544)). - Fixed branch summaries to work with providers that use ambient authentication instead of API keys ([#6595](https://github.com/earendil-works/pi-mono/pull/6595) by [@davidbrai](https://github.com/davidbrai)). - Fixed inherited Amazon Bedrock errors to report unhandled provider stop reasons instead of only `An unknown error occurred` ([#6598](https://github.com/earendil-works/pi-mono/pull/6598) by [@davidbrai](https://github.com/davidbrai)). - Fixed npm package removal when installed packages have conflicting peer dependencies ([#6604](https://github.com/earendil-works/pi-mono/pull/6604) by [@davidbrai](https://github.com/davidbrai)). diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 7b2708fd9..8c703ce44 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -5,13 +5,13 @@ ### Fixed - Fixed fullscreen double-click selection splitting paths and tokens at `~`, `.`, `\`, and `+`. Word characters now match iTerm2's defaults ([#10980](https://github.com/earendil-works/pi/issues/10980)) -- Fixed `fuzzyMatch()` ranking a query scattered over an earlier part of the text above a contiguous match later in it. It now also scores each contiguous occurrence of the query and keeps the best alignment ([#11220](https://github.com/earendil-works/pi/issues/11220), [#11296](https://github.com/earendil-works/pi/pull/11296) by [@petrroll](https://github.com/petrroll)) +- Fixed `fuzzyMatch()` ranking a query scattered over an earlier part of the text above a contiguous match later in it. It now also scores each contiguous occurrence of the query and keeps the best alignment ([#11220](https://github.com/earendil-works/pi/issues/11220), [#11296](https://github.com/earendil-works/pi/pull/11296)) ## [1.4.0] - 2026-10-21 ### Added -- Added detection of the Rex terminal (`TERM_PROGRAM=rex`) with OSC 8 hyperlinks, truecolor, and Kitty images. Links previously fell back to the `text (url)` format ([#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)) +- Added detection of the Rex terminal (`TERM_PROGRAM=rex`) with OSC 8 hyperlinks, truecolor, and Kitty images. Links previously fell back to the `text (url)` format ([#11131](https://github.com/earendil-works/pi/issues/11131), [#11150](https://github.com/earendil-works/pi/pull/11150), [#11183](https://github.com/earendil-works/pi/pull/11183) by [@nihaals](https://github.com/nihaals)) ### Fixed @@ -266,7 +266,7 @@ - Fixed the npm package omitting the source and build script needed to rebuild the Windows native addon. - Fixed the npm package omitting the source and build script needed to rebuild the Darwin native addon. - Fixed Windows console truecolor detection when Windows Terminal does not provide `WT_SESSION` to child shells. -- Fixed terminal width accounting for Indic conjunct grapheme clusters ([#6987](https://github.com/earendil-works/pi/pull/6987) by [@petrroll](https://github.com/petrroll)). +- Fixed terminal width accounting for Indic conjunct grapheme clusters ([#6987](https://github.com/earendil-works/pi/pull/6987)). - Fixed phantom alternate-screen text selection from unmatched mouse events when changing terminal pane focus. - Fixed spaces in searchable settings queries changing the selected value instead of filtering multi-word labels. - Fixed alternate-screen Kitty images crossing vertical layout clip boundaries and overlapping sticky regions while scrolling. From acf83acfd88f18e94e8d6634064b2ee2cca6f317 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Hou=C5=A1ka?= Date: Fri, 23 Oct 2026 22:08:38 +0200 Subject: [PATCH 27/33] docs: restore existing changelog attributions --- packages/ai/CHANGELOG.md | 14 ++++++------- packages/coding-agent/CHANGELOG.md | 32 +++++++++++++++--------------- packages/tui/CHANGELOG.md | 4 ++-- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 23b92fd95..63efd0e37 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -45,7 +45,7 @@ - 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)) +- 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 @@ -300,7 +300,7 @@ ### Fixed -- Fixed GitHub Copilot GPT models, including GPT-6 Astra, using the Chat Completions adapter instead of the required Responses adapter ([#9253](https://github.com/earendil-works/pi/pull/9253)). +- Fixed GitHub Copilot GPT models, including GPT-6 Astra, using the Chat Completions adapter instead of the required Responses adapter ([#9253](https://github.com/earendil-works/pi/pull/9253) by [@petrroll](https://github.com/petrroll)). - Fixed DeepSeek V4.1 thinking levels on OpenRouter and OpenCode Go preserving provider effort metadata ([#9485](https://github.com/earendil-works/pi/issues/9485)). - Fixed bodyless HTTP 400/413 errors from non-Cerebras providers being misclassified as context overflow ([#9482](https://github.com/earendil-works/pi/issues/9482)). - Fixed Vercel AI Gateway replaying unsigned thinking as assistant text ([#9676](https://github.com/earendil-works/pi/issues/9676)). @@ -551,7 +551,7 @@ ### Breaking Changes -- Upgraded the exported TypeBox dependency to 1.3.7, removing deprecated APIs including `Type.Base`, `Type.Awaited`, `Type.Promise`, `Type.AsyncIterator`, `Type.Iterator`, `Type.Options`, and `Value.Mutate`, while fixing compiled validation of nullable array tool arguments. Consumers using removed APIs must migrate to supported TypeBox APIs ([#7243](https://github.com/earendil-works/pi/pull/7243)). +- Upgraded the exported TypeBox dependency to 1.3.7, removing deprecated APIs including `Type.Base`, `Type.Awaited`, `Type.Promise`, `Type.AsyncIterator`, `Type.Iterator`, `Type.Options`, and `Value.Mutate`, while fixing compiled validation of nullable array tool arguments. Consumers using removed APIs must migrate to supported TypeBox APIs ([#7243](https://github.com/earendil-works/pi/pull/7243) by [@petrroll](https://github.com/petrroll)). ### Added @@ -610,7 +610,7 @@ - Fixed `cacheRetention: "none"` to disable implicit prompt-cache writes for supported OpenAI models and session-based caching for OpenAI Codex ([#6618](https://github.com/earendil-works/pi/pull/6618) by [@tmustier](https://github.com/tmustier)). - Fixed DNS lookup failures such as `getaddrinfo`, `ENOTFOUND`, and `EAI_AGAIN` to trigger automatic assistant retries ([#6946](https://github.com/earendil-works/pi/pull/6946) by [@christianklotz](https://github.com/christianklotz)). - Fixed OpenAI Codex WebSocket sessions to retry once without a missing previous-response continuation after `previous_response_not_found` errors ([#6955](https://github.com/earendil-works/pi/pull/6955) by [@davidbrai](https://github.com/davidbrai)). -- Fixed OpenAI and Anthropic provider retry waits to honor abort signals and configured delay limits ([#6980](https://github.com/earendil-works/pi/pull/6980)). +- Fixed OpenAI and Anthropic provider retry waits to honor abort signals and configured delay limits ([#6980](https://github.com/earendil-works/pi/pull/6980) by [@petrroll](https://github.com/petrroll)). - Fixed OpenRouter Anthropic cache breakpoints to advance through tool results and enabled cache control for `~anthropic/*-latest` aliases ([#6941](https://github.com/earendil-works/pi/pull/6941) by [@mteam88](https://github.com/mteam88)). ## [0.81.1] - 2026-07-21 @@ -712,7 +712,7 @@ ### Breaking Changes -- Removed the `OpenAIResponsesCompat.sendSessionIdHeader` flag. Session-affinity behavior is now controlled by `compat.sessionAffinityFormat` (`"openai"`, `"openai-nosession"`, or `"openrouter"`). Replace `sendSessionIdHeader: false` with `sessionAffinityFormat: "openai-nosession"` ([#6496](https://github.com/earendil-works/pi-mono/pull/6496)). +- Removed the `OpenAIResponsesCompat.sendSessionIdHeader` flag. Session-affinity behavior is now controlled by `compat.sessionAffinityFormat` (`"openai"`, `"openai-nosession"`, or `"openrouter"`). Replace `sendSessionIdHeader: false` with `sessionAffinityFormat: "openai-nosession"` ([#6496](https://github.com/earendil-works/pi-mono/pull/6496) by [@petrroll](https://github.com/petrroll)). ### Added @@ -723,9 +723,9 @@ ### Fixed - Fixed OpenRouter model context windows to use the top provider's actual context length ([#6481](https://github.com/earendil-works/pi-mono/pull/6481) by [@davidbrai](https://github.com/davidbrai)). -- Fixed the GitHub Copilot `mai-code-1-flash-picker` model to route through the `/responses` endpoint ([#6544](https://github.com/earendil-works/pi-mono/pull/6544)). +- Fixed the GitHub Copilot `mai-code-1-flash-picker` model to route through the `/responses` endpoint ([#6544](https://github.com/earendil-works/pi-mono/pull/6544) by [@petrroll](https://github.com/petrroll)). - Fixed Amazon Bedrock requests to use the generic `apiKey` stream option as a Bedrock bearer token. -- Fixed OpenRouter OpenAI-compatible session IDs to use the `x-session-id` header instead of OpenAI-specific session-affinity fields ([#6496](https://github.com/earendil-works/pi-mono/pull/6496)). +- Fixed OpenRouter OpenAI-compatible session IDs to use the `x-session-id` header instead of OpenAI-specific session-affinity fields ([#6496](https://github.com/earendil-works/pi-mono/pull/6496) by [@petrroll](https://github.com/petrroll)). - Fixed Amazon Bedrock ambient AWS credentials to keep using SigV4 authentication, including for custom model IDs ([#6532](https://github.com/earendil-works/pi-mono/pull/6532) by [@ribelo](https://github.com/ribelo)). - Fixed Cloudflare Workers AI and AI Gateway authentication to use ambient account and gateway IDs when stored credentials contain only an API key ([#6292](https://github.com/earendil-works/pi-mono/pull/6292) by [@markphelps](https://github.com/markphelps)). - Fixed Amazon Bedrock errors to report unhandled provider stop reasons instead of only `An unknown error occurred` ([#6598](https://github.com/earendil-works/pi-mono/pull/6598) by [@davidbrai](https://github.com/davidbrai)). diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 44896a9df..c1b5fee7a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -37,7 +37,7 @@ - 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), [#11183](https://github.com/earendil-works/pi/pull/11183) by [@nihaals](https://github.com/nihaals)) +- 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 @@ -63,11 +63,11 @@ - 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 ` 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)) -- 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)) -- Fixed Windows shell detection rejecting runnable Microsoft Store shell aliases ([#9504](https://github.com/earendil-works/pi/pull/9504)) -- 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)) -- 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)) +- 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)) @@ -653,7 +653,7 @@ ### Fixed -- Fixed GitHub Copilot GPT models, including GPT-6 Astra, using the Chat Completions adapter instead of the required Responses adapter ([#9253](https://github.com/earendil-works/pi/pull/9253)). +- Fixed GitHub Copilot GPT models, including GPT-6 Astra, using the Chat Completions adapter instead of the required Responses adapter ([#9253](https://github.com/earendil-works/pi/pull/9253) by [@petrroll](https://github.com/petrroll)). - Fixed inherited DeepSeek V4.1 thinking levels on OpenRouter and OpenCode Go preserving provider effort metadata ([#9485](https://github.com/earendil-works/pi/issues/9485)). - Fixed inherited bodyless HTTP 400/413 errors from non-Cerebras providers being misclassified as context overflow ([#9482](https://github.com/earendil-works/pi/issues/9482)). - Fixed inherited Vercel AI Gateway replaying unsigned thinking as assistant text ([#9676](https://github.com/earendil-works/pi/issues/9676)). @@ -851,7 +851,7 @@ ### Fixed - Fixed failed extension factories leaving event subscriptions, provider registrations, and default flag state active ([#8424](https://github.com/earendil-works/pi/pull/8424) by [@acmerfight](https://github.com/acmerfight)). -- Fixed `models.json` typings omitting the documented OpenAI-compatible `compat.supportsFinishReason` provider and model override ([#8487](https://github.com/earendil-works/pi/pull/8487)). +- Fixed `models.json` typings omitting the documented OpenAI-compatible `compat.supportsFinishReason` provider and model override ([#8487](https://github.com/earendil-works/pi/pull/8487) by [@petrroll](https://github.com/petrroll)). - Fixed `/model` and `/thinking` selections being persisted globally unless explicitly saved with Ctrl+S ([#5263](https://github.com/earendil-works/pi/issues/5263)). - Fixed JSON and RPC `toolcall_start` events omitting the tool call id and name ([#7953](https://github.com/earendil-works/pi/pull/7953) by [@christianklotz](https://github.com/christianklotz)). - Fixed extensions failing to load when the Node.js CLI runs as a single-executable application ([#8237](https://github.com/earendil-works/pi/issues/8237)). @@ -1149,8 +1149,8 @@ - Fixed manual compaction racing with threshold auto-compaction ([#7370](https://github.com/earendil-works/pi/pull/7370) by [@davidbrai](https://github.com/davidbrai)). - Fixed responses truncated below their intended output limit ending the run instead of compacting and retrying once ([#7540](https://github.com/earendil-works/pi/pull/7540) by [@davidbrai](https://github.com/davidbrai)). - Fixed Git package updates leaving dependencies missing when `git clean` cannot remove an ignored dependency directory ([#7570](https://github.com/earendil-works/pi/pull/7570) by [@mrexodia](https://github.com/mrexodia)). -- Fixed `find` results from POSIX and Windows filesystem roots losing the first path segment or gaining duplicate trailing separators ([#7569](https://github.com/earendil-works/pi/pull/7569)). -- Fixed transient version-check, catalog, managed-tool, and package-management HTTP failures not being retried ([#7632](https://github.com/earendil-works/pi/pull/7632)). +- Fixed `find` results from POSIX and Windows filesystem roots losing the first path segment or gaining duplicate trailing separators ([#7569](https://github.com/earendil-works/pi/pull/7569) by [@petrroll](https://github.com/petrroll)). +- Fixed transient version-check, catalog, managed-tool, and package-management HTTP failures not being retried ([#7632](https://github.com/earendil-works/pi/pull/7632) by [@petrroll](https://github.com/petrroll)). - Fixed interactive errors ignoring the configured output padding. - Fixed the inherited OpenCode Go provider display name. - Fixed inherited provider error normalization treating arrays and class instances as structured response bodies instead of preserving their original errors ([#7205](https://github.com/earendil-works/pi/pull/7205) by [@erikogenvik](https://github.com/erikogenvik)). @@ -1161,7 +1161,7 @@ - Fixed inherited Gemini 3 tool call ids being discarded during history conversion, breaking signed multi-turn replay ([#7494](https://github.com/earendil-works/pi/pull/7494) by [@muyiyr](https://github.com/muyiyr)). - Restored inherited GitHub Copilot models returned through account-specific policy responses ([#7672](https://github.com/earendil-works/pi/pull/7672) by [@muyiyr](https://github.com/muyiyr)). - Replaced the inherited retired Qwen Token Plan `qwen3.8-max-preview` model with `qwen3.8-max` ([#7670](https://github.com/earendil-works/pi/pull/7670) by [@QuintinShaw](https://github.com/QuintinShaw)). -- Fixed inherited terminal width accounting for Indic conjunct grapheme clusters ([#6987](https://github.com/earendil-works/pi/pull/6987)). +- Fixed inherited terminal width accounting for Indic conjunct grapheme clusters ([#6987](https://github.com/earendil-works/pi/pull/6987) by [@petrroll](https://github.com/petrroll)). - Fixed inherited nested fullscreen stack layouts ignoring child minimum sizes. - Fixed inherited batched terminal color-scheme reports being parsed as one malformed response ([#7550](https://github.com/earendil-works/pi/pull/7550)). - Fixed inherited terminal progress clearing to emit the complete OSC 9;4 sequence ([#7581](https://github.com/earendil-works/pi/pull/7581)). @@ -1187,7 +1187,7 @@ ### Breaking Changes -- Upgraded bundled TypeBox aliases to 1.3.7, removing deprecated APIs including `Type.Base`, `Type.Awaited`, `Type.Promise`, `Type.AsyncIterator`, `Type.Iterator`, `Type.Options`, and `Value.Mutate`, while fixing compiled validation of nullable array tool arguments. Extensions using removed APIs must migrate to supported TypeBox APIs. See [Package Dependencies](docs/packages.md#dependencies) ([#7243](https://github.com/earendil-works/pi/pull/7243)). +- Upgraded bundled TypeBox aliases to 1.3.7, removing deprecated APIs including `Type.Base`, `Type.Awaited`, `Type.Promise`, `Type.AsyncIterator`, `Type.Iterator`, `Type.Options`, and `Value.Mutate`, while fixing compiled validation of nullable array tool arguments. Extensions using removed APIs must migrate to supported TypeBox APIs. See [Package Dependencies](docs/packages.md#dependencies) ([#7243](https://github.com/earendil-works/pi/pull/7243) by [@petrroll](https://github.com/petrroll)). ### Added @@ -1281,7 +1281,7 @@ - Fixed compaction and branch-summary requests to use fresh routing session IDs with prompt caching disabled where supported ([#6618](https://github.com/earendil-works/pi/pull/6618) by [@tmustier](https://github.com/tmustier)). - Fixed explicit self-updates when `PI_SKIP_VERSION_CHECK` is set ([#6977](https://github.com/earendil-works/pi/issues/6977)). - Fixed scoped model IDs containing brackets to resolve as literal exact matches before glob matching ([#6210](https://github.com/earendil-works/pi/issues/6210)). -- Fixed inherited OpenAI and Anthropic provider retry waits to honor abort signals and configured delay limits ([#6980](https://github.com/earendil-works/pi/pull/6980)). +- Fixed inherited OpenAI and Anthropic provider retry waits to honor abort signals and configured delay limits ([#6980](https://github.com/earendil-works/pi/pull/6980) by [@petrroll](https://github.com/petrroll)). - Fixed fresh installs from preferring bundled model catalogs over newer remote catalogs because package file mtimes were newer ([#7016](https://github.com/earendil-works/pi/pull/7016) by [@davidbrai](https://github.com/davidbrai)). - Fixed inherited editor scroll indicators overflowing narrow terminals ([#7015](https://github.com/earendil-works/pi/pull/7015) by [@christianklotz](https://github.com/christianklotz)). - Fixed llama.cpp models to use the loaded context window as their output token limit instead of capping it at 16K ([#7034](https://github.com/earendil-works/pi/pull/7034) by [@christianklotz](https://github.com/christianklotz)). @@ -1431,7 +1431,7 @@ ### Breaking Changes -- Removed the `openai-responses` `compat.sendSessionIdHeader` flag from `models.json`. Session-affinity behavior is now controlled by `compat.sessionAffinityFormat` (`"openai"`, `"openai-nosession"`, or `"openrouter"`). Replace `sendSessionIdHeader: false` with `sessionAffinityFormat: "openai-nosession"` ([#6496](https://github.com/earendil-works/pi-mono/pull/6496)). +- Removed the `openai-responses` `compat.sendSessionIdHeader` flag from `models.json`. Session-affinity behavior is now controlled by `compat.sessionAffinityFormat` (`"openai"`, `"openai-nosession"`, or `"openrouter"`). Replace `sendSessionIdHeader: false` with `sessionAffinityFormat: "openai-nosession"` ([#6496](https://github.com/earendil-works/pi-mono/pull/6496) by [@petrroll](https://github.com/petrroll)). ### New Features @@ -1449,13 +1449,13 @@ ### Fixed - Fixed inherited OpenRouter model context windows to use the top provider's actual context length ([#6481](https://github.com/earendil-works/pi-mono/pull/6481) by [@davidbrai](https://github.com/davidbrai)). -- Fixed inherited OpenRouter OpenAI-compatible session IDs to use the `x-session-id` header instead of OpenAI-specific session-affinity fields ([#6496](https://github.com/earendil-works/pi-mono/pull/6496)). +- Fixed inherited OpenRouter OpenAI-compatible session IDs to use the `x-session-id` header instead of OpenAI-specific session-affinity fields ([#6496](https://github.com/earendil-works/pi-mono/pull/6496) by [@petrroll](https://github.com/petrroll)). - Fixed `Ctrl+V` to paste clipboard text when the pasteboard does not contain an image. - Fixed `/login amazon-bedrock` to prompt for and save a Bedrock API key instead of only displaying ambient AWS credential setup instructions. - Fixed inherited Amazon Bedrock ambient AWS credentials to keep using SigV4 authentication, including for custom model IDs ([#6532](https://github.com/earendil-works/pi-mono/pull/6532) by [@ribelo](https://github.com/ribelo)). - Fixed inherited Cloudflare Workers AI and AI Gateway authentication to use ambient account and gateway IDs when stored credentials contain only an API key ([#6292](https://github.com/earendil-works/pi-mono/pull/6292) by [@markphelps](https://github.com/markphelps)). - Fixed inherited legacy terminal decoding for Alt+symbol key combinations such as `Alt+,` and `Alt+.` ([#6523](https://github.com/earendil-works/pi-mono/pull/6523) by [@ribelo](https://github.com/ribelo)). -- Fixed the GitHub Copilot `mai-code-1-flash-picker` model to route through the `/responses` endpoint ([#6544](https://github.com/earendil-works/pi-mono/pull/6544)). +- Fixed the GitHub Copilot `mai-code-1-flash-picker` model to route through the `/responses` endpoint ([#6544](https://github.com/earendil-works/pi-mono/pull/6544) by [@petrroll](https://github.com/petrroll)). - Fixed branch summaries to work with providers that use ambient authentication instead of API keys ([#6595](https://github.com/earendil-works/pi-mono/pull/6595) by [@davidbrai](https://github.com/davidbrai)). - Fixed inherited Amazon Bedrock errors to report unhandled provider stop reasons instead of only `An unknown error occurred` ([#6598](https://github.com/earendil-works/pi-mono/pull/6598) by [@davidbrai](https://github.com/davidbrai)). - Fixed npm package removal when installed packages have conflicting peer dependencies ([#6604](https://github.com/earendil-works/pi-mono/pull/6604) by [@davidbrai](https://github.com/davidbrai)). diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 8c703ce44..f3e85bc4f 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -11,7 +11,7 @@ ### Added -- Added detection of the Rex terminal (`TERM_PROGRAM=rex`) with OSC 8 hyperlinks, truecolor, and Kitty images. Links previously fell back to the `text (url)` format ([#11131](https://github.com/earendil-works/pi/issues/11131), [#11150](https://github.com/earendil-works/pi/pull/11150), [#11183](https://github.com/earendil-works/pi/pull/11183) by [@nihaals](https://github.com/nihaals)) +- Added detection of the Rex terminal (`TERM_PROGRAM=rex`) with OSC 8 hyperlinks, truecolor, and Kitty images. Links previously fell back to the `text (url)` format ([#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)) ### Fixed @@ -266,7 +266,7 @@ - Fixed the npm package omitting the source and build script needed to rebuild the Windows native addon. - Fixed the npm package omitting the source and build script needed to rebuild the Darwin native addon. - Fixed Windows console truecolor detection when Windows Terminal does not provide `WT_SESSION` to child shells. -- Fixed terminal width accounting for Indic conjunct grapheme clusters ([#6987](https://github.com/earendil-works/pi/pull/6987)). +- Fixed terminal width accounting for Indic conjunct grapheme clusters ([#6987](https://github.com/earendil-works/pi/pull/6987) by [@petrroll](https://github.com/petrroll)). - Fixed phantom alternate-screen text selection from unmatched mouse events when changing terminal pane focus. - Fixed spaces in searchable settings queries changing the selected value instead of filtering multi-word labels. - Fixed alternate-screen Kitty images crossing vertical layout clip boundaries and overlapping sticky regions while scrolling. From 136b87e8a7f5a3e5186de0289523ba327dc592fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Hou=C5=A1ka?= Date: Fri, 23 Oct 2026 22:15:53 +0200 Subject: [PATCH 28/33] docs: require changelog entries for work targeting main from any branch --- .pi/prompts/wr.md | 2 +- AGENTS.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pi/prompts/wr.md b/.pi/prompts/wr.md index 5d9b11bbc..94d43eb26 100644 --- a/.pi/prompts/wr.md +++ b/.pi/prompts/wr.md @@ -38,4 +38,4 @@ Constraints: - Do not open a PR unless I explicitly ask. - If this is not GitHub issue or PR work, do not post a GitHub comment. - If a final issue or PR comment was already posted in this session, do not post another one unless I explicitly ask. -- When working against a branch other than `main`, skip the changelog. +- Skip the changelog only when the work targets a branch other than `main`. Feature branches, worktrees, and PRs that target `main` get changelog entries. diff --git a/AGENTS.md b/AGENTS.md index c7c874294..190d394f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,7 +109,7 @@ Rules: - All new entries go under `## [Unreleased]`. Read the full section first and append to existing subsections; never duplicate them. - Released version sections (e.g. `## [0.12.2]`) are immutable; never modify them. -- Do not create changelog entries when working on a branch other than `main` or pull request +- Do not create changelog entries when the work targets a branch other than `main` Attribution: From f715a15c101ca8a44d759bceeb7ec2a51024c81c Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 24 Oct 2026 08:00:07 +0000 Subject: [PATCH 29/33] fix(ai,chord,durable,coding-agent): parse streamed tool-call arguments incrementally (fixes #9265) - ai: StreamingJsonParser / ToolCallArgumentsStream read each argument delta once (tolerant, immutable snapshots, linear cost); every provider, pi-messages and the faux provider use it and keep stream state (partialJson, partialArgs, index, customInput, streamIndex, Bedrock reasoning bytes) off the content blocks; grammar tool input appends per delta - agent: streamProxy uses the same parser - chord: Delta append ops own their text (ownedSlice), so ops and replicas no longer keep the whole growing string alive - durable, ai, mcp: bounded error excerpts and summaries own their characters - coding-agent: tool calls render streamed arguments once per frame, not once per delta --- packages/agent/CHANGELOG.md | 4 + packages/agent/src/proxy.ts | 22 +- packages/agent/test/proxy.test.ts | 53 ++ packages/ai/CHANGELOG.md | 12 + packages/ai/src/api/anthropic-messages.ts | 100 +-- packages/ai/src/api/azure-openai-responses.ts | 6 - .../ai/src/api/bedrock-converse-stream.ts | 163 ++-- packages/ai/src/api/constrained-sampling.ts | 55 +- packages/ai/src/api/google-generative-ai.ts | 6 - packages/ai/src/api/google-vertex.ts | 6 - packages/ai/src/api/mistral-conversations.ts | 62 +- packages/ai/src/api/openai-codex-responses.ts | 5 - packages/ai/src/api/openai-completions.ts | 114 +-- .../ai/src/api/openai-responses-shared.ts | 154 ++-- packages/ai/src/api/openai-responses.ts | 6 - packages/ai/src/api/pi-messages.ts | 18 +- packages/ai/src/index.ts | 1 + packages/ai/src/providers/faux.ts | 4 + packages/ai/src/providers/radius-config.ts | 3 +- packages/ai/src/telemetry.ts | 3 +- packages/ai/src/utils/error-body.ts | 4 +- packages/ai/src/utils/streaming-json.ts | 559 ++++++++++++ packages/ai/src/utils/text.ts | 10 + packages/ai/test/bedrock-stream-state.test.ts | 158 ++++ packages/ai/test/constrained-sampling.test.ts | 77 +- packages/ai/test/data/json-test-suite.json | 322 +++++++ packages/ai/test/error-body.test.ts | 26 +- packages/ai/test/memory-helpers.ts | 39 + packages/ai/test/streaming-json.test.ts | 848 ++++++++++++++++++ .../ai/test/streaming-tool-arguments.test.ts | 420 +++++++++ packages/ai/test/telemetry.test.ts | 23 + packages/chord/CHANGELOG.md | 5 + packages/chord/src/delta/diff.ts | 7 +- packages/chord/src/delta/index.ts | 10 + packages/chord/src/delta/tracker.ts | 19 +- .../test/delta-op-text-retention.test.ts | 80 ++ packages/coding-agent/CHANGELOG.md | 1 + .../custom-provider-anthropic/index.ts | 58 +- .../interactive/components/tool-execution.ts | 11 +- .../test/tool-execution-component.test.ts | 53 ++ packages/durable/CHANGELOG.md | 6 + packages/durable/src/codemode/codemode.ts | 4 +- packages/durable/src/harness/scheduler.ts | 3 +- packages/durable/src/harness/tool.ts | 9 +- packages/durable/test/harness-events.test.ts | 89 ++ packages/mcp/CHANGELOG.md | 1 + .../mcp/src/transports/streamable-http.ts | 3 +- 47 files changed, 3187 insertions(+), 455 deletions(-) create mode 100644 packages/ai/src/utils/streaming-json.ts create mode 100644 packages/ai/test/bedrock-stream-state.test.ts create mode 100644 packages/ai/test/data/json-test-suite.json create mode 100644 packages/ai/test/memory-helpers.ts create mode 100644 packages/ai/test/streaming-json.test.ts create mode 100644 packages/ai/test/streaming-tool-arguments.test.ts create mode 100644 packages/chord/test/delta-op-text-retention.test.ts diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index b5367030f..2ae23784d 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `streamProxy()` re-parsing the accumulated tool-call arguments on every delta and keeping their raw text on the partial content block ([#9265](https://github.com/earendil-works/pi/issues/9265)) + ## [1.4.0] - 2026-10-21 ### Added diff --git a/packages/agent/src/proxy.ts b/packages/agent/src/proxy.ts index c5f6388ed..8aae7b72c 100644 --- a/packages/agent/src/proxy.ts +++ b/packages/agent/src/proxy.ts @@ -3,17 +3,16 @@ * The server manages auth and proxies requests to LLM providers. */ -// Internal import for JSON parsing utility import { type AssistantMessage, type AssistantMessageEvent, type AssistantMessageEventStream, createAssistantMessageEventStream, type Model, - parseStreamingJson, type SimpleStreamOptions, type StopReason, type ToolCall, + ToolCallArgumentsStream, type TranscriptContext, } from "@earendil-works/pi-ai"; @@ -175,13 +174,15 @@ export function streamProxy( const decoder = new TextDecoder(); let buffer = ""; let sawTerminalEvent = false; + // Argument streams of open tool calls by content index, kept off the content blocks. + const toolArguments = new Map(); const processLine = (line: string): void => { if (!line.startsWith("data: ")) return; const data = line.slice(6).trim(); if (!data) return; const proxyEvent = JSON.parse(data) as ProxyAssistantMessageEvent; - const event = processProxyEvent(proxyEvent, partial); + const event = processProxyEvent(proxyEvent, partial, toolArguments); if (event) { if (event.type === "done" || event.type === "error") sawTerminalEvent = true; stream.push(event); @@ -257,6 +258,7 @@ export function streamProxy( function processProxyEvent( proxyEvent: ProxyAssistantMessageEvent, partial: AssistantMessage, + toolArguments: Map, ): AssistantMessageEvent | undefined { switch (proxyEvent.type) { case "start": @@ -332,16 +334,16 @@ function processProxyEvent( id: proxyEvent.id, name: proxyEvent.toolName, arguments: {}, - partialJson: "", - } satisfies ToolCall & { partialJson: string } as ToolCall; + }; + toolArguments.set(proxyEvent.contentIndex, new ToolCallArgumentsStream()); return { type: "toolcall_start", contentIndex: proxyEvent.contentIndex, partial }; case "toolcall_delta": { const content = partial.content[proxyEvent.contentIndex]; - if (content?.type === "toolCall") { - (content as any).partialJson += proxyEvent.delta; - content.arguments = parseStreamingJson((content as any).partialJson) || {}; - partial.content[proxyEvent.contentIndex] = { ...content }; // Trigger reactivity + const args = toolArguments.get(proxyEvent.contentIndex); + if (content?.type === "toolCall" && args !== undefined) { + // A new block object, so reactive consumers see the change. + partial.content[proxyEvent.contentIndex] = { ...content, arguments: args.append(proxyEvent.delta) }; return { type: "toolcall_delta", contentIndex: proxyEvent.contentIndex, @@ -356,7 +358,7 @@ function processProxyEvent( const content = partial.content[proxyEvent.contentIndex]; if (content?.type === "toolCall") { Object.assign(content, proxyEvent.toolCall); - delete (content as any).partialJson; + toolArguments.delete(proxyEvent.contentIndex); return { type: "toolcall_end", contentIndex: proxyEvent.contentIndex, diff --git a/packages/agent/test/proxy.test.ts b/packages/agent/test/proxy.test.ts index 43e5174c3..822cc5391 100644 --- a/packages/agent/test/proxy.test.ts +++ b/packages/agent/test/proxy.test.ts @@ -115,4 +115,57 @@ describe("streamProxy", () => { expect(result.stopReason).toBe("error"); expect(result.errorMessage).toContain("Connection closed by proxy server"); }); + + // #9265 + it("shows growing partial arguments without keeping the raw text on the block, in linear time", async () => { + const content = 'line "quoted" \\ end\n'.repeat(20_000); + const text = JSON.stringify({ path: "a.ts", content }); + const proxyEvents: ProxyAssistantMessageEvent[] = [ + { type: "start" }, + { type: "toolcall_start", contentIndex: 0, id: "call_1", toolName: "write" }, + ]; + for (let index = 0; index < text.length; index += 32) { + proxyEvents.push({ type: "toolcall_delta", contentIndex: 0, delta: text.slice(index, index + 32) }); + } + proxyEvents.push( + { + type: "toolcall_end", + contentIndex: 0, + toolCall: { type: "toolCall", id: "call_1", name: "write", arguments: { path: "a.ts", content } }, + }, + { type: "done", reason: "toolUse", usage }, + ); + const body = proxyEvents.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(body, { status: 200 })), + ); + + const started = performance.now(); + const stream = streamProxy(model, normalizeContext({ systemPrompt: "", messages: [] }), { + authToken: "test-token", + proxyUrl: "https://proxy.example.com", + }); + let shown = 0; + let deltas = 0; + for await (const event of stream) { + if (event.type !== "toolcall_delta") continue; + const block = event.partial.content[0]; + expect(Object.keys(block!).sort()).toEqual(["arguments", "id", "name", "type"]); + const partial = block?.type === "toolCall" ? block.arguments.content : undefined; + if (typeof partial !== "string") continue; + // Comparing the whole text on every delta would make this test itself quadratic. + if (deltas++ % 500 === 0) expect(content.startsWith(partial)).toBe(true); + expect(partial.length).toBeGreaterThanOrEqual(shown); + shown = partial.length; + } + const result = await stream.result(); + + expect(shown).toBeGreaterThan(content.length - 64); + expect(result.content).toEqual([ + { type: "toolCall", id: "call_1", name: "write", arguments: { path: "a.ts", content } }, + ]); + // Re-parsing the accumulated 520 KB of arguments on each of its 16,000 deltas took minutes. + expect(performance.now() - started).toBeLessThan(10_000); + }); }); diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 63efd0e37..9ea77c63b 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,14 +2,26 @@ ## [Unreleased] +### Breaking Changes + +- `appendGrammarToolInputJsonDelta()` (`api/constrained-sampling`) takes the input delta instead of the whole input so far and no longer closes the input; close it with the new `closeGrammarToolInputJson()` ([#9265](https://github.com/earendil-works/pi/issues/9265)) + ### Added - Added optional `openBrowserOnConfirm` and `copyCodeOnConfirm` to `device_code` auth events and `OAuthDeviceCodeInfo`. Both default to `true`; set them to `false` to stop clients from offering to open the verification page or copy the code ([#9282](https://github.com/earendil-works/pi/issues/9282), [#9301](https://github.com/earendil-works/pi/pull/9301)) +- Added `StreamingJsonParser`, an incremental, tolerant parser for JSON text that arrives in pieces (each piece is read once; snapshots of the partial value never change once returned), and `ToolCallArgumentsStream`, which every provider now uses for streamed tool-call arguments; its `append()` also returns the changes each piece made (`JsonChange`: a value set at a path, or text appended to a string), for writers that keep their own copy ([#9265](https://github.com/earendil-works/pi/issues/9265)) + +### Changed + +- The faux provider streams partial tool-call arguments, so `toolcall_delta` partials show growing arguments as real providers do ([#9265](https://github.com/earendil-works/pi/issues/9265)) ### 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)) - Fixed Claude models with mid-conversation effort failing with `Invalid effort level` on accounts whose organization caps effort below `high`, even at `low` or `medium` thinking, by sending a fixed `low` top-level effort ([#10063](https://github.com/earendil-works/pi/issues/10063)) +- Fixed streamed tool-call arguments being re-parsed in full on every delta, which made CPU time and memory grow quadratically with the size of a tool call (a 90 KB `write` on Anthropic peaked at about 140 MB of heap; a 500 KB one took minutes). Every provider and the pi-messages client now read each delta once, and keep the raw argument text and their other stream state (`partialJson`, `partialArgs`, `index`, `customInput`, `streamIndex`, Bedrock's encrypted reasoning bytes) off the content blocks, so it no longer reaches partial messages or transcripts of failed streams ([#9265](https://github.com/earendil-works/pi/issues/9265)) +- Fixed grammar (custom) tool input streaming copying the whole input so far on every delta ([#9265](https://github.com/earendil-works/pi/issues/9265)) +- Fixed bounded error texts (telemetry span messages, truncated provider error bodies, pi-messages diagnostics, Radius gateway errors) keeping the whole text they were cut from alive ([#9265](https://github.com/earendil-works/pi/issues/9265)) ## [1.4.0] - 2026-10-21 diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index 00d820341..53764b406 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -32,16 +32,14 @@ import type { StreamFunction, StreamOptions, TextContent, - ThinkingContent, Tool, - ToolCall, ToolResultMessage, } from "../types.ts"; import { appendAssistantMessageDiagnostic } from "../utils/diagnostics.ts"; 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 { parseJsonWithRepair } from "../utils/json-parse.ts"; import { applyOpenRouterReportedCost, isOpenRouterModel, @@ -50,6 +48,7 @@ import { import { getProviderEnvValue } from "../utils/provider-env.ts"; import { retryProviderRequest } from "../utils/provider-retry.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; +import { ToolCallArgumentsStream } from "../utils/streaming-json.ts"; import { getSystemMessageText, renderSystemMessageUpdate } from "../utils/text.ts"; import { getCurrentTools, @@ -695,8 +694,12 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model); stream.push({ type: "start", partial: output }); - type Block = (ThinkingContent | TextContent | (ToolCall & { partialJson: string })) & { index: number }; - const blocks = output.content as Block[]; + // Stream state per Anthropic block index, kept off the content blocks so it never reaches partials. + const openBlocks = new Map(); + const openBlock = (anthropicIndex: number) => { + const open = openBlocks.get(anthropicIndex); + return open === undefined ? undefined : { ...open, block: output.content[open.contentIndex]! }; + }; for await (const event of iterateAnthropicEvents(response, options?.signal)) { await options?.onProviderStreamEvent?.(event, model); @@ -732,97 +735,84 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( continue; } if (event.content_block.type === "text") { - const block: Block = { - type: "text", - text: event.content_block.text ?? "", - index: event.index, - }; - output.content.push(block); + output.content.push({ type: "text", text: event.content_block.text ?? "" }); + openBlocks.set(event.index, { contentIndex: output.content.length - 1 }); stream.push({ type: "text_start", contentIndex: output.content.length - 1, partial: output }); } else if (event.content_block.type === "thinking") { - const block: Block = { + output.content.push({ type: "thinking", thinking: event.content_block.thinking ?? "", thinkingSignature: event.content_block.signature ?? "", - index: event.index, - }; - output.content.push(block); + }); + openBlocks.set(event.index, { contentIndex: output.content.length - 1 }); stream.push({ type: "thinking_start", contentIndex: output.content.length - 1, partial: output }); } else if (event.content_block.type === "redacted_thinking") { - const block: Block = { + output.content.push({ type: "thinking", thinking: "[Reasoning redacted]", thinkingSignature: event.content_block.data, redacted: true, - index: event.index, - }; - output.content.push(block); + }); + openBlocks.set(event.index, { contentIndex: output.content.length - 1 }); stream.push({ type: "thinking_start", contentIndex: output.content.length - 1, partial: output }); } else if (event.content_block.type === "tool_use") { - const block: Block = { + output.content.push({ type: "toolCall", id: event.content_block.id, name: isOAuth ? fromClaudeCodeName(event.content_block.name, currentTools) : event.content_block.name, arguments: (event.content_block.input as Record) ?? {}, - partialJson: "", - index: event.index, - }; - output.content.push(block); + }); + openBlocks.set(event.index, { + contentIndex: output.content.length - 1, + arguments: new ToolCallArgumentsStream(), + }); stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output }); } } else if (event.type === "content_block_delta") { + const open = openBlock(event.index); if (event.delta.type === "text_delta") { - const index = blocks.findIndex((b) => b.index === event.index); - const block = blocks[index]; - if (block && block.type === "text") { - block.text += event.delta.text; + if (open?.block.type === "text") { + open.block.text += event.delta.text; stream.push({ type: "text_delta", - contentIndex: index, + contentIndex: open.contentIndex, delta: event.delta.text, partial: output, }); } } else if (event.delta.type === "thinking_delta") { - const index = blocks.findIndex((b) => b.index === event.index); - const block = blocks[index]; - if (block && block.type === "thinking") { - block.thinking += event.delta.thinking; + if (open?.block.type === "thinking") { + open.block.thinking += event.delta.thinking; stream.push({ type: "thinking_delta", - contentIndex: index, + contentIndex: open.contentIndex, delta: event.delta.thinking, partial: output, }); } } else if (event.delta.type === "input_json_delta") { - const index = blocks.findIndex((b) => b.index === event.index); - const block = blocks[index]; - if (block && block.type === "toolCall") { - block.partialJson += event.delta.partial_json; - block.arguments = parseStreamingJson(block.partialJson); + if (open?.block.type === "toolCall" && open.arguments !== undefined) { + open.block.arguments = open.arguments.append(event.delta.partial_json); stream.push({ type: "toolcall_delta", - contentIndex: index, + contentIndex: open.contentIndex, delta: event.delta.partial_json, partial: output, }); } } else if (event.delta.type === "signature_delta") { - const index = blocks.findIndex((b) => b.index === event.index); - const block = blocks[index]; - if (block && block.type === "thinking") { - block.thinkingSignature = block.thinkingSignature || ""; - block.thinkingSignature += event.delta.signature; + if (open?.block.type === "thinking") { + open.block.thinkingSignature = open.block.thinkingSignature || ""; + open.block.thinkingSignature += event.delta.signature; } } } else if (event.type === "content_block_stop") { - const index = blocks.findIndex((b) => b.index === event.index); - const block = blocks[index]; - if (block) { - delete (block as any).index; + const open = openBlock(event.index); + openBlocks.delete(event.index); + if (open) { + const { block, contentIndex: index } = open; if (block.type === "text") { stream.push({ type: "text_end", @@ -837,11 +827,8 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( content: block.thinking, partial: output, }); - } else if (block.type === "toolCall") { - Object.assign(block, parseToolCallArguments(block.partialJson)); - // Finalize in-place and strip the scratch buffer so replay only - // carries parsed arguments. - delete (block as { partialJson?: string }).partialJson; + } else if (block.type === "toolCall" && open.arguments !== undefined) { + Object.assign(block, open.arguments.finish()); stream.push({ type: "toolcall_end", contentIndex: index, @@ -931,11 +918,6 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( stream.push({ type: "done", reason: output.stopReason, message: output }); stream.end(); } catch (error) { - for (const block of output.content) { - delete (block as { index?: number }).index; - // partialJson is only a streaming scratch buffer; never persist it. - delete (block as { partialJson?: string }).partialJson; - } output.stopReason = options?.signal?.aborted ? "aborted" : "error"; output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); stream.push({ type: "error", reason: output.stopReason, error: output }); diff --git a/packages/ai/src/api/azure-openai-responses.ts b/packages/ai/src/api/azure-openai-responses.ts index 07ce1a289..24e73fd05 100644 --- a/packages/ai/src/api/azure-openai-responses.ts +++ b/packages/ai/src/api/azure-openai-responses.ts @@ -118,12 +118,6 @@ export const stream: StreamFunction<"azure-openai-responses", AzureOpenAIRespons stream.push({ type: "done", reason: output.stopReason, message: output }); stream.end(); } catch (error) { - for (const block of output.content) { - delete (block as { index?: number }).index; - // Streaming scratch buffers are only used during parsing; never persist them. - delete (block as { partialJson?: string }).partialJson; - delete (block as { customInput?: unknown }).customInput; - } output.stopReason = options?.signal?.aborted ? "aborted" : "error"; output.errorMessage = formatAzureOpenAIError(error); stream.push({ type: "error", reason: output.stopReason, error: output }); diff --git a/packages/ai/src/api/bedrock-converse-stream.ts b/packages/ai/src/api/bedrock-converse-stream.ts index 8c6df2531..7a7740f06 100644 --- a/packages/ai/src/api/bedrock-converse-stream.ts +++ b/packages/ai/src/api/bedrock-converse-stream.ts @@ -43,10 +43,8 @@ import type { StreamOptions, TextContent, ThinkingBudgets, - ThinkingContent, ThinkingLevel, Tool, - ToolCall, ToolResultMessage, } from "../types.ts"; import { appendAssistantMessageDiagnostic } from "../utils/diagnostics.ts"; @@ -54,10 +52,10 @@ import { normalizeProviderError } from "../utils/error-body.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { providerHeadersToRecord } from "../utils/headers.ts"; import { type ClientIdentity, getClientVersion } from "../utils/identity.ts"; -import { parseStreamingJson, parseToolCallArguments } from "../utils/json-parse.ts"; import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; +import { ToolCallArgumentsStream } from "../utils/streaming-json.ts"; import { getSystemMessageText } from "../utils/text.ts"; import { collapseSystemMessages, @@ -111,13 +109,19 @@ export interface BedrockOptions extends StreamOptions { bearerToken?: string; } -type Block = (TextContent | ThinkingContent | ToolCall) & { - index?: number; - partialJson?: string; - /** Scratch buffer for encrypted reasoning deltas, joined into `thinkingSignature`. */ +/** + * Stream state of one open Bedrock content block, keyed by `contentBlockIndex` and kept off the content block so it + * never reaches partial messages. + */ +type OpenBlock = { + readonly contentIndex: number; + readonly arguments?: ToolCallArgumentsStream; + /** Encrypted reasoning deltas, joined into `thinkingSignature` when the block ends. */ redactedChunks?: Uint8Array[]; }; +type OpenBlocks = Map; + const EMPTY_TEXT_PLACEHOLDER = ""; const THINKING_BINDING_CONTROLS_BETA = "thinking-binding-controls-2026-08-01"; @@ -153,7 +157,7 @@ export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> = timestamp: Date.now(), }; - const blocks = output.content as Block[]; + const openBlocks: OpenBlocks = new Map(); // A profile explicitly configured through pi's auth flow (the `profile` // option or scoped `AWS_PROFILE` on the stored credential's env) must win @@ -305,11 +309,11 @@ export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> = } stream.push({ type: "start", partial: output }); } else if (item.contentBlockStart) { - handleContentBlockStart(item.contentBlockStart, blocks, output, stream); + handleContentBlockStart(item.contentBlockStart, openBlocks, output, stream); } else if (item.contentBlockDelta) { - handleContentBlockDelta(item.contentBlockDelta, blocks, output, stream); + handleContentBlockDelta(item.contentBlockDelta, openBlocks, output, stream); } else if (item.contentBlockStop) { - handleContentBlockStop(item.contentBlockStop, blocks, output, stream); + handleContentBlockStop(item.contentBlockStop, openBlocks, output, stream); } else if (item.messageStop) { output.rawStopReason = item.messageStop.stopReason; const { stopReason, errorMessage } = mapStopReason(item.messageStop.stopReason); @@ -344,13 +348,11 @@ export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> = } // A stream can settle without stopping every block, so finalize here too. - for (const block of output.content) finalizeStreamingBlock(block as Block); + flushOpenBlocks(openBlocks, output); stream.push({ type: "done", reason: output.stopReason, message: output }); stream.end(); } catch (error) { - for (const block of output.content) { - finalizeStreamingBlock(block as Block); - } + flushOpenBlocks(openBlocks, output); output.stopReason = options.signal?.aborted ? "aborted" : "error"; output.errorMessage = formatBedrockError(error); if (output.stopReason === "error") { @@ -593,70 +595,70 @@ export const streamSimple: StreamFunction<"bedrock-converse-stream", SimpleStrea function handleContentBlockStart( event: ContentBlockStartEvent, - blocks: Block[], + openBlocks: OpenBlocks, output: AssistantMessage, stream: AssistantMessageEventStream, ): void { - const index = event.contentBlockIndex!; const start = event.start; if (start?.toolUse) { - const block: Block = { + output.content.push({ type: "toolCall", id: start.toolUse.toolUseId || "", name: start.toolUse.name || "", arguments: {}, - partialJson: "", - index, - }; - output.content.push(block); - stream.push({ type: "toolcall_start", contentIndex: blocks.length - 1, partial: output }); + }); + const contentIndex = output.content.length - 1; + openBlocks.set(event.contentBlockIndex!, { contentIndex, arguments: new ToolCallArgumentsStream() }); + stream.push({ type: "toolcall_start", contentIndex, partial: output }); } } function handleContentBlockDelta( event: ContentBlockDeltaEvent, - blocks: Block[], + openBlocks: OpenBlocks, output: AssistantMessage, stream: AssistantMessageEventStream, ): void { const contentBlockIndex = event.contentBlockIndex!; const delta = event.delta; - let index = blocks.findIndex((b) => b.index === contentBlockIndex); - let block = blocks[index]; + let open = openBlocks.get(contentBlockIndex); + let block = open === undefined ? undefined : output.content[open.contentIndex]; if (delta?.text !== undefined) { // If no text block exists yet, create one, as `handleContentBlockStart` is not sent for text blocks - if (!block) { - const newBlock: Block = { type: "text", text: "", index: contentBlockIndex }; - output.content.push(newBlock); - index = blocks.length - 1; - block = blocks[index]; - stream.push({ type: "text_start", contentIndex: index, partial: output }); + if (!open) { + output.content.push({ type: "text", text: "" }); + open = { contentIndex: output.content.length - 1 }; + openBlocks.set(contentBlockIndex, open); + block = output.content[open.contentIndex]; + stream.push({ type: "text_start", contentIndex: open.contentIndex, partial: output }); } - if (block.type === "text") { + if (block?.type === "text") { block.text += delta.text; - stream.push({ type: "text_delta", contentIndex: index, delta: delta.text, partial: output }); + stream.push({ type: "text_delta", contentIndex: open.contentIndex, delta: delta.text, partial: output }); } - } else if (delta?.toolUse && block?.type === "toolCall") { - block.partialJson = (block.partialJson || "") + (delta.toolUse.input || ""); - block.arguments = parseStreamingJson(block.partialJson); - stream.push({ type: "toolcall_delta", contentIndex: index, delta: delta.toolUse.input || "", partial: output }); + } else if (delta?.toolUse && block?.type === "toolCall" && open?.arguments !== undefined) { + block.arguments = open.arguments.append(delta.toolUse.input || ""); + stream.push({ + type: "toolcall_delta", + contentIndex: open.contentIndex, + delta: delta.toolUse.input || "", + partial: output, + }); } else if (delta?.reasoningContent) { - let thinkingBlock = block; - let thinkingIndex = index; - - if (!thinkingBlock) { - const newBlock: Block = { type: "thinking", thinking: "", thinkingSignature: "", index: contentBlockIndex }; - output.content.push(newBlock); - thinkingIndex = blocks.length - 1; - thinkingBlock = blocks[thinkingIndex]; - stream.push({ type: "thinking_start", contentIndex: thinkingIndex, partial: output }); + if (!open) { + output.content.push({ type: "thinking", thinking: "", thinkingSignature: "" }); + open = { contentIndex: output.content.length - 1 }; + openBlocks.set(contentBlockIndex, open); + block = output.content[open.contentIndex]; + stream.push({ type: "thinking_start", contentIndex: open.contentIndex, partial: output }); } - if (thinkingBlock?.type === "thinking") { + if (block?.type === "thinking") { + const thinkingIndex = open.contentIndex; if (delta.reasoningContent.text) { - thinkingBlock.thinking += delta.reasoningContent.text; + block.thinking += delta.reasoningContent.text; stream.push({ type: "thinking_delta", contentIndex: thinkingIndex, @@ -666,18 +668,17 @@ function handleContentBlockDelta( } // `thinkingSignature` holds either an Anthropic signature or an opaque redacted // payload, never both: mixing them would corrupt whichever arrived first. - if (delta.reasoningContent.signature && !thinkingBlock.redacted) { - thinkingBlock.thinkingSignature = - (thinkingBlock.thinkingSignature || "") + delta.reasoningContent.signature; + if (delta.reasoningContent.signature && !block.redacted) { + block.thinkingSignature = (block.thinkingSignature || "") + delta.reasoningContent.signature; } if (delta.reasoningContent.redactedContent?.length) { // Encrypted reasoning from non-Anthropic models on Bedrock (e.g. OpenAI GPT-5.6). // The payload is opaque, so keep it verbatim in `thinkingSignature` the way the // Anthropic path stores redacted thinking, and replay it on the next turn. - if (!thinkingBlock.redacted) { - thinkingBlock.redacted = true; - thinkingBlock.thinkingSignature = ""; - thinkingBlock.thinking += REDACTED_THINKING_PLACEHOLDER; + if (!block.redacted) { + block.redacted = true; + block.thinkingSignature = ""; + block.thinking += REDACTED_THINKING_PLACEHOLDER; stream.push({ type: "thinking_delta", contentIndex: thinkingIndex, @@ -685,33 +686,29 @@ function handleContentBlockDelta( partial: output, }); } - thinkingBlock.redactedChunks ??= []; - thinkingBlock.redactedChunks.push(delta.reasoningContent.redactedContent); + open.redactedChunks ??= []; + open.redactedChunks.push(delta.reasoningContent.redactedContent); } } } } /** - * Encodes buffered encrypted reasoning into `thinkingSignature` and drops the scratch - * buffer, which must never reach a persisted message: `Uint8Array` serializes to an - * index-keyed object roughly ten times the size of the base64 payload. + * Encodes buffered encrypted reasoning into `thinkingSignature`. The chunks stay in the stream state, never on the + * block: `Uint8Array` is not JSON and serializes to an index-keyed object roughly ten times the size of the base64 + * payload. */ -function flushRedactedContent(block: Block): void { - if (block.type !== "thinking" || !block.redactedChunks) return; - block.thinkingSignature = bytesToBase64(block.redactedChunks); - delete block.redactedChunks; +function flushRedactedContent(open: OpenBlock, output: AssistantMessage): void { + const block = output.content[open.contentIndex]; + if (block?.type !== "thinking" || !open.redactedChunks) return; + block.thinkingSignature = bytesToBase64(open.redactedChunks); + open.redactedChunks = undefined; } -/** - * Strips every streaming scratch field. Runs from the terminal paths as well as - * `contentBlockStop`, because a stream can settle without stopping each block. - */ -function finalizeStreamingBlock(block: Block): void { - delete block.index; - // partialJson is only a streaming scratch buffer; never persist it. - delete block.partialJson; - flushRedactedContent(block); +/** Ends the stream state of blocks a stream settled without stopping. */ +function flushOpenBlocks(openBlocks: OpenBlocks, output: AssistantMessage): void { + for (const open of openBlocks.values()) flushRedactedContent(open, output); + openBlocks.clear(); } function handleMetadata( @@ -735,28 +732,26 @@ function handleMetadata( function handleContentBlockStop( event: ContentBlockStopEvent, - blocks: Block[], + openBlocks: OpenBlocks, output: AssistantMessage, stream: AssistantMessageEventStream, ): void { - const index = blocks.findIndex((b) => b.index === event.contentBlockIndex); - const block = blocks[index]; - if (!block) return; - delete (block as Block).index; + const open = openBlocks.get(event.contentBlockIndex!); + if (!open) return; + openBlocks.delete(event.contentBlockIndex!); + const index = open.contentIndex; + const block = output.content[index]!; switch (block.type) { case "text": stream.push({ type: "text_end", contentIndex: index, content: block.text, partial: output }); break; case "thinking": - flushRedactedContent(block); + flushRedactedContent(open, output); stream.push({ type: "thinking_end", contentIndex: index, content: block.thinking, partial: output }); break; case "toolCall": - Object.assign(block, parseToolCallArguments(block.partialJson)); - // Finalize in-place and strip the scratch buffer so replay only - // carries parsed arguments. - delete (block as Block).partialJson; + if (open.arguments !== undefined) Object.assign(block, open.arguments.finish()); stream.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: output }); break; } diff --git a/packages/ai/src/api/constrained-sampling.ts b/packages/ai/src/api/constrained-sampling.ts index 3f1cb262e..62e03a105 100644 --- a/packages/ai/src/api/constrained-sampling.ts +++ b/packages/ai/src/api/constrained-sampling.ts @@ -167,36 +167,51 @@ export function getGrammarToolInput( return input; } +/** + * Append `inputDelta` to a streaming grammar tool input and return the matching piece of its JSON arguments text, or + * `undefined` for an empty delta. Costs time linear in the delta, never in the input so far. + */ export function appendGrammarToolInputJsonDelta( buffer: GrammarToolInputJsonBuffer, inputProperty: string, - nextInput: string, - close: boolean, + inputDelta: string, ): string | undefined { if (buffer.closed) { - if (close && nextInput === buffer.input) return undefined; throw new Error(`grammar tool input for property "${inputProperty}" changed after it was closed`); } - if (!nextInput.startsWith(buffer.input)) { + if (inputDelta.length === 0) return undefined; + buffer.input += inputDelta; + return grammarToolInputJsonStart(buffer, inputProperty) + JSON.stringify(inputDelta).slice(1, -1); +} + +/** + * Close a streaming grammar tool input and return the rest of its JSON arguments text. `finalInput` is the complete + * input when the provider sends one; it must continue the input streamed so far. Closing again with the same input + * returns `undefined`. + */ +export function closeGrammarToolInputJson( + buffer: GrammarToolInputJsonBuffer, + inputProperty: string, + finalInput: string | undefined, +): string | undefined { + if (buffer.closed) { + if (finalInput === undefined || finalInput === buffer.input) return undefined; + throw new Error(`grammar tool input for property "${inputProperty}" changed after it was closed`); + } + if (finalInput !== undefined && !finalInput.startsWith(buffer.input)) { throw new Error(`grammar tool input for property "${inputProperty}" changed non-monotonically`); } + const rest = finalInput === undefined ? "" : finalInput.slice(buffer.input.length); + if (finalInput !== undefined) buffer.input = finalInput; + buffer.closed = true; + return `${grammarToolInputJsonStart(buffer, inputProperty)}${JSON.stringify(rest).slice(1, -1)}"}`; +} - const inputDelta = nextInput.slice(buffer.input.length); - if (!close && inputDelta.length === 0) return undefined; - - let delta = ""; - if (!buffer.started) { - delta += `{${JSON.stringify(inputProperty)}:"`; - buffer.started = true; - } - delta += JSON.stringify(inputDelta).slice(1, -1); - buffer.input = nextInput; - - if (close) { - delta += '"}'; - buffer.closed = true; - } - return delta; +/** The opening of the JSON arguments text, once. */ +function grammarToolInputJsonStart(buffer: GrammarToolInputJsonBuffer, inputProperty: string): string { + if (buffer.started) return ""; + buffer.started = true; + return `{${JSON.stringify(inputProperty)}:"`; } function inferGrammarInputProperty(tool: Tool): string { diff --git a/packages/ai/src/api/google-generative-ai.ts b/packages/ai/src/api/google-generative-ai.ts index 5e44ce2a4..213eff5e8 100644 --- a/packages/ai/src/api/google-generative-ai.ts +++ b/packages/ai/src/api/google-generative-ai.ts @@ -286,12 +286,6 @@ export const stream: StreamFunction<"google-generative-ai", GoogleOptions> = ( stream.push({ type: "done", reason: output.stopReason, message: output }); stream.end(); } catch (error) { - // Remove internal index property used during streaming - for (const block of output.content) { - if ("index" in block) { - delete (block as { index?: number }).index; - } - } output.stopReason = options?.signal?.aborted ? "aborted" : "error"; output.errorMessage = formatProviderError(normalizeProviderError(error)); stream.push({ type: "error", reason: output.stopReason, error: output }); diff --git a/packages/ai/src/api/google-vertex.ts b/packages/ai/src/api/google-vertex.ts index 1865cbceb..07f78afe8 100644 --- a/packages/ai/src/api/google-vertex.ts +++ b/packages/ai/src/api/google-vertex.ts @@ -301,12 +301,6 @@ export const stream: StreamFunction<"google-vertex", GoogleVertexOptions> = ( stream.push({ type: "done", reason: output.stopReason, message: output }); stream.end(); } catch (error) { - // Remove internal index property used during streaming - for (const block of output.content) { - if ("index" in block) { - delete (block as { index?: number }).index; - } - } output.stopReason = options?.signal?.aborted ? "aborted" : "error"; output.errorMessage = formatProviderError(normalizeProviderError(error)); stream.push({ type: "error", reason: output.stopReason, error: output }); diff --git a/packages/ai/src/api/mistral-conversations.ts b/packages/ai/src/api/mistral-conversations.ts index 9ac710399..69e3f1dd1 100644 --- a/packages/ai/src/api/mistral-conversations.ts +++ b/packages/ai/src/api/mistral-conversations.ts @@ -14,12 +14,13 @@ import type { TranscriptContext, } from "../types.ts"; import { combineAbortSignals } from "../utils/abort-signals.ts"; +import { truncateErrorText } from "../utils/error-body.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { shortHash } from "../utils/hash.ts"; import { headersToRecord } from "../utils/headers.ts"; import { getIdentityHeaders } from "../utils/identity.ts"; -import { parseStreamingJson, parseToolCallArguments } from "../utils/json-parse.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; +import { ToolCallArgumentsStream } from "../utils/streaming-json.ts"; import { getSystemMessageText, renderSystemMessageUpdate } from "../utils/text.ts"; import { getCurrentTools, resolveTranscript } from "../utils/transcript.ts"; import { getJsonSchemaToolParameters, resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts"; @@ -167,10 +168,6 @@ export const stream: StreamFunction<"mistral-conversations", MistralOptions> = ( stream.push({ type: "done", reason: output.stopReason, message: output }); stream.end(); } catch (error) { - for (const block of output.content) { - // partialArgs is only a streaming scratch buffer; never persist it. - delete (block as { partialArgs?: string }).partialArgs; - } output.stopReason = options?.signal?.aborted ? "aborted" : "error"; output.errorMessage = formatMistralError(error); stream.push({ type: "error", reason: output.stopReason, error: output }); @@ -281,11 +278,6 @@ function formatMistralError(error: unknown): string { return safeJsonStringify(error); } -function truncateErrorText(text: string, maxChars: number): string { - if (text.length <= maxChars) return text; - return `${text.slice(0, maxChars)}... [truncated ${text.length - maxChars} chars]`; -} - function safeJsonStringify(value: unknown): string { try { const serialized = JSON.stringify(value); @@ -583,7 +575,8 @@ async function consumeChatStream( let currentBlock: TextContent | ThinkingContent | null = null; const blocks = output.content; const blockIndex = () => blocks.length - 1; - const toolBlocksByKey = new Map(); + // Stream state per Mistral tool call, kept off the content blocks so it never reaches partials. + const toolCallsByKey = new Map(); const finishCurrentBlock = (block?: typeof currentBlock) => { if (!block) return; @@ -718,38 +711,22 @@ async function consumeChatStream( ? toolCall.id : deriveMistralToolCallId(`toolcall:${toolCall.index ?? 0}`, 0); const key = toolCall.index ?? callId; - const existingIndex = toolBlocksByKey.get(key); - let block: (ToolCall & { partialArgs?: string }) | undefined; - - if (existingIndex !== undefined) { - const existing = output.content[existingIndex]; - if (existing?.type === "toolCall") { - block = existing as ToolCall & { partialArgs?: string }; - } - } - - if (!block) { - block = { - type: "toolCall", - id: callId, - name: toolCall.function.name, - arguments: {}, - partialArgs: "", - }; - output.content.push(block); - toolBlocksByKey.set(key, output.content.length - 1); - stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output }); + let open = toolCallsByKey.get(key); + if (!open) { + output.content.push({ type: "toolCall", id: callId, name: toolCall.function.name, arguments: {} }); + open = { contentIndex: output.content.length - 1, arguments: new ToolCallArgumentsStream() }; + toolCallsByKey.set(key, open); + stream.push({ type: "toolcall_start", contentIndex: open.contentIndex, partial: output }); } const argsDelta = typeof toolCall.function.arguments === "string" ? toolCall.function.arguments : JSON.stringify(toolCall.function.arguments || {}); - block.partialArgs = (block.partialArgs || "") + argsDelta; - block.arguments = parseStreamingJson(block.partialArgs); + (output.content[open.contentIndex] as ToolCall).arguments = open.arguments.append(argsDelta); stream.push({ type: "toolcall_delta", - contentIndex: toolBlocksByKey.get(key)!, + contentIndex: open.contentIndex, delta: argsDelta, partial: output, }); @@ -757,18 +734,13 @@ async function consumeChatStream( } finishCurrentBlock(currentBlock); - for (const index of toolBlocksByKey.values()) { - const block = output.content[index]; - if (block.type !== "toolCall") continue; - const toolBlock = block as ToolCall & { partialArgs?: string }; - Object.assign(toolBlock, parseToolCallArguments(toolBlock.partialArgs)); - // Finalize in-place and strip the scratch buffer so replay only - // carries parsed arguments. - delete toolBlock.partialArgs; + for (const open of toolCallsByKey.values()) { + const block = output.content[open.contentIndex] as ToolCall; + Object.assign(block, open.arguments.finish()); stream.push({ type: "toolcall_end", - contentIndex: index, - toolCall: toolBlock, + contentIndex: open.contentIndex, + toolCall: block, partial: output, }); } diff --git a/packages/ai/src/api/openai-codex-responses.ts b/packages/ai/src/api/openai-codex-responses.ts index 90a599e66..5165e1ccf 100644 --- a/packages/ai/src/api/openai-codex-responses.ts +++ b/packages/ai/src/api/openai-codex-responses.ts @@ -497,11 +497,6 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons stream.push({ type: "done", reason: output.stopReason, message: output }); stream.end(); } catch (error) { - for (const block of output.content) { - // Streaming scratch buffers are only used during parsing; never persist them. - delete (block as { partialJson?: string }).partialJson; - delete (block as { customInput?: unknown }).customInput; - } output.stopReason = options?.signal?.aborted ? "aborted" : "error"; output.errorMessage = formatProviderError(normalizeProviderError(error)); stream.push({ type: "error", reason: output.stopReason, error: output }); diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index a752d304e..0e5b606e6 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -40,7 +40,6 @@ import { AssistantMessageEventStream } from "../utils/event-stream.ts"; 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, @@ -49,6 +48,7 @@ import { import { getProviderEnvValue } from "../utils/provider-env.ts"; import { retryProviderRequest } from "../utils/provider-retry.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; +import { ToolCallArgumentsStream } from "../utils/streaming-json.ts"; import { getSystemMessageText, renderSystemMessageUpdate } from "../utils/text.ts"; import { getDeclaredTools, @@ -58,6 +58,7 @@ import { } from "../utils/transcript.ts"; import { appendGrammarToolInputJsonDelta, + closeGrammarToolInputJson, createGrammarToolInputProperties, type GrammarToolInputJsonBuffer, getGrammarToolInput, @@ -389,15 +390,13 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model); stream.push({ type: "start", partial: output }); - interface StreamingToolCallBlock extends ToolCall { - partialArgs?: string; - customInput?: { - property: string; - jsonBuffer: GrammarToolInputJsonBuffer; - }; - streamIndex?: number; - } - type StreamingBlock = TextContent | ThinkingContent | StreamingToolCallBlock; + // Stream state of a tool call, kept off the content block so it never reaches partials: JSON arguments, or + // the input of a grammar (custom) tool. + type ToolCallInput = + | { readonly kind: "json"; readonly arguments: ToolCallArgumentsStream } + | { readonly kind: "custom"; readonly property: string; readonly buffer: GrammarToolInputJsonBuffer }; + type ToolCallState = { streamIndex: number | undefined; input: ToolCallInput }; + type StreamingBlock = TextContent | ThinkingContent | ToolCall; type StreamingToolCallDelta = { index?: number; id?: string; @@ -409,32 +408,16 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio let textBlock: TextContent | null = null; let thinkingBlock: ThinkingContent | null = null; let hasFinishReason = false; - const toolCallBlocksByIndex = new Map(); - const toolCallBlocksById = new Map(); + const toolCallBlocksByIndex = new Map(); + const toolCallBlocksById = new Map(); + const toolCallStates = new Map(); const blocks = output.content as StreamingBlock[]; const getContentIndex = (block: StreamingBlock) => blocks.indexOf(block); - const getCustomToolCallInput = (block: StreamingToolCallBlock): string => { - const property = block.customInput?.property; - if (property === undefined) return ""; - const value = block.arguments[property]; - return typeof value === "string" ? value : ""; - }; - const appendCustomToolCallInput = ( - block: StreamingToolCallBlock, - nextInput: string, - close: boolean, - ): string | undefined => { - const customInput = block.customInput; - if (!customInput) return undefined; - const delta = appendGrammarToolInputJsonDelta( - customInput.jsonBuffer, - customInput.property, - nextInput, - close, - ); - block.arguments = { [customInput.property]: nextInput }; - return delta; - }; + const customInput = (property: string): ToolCallInput => ({ + kind: "custom", + property, + buffer: { input: "", started: false, closed: false }, + }); const finishBlock = (block: StreamingBlock) => { const contentIndex = getContentIndex(block); if (contentIndex === -1) { @@ -456,8 +439,10 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio partial: output, }); } else if (block.type === "toolCall") { - if (block.customInput) { - const delta = appendCustomToolCallInput(block, getCustomToolCallInput(block), true); + const input = toolCallStates.get(block)!.input; + toolCallStates.delete(block); + if (input.kind === "custom") { + const delta = closeGrammarToolInputJson(input.buffer, input.property, undefined); if (delta !== undefined) { stream.push({ type: "toolcall_delta", @@ -467,13 +452,8 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio }); } } else { - Object.assign(block, parseToolCallArguments(block.partialArgs)); + Object.assign(block, input.arguments.finish()); } - // Finalize in-place and strip the scratch buffers so replay only - // carries parsed arguments. - delete block.partialArgs; - delete block.customInput; - delete block.streamIndex; stream.push({ type: "toolcall_end", contentIndex, @@ -514,18 +494,19 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio // a tool we don't knwo about, we at least have a place to stash our stuff. const customInputProperty = toolCall.custom && !toolCall.function ? (grammarToolInputProperties.get(name) ?? "input") : undefined; - const hasCustomInput = customInputProperty !== undefined; block = { type: "toolCall", id: toolCall.id || "", name, - arguments: hasCustomInput ? { [customInputProperty]: "" } : {}, - partialArgs: hasCustomInput ? undefined : "", - customInput: hasCustomInput - ? { property: customInputProperty, jsonBuffer: { input: "", started: false, closed: false } } - : undefined, - streamIndex, + arguments: customInputProperty !== undefined ? { [customInputProperty]: "" } : {}, }; + toolCallStates.set(block, { + streamIndex, + input: + customInputProperty !== undefined + ? customInput(customInputProperty) + : { kind: "json", arguments: new ToolCallArgumentsStream() }, + }); if (streamIndex !== undefined) { toolCallBlocksByIndex.set(streamIndex, block); } @@ -539,8 +520,9 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio partial: output, }); } - if (streamIndex !== undefined && block.streamIndex === undefined) { - block.streamIndex = streamIndex; + const state = toolCallStates.get(block)!; + if (streamIndex !== undefined && state.streamIndex === undefined) { + state.streamIndex = streamIndex; toolCallBlocksByIndex.set(streamIndex, block); } if (toolCall.id) { @@ -549,16 +531,12 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio if (!block.name && name) { block.name = name; } - if (toolCall.custom && !toolCall.function && !block.customInput) { + if (toolCall.custom && !toolCall.function && state.input.kind !== "custom") { const customInputProperty = grammarToolInputProperties.get(block.name) ?? "input"; block.arguments = { [customInputProperty]: "" }; - block.customInput = { - property: customInputProperty, - jsonBuffer: { input: "", started: false, closed: false }, - }; - delete block.partialArgs; + state.input = customInput(customInputProperty); } - return block; + return { block, state }; }; for await (const chunk of openaiStream) { @@ -660,7 +638,7 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio if (choice?.delta?.tool_calls) { for (const toolCall of choice.delta.tool_calls as StreamingToolCallDelta[]) { - const block = ensureToolCallBlock(toolCall); + const { block, state } = ensureToolCallBlock(toolCall); if (!block.id && toolCall.id) { block.id = toolCall.id; toolCallBlocksById.set(toolCall.id, block); @@ -671,13 +649,14 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio } let delta = ""; - if (toolCall.function?.arguments) { + const input = state.input; + if (toolCall.function?.arguments && input.kind === "json") { delta = toolCall.function.arguments; - block.partialArgs = (block.partialArgs ?? "") + toolCall.function.arguments; - block.arguments = parseStreamingJson(block.partialArgs); - } else if (toolCall.custom?.input) { - const nextInput = getCustomToolCallInput(block) + toolCall.custom.input; - delta = appendCustomToolCallInput(block, nextInput, false) ?? ""; + block.arguments = input.arguments.append(delta); + } else if (toolCall.custom?.input && input.kind === "custom") { + delta = + appendGrammarToolInputJsonDelta(input.buffer, input.property, toolCall.custom.input) ?? ""; + block.arguments = { [input.property]: input.buffer.input }; } stream.push({ type: "toolcall_delta", @@ -717,11 +696,6 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio if (block.type === "thinking") { applyStreamedReasoningDetails(block); } - delete (block as { index?: number }).index; - // Streaming scratch buffers are only used during parsing; never persist them. - delete (block as { partialArgs?: string }).partialArgs; - delete (block as { customInput?: unknown }).customInput; - delete (block as { streamIndex?: number }).streamIndex; } output.stopReason = options?.signal?.aborted ? "aborted" : "error"; output.errorMessage = formatProviderError(normalizeProviderError(error)); diff --git a/packages/ai/src/api/openai-responses-shared.ts b/packages/ai/src/api/openai-responses-shared.ts index 3f6371053..ecc104043 100644 --- a/packages/ai/src/api/openai-responses-shared.ts +++ b/packages/ai/src/api/openai-responses-shared.ts @@ -33,12 +33,13 @@ import type { } from "../types.ts"; import type { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { shortHash } from "../utils/hash.ts"; -import { parseStreamingJson, parseToolCallArguments } from "../utils/json-parse.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; +import { ToolCallArgumentsStream } from "../utils/streaming-json.ts"; import { getSystemMessageText, renderSystemMessageUpdate } from "../utils/text.ts"; import { resolveTranscript, resolveTranscriptTools, type TranscriptToolSupport } from "../utils/transcript.ts"; import { appendGrammarToolInputJsonDelta, + closeGrammarToolInputJson, type GrammarToolInputJsonBuffer, getGrammarToolInput, getJsonSchemaToolParameters, @@ -478,33 +479,24 @@ export function convertResponsesTools(tools: readonly Tool[], options?: ConvertR // Stream processing // ============================================================================= -type StreamingToolCall = ToolCall & { - partialJson?: string; - customInput?: { - property: string; - jsonBuffer: GrammarToolInputJsonBuffer; - }; -}; - -function getCustomToolCallInput(block: StreamingToolCall): string { - const property = block.customInput?.property; - if (property === undefined) return ""; - const value = block.arguments[property]; - return typeof value === "string" ? value : ""; -} - -function appendCustomToolCallInput(block: StreamingToolCall, nextInput: string, close: boolean): string | undefined { - const customInput = block.customInput; - if (!customInput) return undefined; - const delta = appendGrammarToolInputJsonDelta(customInput.jsonBuffer, customInput.property, nextInput, close); - block.arguments = { [customInput.property]: nextInput }; - return delta; -} +/** + * Stream state of a tool call, kept in its output slot and never on the content block, so it never reaches partials: + * the JSON arguments of a function call, or the input of a custom (grammar) tool call. + */ +type ToolCallInput = + | { readonly kind: "json"; readonly arguments: ToolCallArgumentsStream } + | { + readonly kind: "custom"; + readonly property: string; + readonly buffer: GrammarToolInputJsonBuffer; + /** JSON text of the item's initial input, sent with the first delta. */ + unsent: string; + }; type ResponsesOutputSlot = | { type: "thinking"; block: ThinkingContent; contentIndex: number } | { type: "text"; block: TextContent; contentIndex: number } - | { type: "toolCall"; block: StreamingToolCall; contentIndex: number }; + | { type: "toolCall"; block: ToolCall; contentIndex: number; input: ToolCallInput }; type ToolCallOutputSlot = Extract; @@ -517,6 +509,8 @@ export async function processResponsesStream( ): Promise { let sawTerminalResponseEvent = false; const outputSlots = new Map(); + // Tool calls whose output_item.done has not arrived yet. + const unfinishedToolCalls = new Set(); const reasoningBlocksById = new Map(); const applyMessagePhaseStopReason = (item: ResponseOutputItem): void => { if (item.type === "message" && item.phase === "final_answer") { @@ -562,19 +556,21 @@ export async function processResponsesStream( return slot; } if (item.type === "function_call") { - const block: StreamingToolCall = { + const args = new ToolCallArgumentsStream(); + const block: ToolCall = { type: "toolCall", id: `${item.call_id}|${item.id}`, name: item.name, - arguments: {}, + arguments: item.arguments ? args.append(item.arguments) : {}, ...(item.namespace !== undefined ? { namespace: item.namespace } : {}), - partialJson: item.arguments || "", }; output.content.push(block); + unfinishedToolCalls.add(block); const slot = { type: "toolCall", block, contentIndex: output.content.length - 1, + input: { kind: "json", arguments: args }, } satisfies ResponsesOutputSlot; outputSlots.set(outputIndex, slot); stream.push({ type: "toolcall_start", contentIndex: slot.contentIndex, partial: output }); @@ -583,22 +579,20 @@ export async function processResponsesStream( if (item.type === "custom_tool_call") { const inputProperty = options?.grammarToolInputProperties?.get(item.name) ?? "input"; const input = item.input || ""; - const block: StreamingToolCall = { + const block: ToolCall = { type: "toolCall", id: `${item.call_id}|${item.id}`, name: item.name, arguments: { [inputProperty]: input }, ...(item.namespace !== undefined ? { namespace: item.namespace } : {}), - customInput: { - property: inputProperty, - jsonBuffer: { input: "", started: false, closed: false }, - }, }; output.content.push(block); + unfinishedToolCalls.add(block); const slot = { type: "toolCall", block, contentIndex: output.content.length - 1, + input: customInput(inputProperty, input), } satisfies ResponsesOutputSlot; outputSlots.set(outputIndex, slot); stream.push({ type: "toolcall_start", contentIndex: slot.contentIndex, partial: output }); @@ -732,32 +726,23 @@ export async function processResponsesStream( }); } else if (event.type === "response.function_call_arguments.delta") { const slot = getSlot(event.output_index, "toolCall"); - if (!slot || slot.block.partialJson === undefined) continue; - slot.block.partialJson += event.delta; - slot.block.arguments = parseStreamingJson(slot.block.partialJson); + if (slot?.input.kind !== "json") continue; + slot.block.arguments = slot.input.arguments.append(event.delta); pushToolCallDelta(slot, event.delta); } else if (event.type === "response.function_call_arguments.done") { const slot = getSlot(event.output_index, "toolCall"); - if (!slot || slot.block.partialJson === undefined) continue; - const previousPartialJson = slot.block.partialJson; - slot.block.partialJson = event.arguments; - slot.block.arguments = parseStreamingJson(slot.block.partialJson); - - if (event.arguments.startsWith(previousPartialJson)) { - const delta = event.arguments.slice(previousPartialJson.length); - if (delta.length > 0) pushToolCallDelta(slot, delta); - } + if (slot?.input.kind !== "json") continue; + const replaced = slot.input.arguments.replace(event.arguments); + slot.block.arguments = replaced.arguments; + if (replaced.delta) pushToolCallDelta(slot, replaced.delta); } else if (event.type === "response.custom_tool_call_input.delta") { const slot = getSlot(event.output_index, "toolCall"); - if (!slot || !slot.block.customInput) continue; - pushToolCallDelta( - slot, - appendCustomToolCallInput(slot.block, getCustomToolCallInput(slot.block) + event.delta, false), - ); + if (slot?.input.kind !== "custom") continue; + pushToolCallDelta(slot, appendCustomInput(slot.block, slot.input, event.delta)); } else if (event.type === "response.custom_tool_call_input.done") { const slot = getSlot(event.output_index, "toolCall"); - if (!slot || !slot.block.customInput) continue; - pushToolCallDelta(slot, appendCustomToolCallInput(slot.block, event.input, true)); + if (slot?.input.kind !== "custom") continue; + pushToolCallDelta(slot, closeCustomInput(slot.block, slot.input, event.input)); } else if (event.type === "response.output_item.done") { const item = event.item; applyMessagePhaseStopReason(item); @@ -786,16 +771,11 @@ export async function processResponsesStream( partial: output, }); outputSlots.delete(event.output_index); - } else if ( - item.type === "function_call" && - slot?.type === "toolCall" && - slot.block.partialJson !== undefined - ) { - Object.assign(slot.block, parseToolCallArguments(item.arguments || slot.block.partialJson)); + } else if (item.type === "function_call" && slot?.type === "toolCall" && slot.input.kind === "json") { + if (item.arguments) slot.input.arguments.replace(item.arguments); + Object.assign(slot.block, slot.input.arguments.finish()); if (item.namespace !== undefined) slot.block.namespace = item.namespace; - // Finalize in-place and strip the scratch buffer so replay only - // carries parsed arguments. - delete slot.block.partialJson; + unfinishedToolCalls.delete(slot.block); stream.push({ type: "toolcall_end", contentIndex: slot.contentIndex, @@ -803,13 +783,10 @@ export async function processResponsesStream( partial: output, }); outputSlots.delete(event.output_index); - } else if (item.type === "custom_tool_call" && slot?.type === "toolCall" && slot.block.customInput) { - pushToolCallDelta( - slot, - appendCustomToolCallInput(slot.block, item.input ?? getCustomToolCallInput(slot.block), true), - ); + } else if (item.type === "custom_tool_call" && slot?.type === "toolCall" && slot.input.kind === "custom") { + pushToolCallDelta(slot, closeCustomInput(slot.block, slot.input, item.input)); if (item.namespace !== undefined) slot.block.namespace = item.namespace; - delete slot.block.customInput; + unfinishedToolCalls.delete(slot.block); stream.push({ type: "toolcall_end", contentIndex: slot.contentIndex, @@ -840,20 +817,47 @@ export async function processResponsesStream( } // The agent runs every tool call in the final message. Refuse to hand over calls whose // output_item.done never arrived: their arguments may be cut off or mixed up, e.g. when a - // non-compliant server omits output_index. Finished calls have their scratch buffers removed. + // non-compliant server omits output_index. if (output.stopReason === "toolUse") { - for (const block of output.content) { - if (block.type !== "toolCall") continue; - const toolCall = block as StreamingToolCall; - if (toolCall.partialJson !== undefined || toolCall.customInput !== undefined) { - throw new Error( - `OpenAI Responses stream completed with an unfinished tool call: ${toolCall.name} (${toolCall.id})`, - ); - } + for (const toolCall of unfinishedToolCalls) { + throw new Error( + `OpenAI Responses stream completed with an unfinished tool call: ${toolCall.name} (${toolCall.id})`, + ); } } } +type CustomInput = Extract; + +/** Stream state of a custom tool call that starts with `initialInput`. */ +function customInput(property: string, initialInput: string): CustomInput { + const buffer = { input: "", started: false, closed: false }; + return { + kind: "custom", + property, + buffer, + unsent: appendGrammarToolInputJsonDelta(buffer, property, initialInput) ?? "", + }; +} + +/** Append to a streaming custom tool input; returns the matching piece of its JSON arguments text. */ +function appendCustomInput(block: ToolCall, input: CustomInput, delta: string): string | undefined { + return sendCustomInput(block, input, appendGrammarToolInputJsonDelta(input.buffer, input.property, delta)); +} + +/** Close a custom tool input with the provider's complete input, if it sent one. */ +function closeCustomInput(block: ToolCall, input: CustomInput, finalInput: string | undefined): string | undefined { + return sendCustomInput(block, input, closeGrammarToolInputJson(input.buffer, input.property, finalInput)); +} + +function sendCustomInput(block: ToolCall, input: CustomInput, jsonDelta: string | undefined): string | undefined { + block.arguments = { [input.property]: input.buffer.input }; + if (input.unsent === "") return jsonDelta; + const sent = input.unsent + (jsonDelta ?? ""); + input.unsent = ""; + return sent; +} + function mapStopReason( status: OpenAI.Responses.ResponseStatus | undefined, incompleteReason?: string, diff --git a/packages/ai/src/api/openai-responses.ts b/packages/ai/src/api/openai-responses.ts index 8ddaa6944..c29e6adfb 100644 --- a/packages/ai/src/api/openai-responses.ts +++ b/packages/ai/src/api/openai-responses.ts @@ -249,12 +249,6 @@ export const stream: StreamFunction<"openai-responses", OpenAIResponsesOptions> stream.push({ type: "done", reason: output.stopReason, message: output }); stream.end(); } catch (error) { - for (const block of output.content) { - delete (block as { index?: number }).index; - // Streaming scratch buffers are only used during parsing; never persist them. - delete (block as { partialJson?: string }).partialJson; - delete (block as { customInput?: unknown }).customInput; - } output.stopReason = options?.signal?.aborted ? "aborted" : "error"; const errorMessage = formatProviderError( normalizeProviderError(error), diff --git a/packages/ai/src/api/pi-messages.ts b/packages/ai/src/api/pi-messages.ts index 83ddc06c7..59b54567e 100644 --- a/packages/ai/src/api/pi-messages.ts +++ b/packages/ai/src/api/pi-messages.ts @@ -28,8 +28,9 @@ import { appendAssistantMessageDiagnostic, createAssistantMessageDiagnostic } fr import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord, providerHeadersToRecord } from "../utils/headers.ts"; import { getIdentityHeaders } from "../utils/identity.ts"; -import { parseStreamingJson } from "../utils/json-parse.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; +import { ToolCallArgumentsStream } from "../utils/streaming-json.ts"; +import { ownedSlice } from "../utils/text.ts"; export interface PiMessagesOptions extends StreamOptions { reasoning?: ThinkingLevel; @@ -122,7 +123,7 @@ function parsePiMessagesErrorBody(body: string): PiMessagesErrorBody | undefined function truncateDiagnosticString(value: string): string { const maxLength = 8192; - return value.length > maxLength ? `${value.slice(0, maxLength)}…` : value; + return value.length > maxLength ? `${ownedSlice(value, 0, maxLength)}…` : value; } function formatPiMessagesResponseError( @@ -191,7 +192,7 @@ function createEventConverter(model: Model<"pi-messages">) { stopReason: "pending", timestamp: Date.now(), }; - const toolJson = new Map(); + const toolArguments = new Map(); return (event: PiMessagesEvent): AssistantMessageEvent => { switch (event.type) { @@ -258,18 +259,17 @@ function createEventConverter(model: Model<"pi-messages">) { name: event.toolName, arguments: {}, }; - toolJson.set(event.contentIndex, ""); + toolArguments.set(event.contentIndex, new ToolCallArgumentsStream()); break; case "toolcall_delta": { - const json = `${toolJson.get(event.contentIndex) ?? ""}${event.delta}`; - toolJson.set(event.contentIndex, json); - (partial.content[event.contentIndex] as ToolCall).arguments = - parseStreamingJson(json); + const args = toolArguments.get(event.contentIndex); + if (args !== undefined) + (partial.content[event.contentIndex] as ToolCall).arguments = args.append(event.delta); break; } case "toolcall_end": Object.assign(partial.content[event.contentIndex]!, event.toolCall); - toolJson.delete(event.contentIndex); + toolArguments.delete(event.contentIndex); return { type: "toolcall_end", contentIndex: event.contentIndex, diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index d58b2a259..54a0345f1 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -41,6 +41,7 @@ export * from "./utils/identity.ts"; export * from "./utils/json-parse.ts"; export * from "./utils/overflow.ts"; export * from "./utils/retry.ts"; +export * from "./utils/streaming-json.ts"; export { contentText, getSystemMessageText, renderSystemMessageUpdate } from "./utils/text.ts"; export * from "./utils/transcript.ts"; export * from "./utils/typebox-helpers.ts"; diff --git a/packages/ai/src/providers/faux.ts b/packages/ai/src/providers/faux.ts index 0f4188e60..87797dbfa 100644 --- a/packages/ai/src/providers/faux.ts +++ b/packages/ai/src/providers/faux.ts @@ -19,6 +19,7 @@ import type { Usage, } from "../types.ts"; import { createAssistantMessageEventStream } from "../utils/event-stream.ts"; +import { ToolCallArgumentsStream } from "../utils/streaming-json.ts"; import { getSystemMessageText } from "../utils/text.ts"; const DEFAULT_API = "faux"; @@ -425,6 +426,8 @@ async function streamWithDeltas( partial.content = [...partial.content, { type: "toolCall", id: block.id, name: block.name, arguments: {} }]; stream.push({ type: "toolcall_start", contentIndex: index, partial: { ...partial } }); + // Partial arguments come from the same parser real providers use, so consumers see them grow. + const toolArguments = new ToolCallArgumentsStream(); for (const chunk of splitStringByTokenSize(JSON.stringify(block.arguments), minTokenSize, maxTokenSize)) { await scheduleChunk(chunk, tokensPerSecond); if (signal?.aborted) { @@ -433,6 +436,7 @@ async function streamWithDeltas( stream.end(aborted); return; } + (partial.content[index] as ToolCall).arguments = toolArguments.append(chunk); stream.push({ type: "toolcall_delta", contentIndex: index, delta: chunk, partial: { ...partial } }); } (partial.content[index] as ToolCall).arguments = block.arguments; diff --git a/packages/ai/src/providers/radius-config.ts b/packages/ai/src/providers/radius-config.ts index a2715aaec..4bf82cf9f 100644 --- a/packages/ai/src/providers/radius-config.ts +++ b/packages/ai/src/providers/radius-config.ts @@ -1,5 +1,6 @@ import type { OAuthCredential } from "../auth/types.ts"; import type { Model, ThinkingLevelMap } from "../types.ts"; +import { ownedSlice } from "../utils/text.ts"; export const DEFAULT_RADIUS_GATEWAY = "https://radius.pi.dev"; @@ -74,7 +75,7 @@ export function getRadiusModels(providerId: string, credential: OAuthCredential function truncateHttpBody(body: string): string { const trimmed = body.trim(); - return trimmed.length > 512 ? `${trimmed.slice(0, 512)}…` : trimmed; + return trimmed.length > 512 ? `${ownedSlice(trimmed, 0, 512)}…` : trimmed; } export async function loadRadiusGatewayConfig( diff --git a/packages/ai/src/telemetry.ts b/packages/ai/src/telemetry.ts index 683339939..7969bfc23 100644 --- a/packages/ai/src/telemetry.ts +++ b/packages/ai/src/telemetry.ts @@ -10,6 +10,7 @@ import { } from "@earendil-works/pi-telemetry"; import type { LazyStreamObserver } from "./api/lazy.ts"; import type { Api, Model } from "./types.ts"; +import { ownedSlice } from "./utils/text.ts"; const provider = { type: "string", description: "Provider identifier.", required: true } as const; const model = { type: "string", description: "Requested model identifier.", required: true } as const; @@ -191,7 +192,7 @@ export function observeModelRequest( if (message.stopReason === "error") { span.setStatus({ status: "error", - error: { name: "provider_error", message: message.errorMessage?.slice(0, 500) ?? "" }, + error: { name: "provider_error", message: ownedSlice(message.errorMessage ?? "", 0, 500) }, }); } requestDuration.record(performance.now() - started, { ...attributes, stop: message.stopReason }, span); diff --git a/packages/ai/src/utils/error-body.ts b/packages/ai/src/utils/error-body.ts index cb8c1859a..d35f51a0e 100644 --- a/packages/ai/src/utils/error-body.ts +++ b/packages/ai/src/utils/error-body.ts @@ -13,6 +13,8 @@ // Anthropic / `@google/genai` happy path where the SDK already folded the body // into the message, so providers can preserve it without double-printing. +import { ownedSlice } from "./text.ts"; + export const MAX_PROVIDER_ERROR_BODY_CHARS = 4000; export interface NormalizedProviderError { @@ -136,7 +138,7 @@ export function formatProviderError(norm: NormalizedProviderError, prefix?: stri export function truncateErrorText(text: string, maxChars: number): string { if (text.length <= maxChars) return text; - return `${text.slice(0, maxChars)}... [truncated ${text.length - maxChars} chars]`; + return `${ownedSlice(text, 0, maxChars)}... [truncated ${text.length - maxChars} chars]`; } export function safeJsonStringify(value: unknown): string { diff --git a/packages/ai/src/utils/streaming-json.ts b/packages/ai/src/utils/streaming-json.ts new file mode 100644 index 000000000..9f71ae7ec --- /dev/null +++ b/packages/ai/src/utils/streaming-json.ts @@ -0,0 +1,559 @@ +/** + * Incremental, tolerant parsing of JSON text that arrives in pieces, such as streamed tool-call arguments. + * + * `StreamingJsonParser.append()` reads each piece once, so a stream costs time and memory linear in its length, and + * `snapshot()` returns the value of the text read so far. Snapshots are plain JSON values and never change once + * returned: the parser copies the containers that are still open (the "open path") into every new snapshot and shares + * completed containers and strings. The rules for incomplete text follow `parseStreamingJson()`: + * + * - An unterminated string shows the characters read so far; an unterminated escape is held back until complete. + * - An unterminated container shows the members and elements read so far. + * - A member whose key or value has not started is omitted. + * - An unterminated literal shows its value once it is a prefix of `true`, `false` or `null`; an unterminated number + * shows its value when the digits read so far form a number (an exponent without digits is ignored). + * + * Malformed text is read leniently, the way `repairJson()` and `parseStreamingJson()` treat it: raw control characters + * in strings are kept, an invalid escape such as `\d` stays literal text, missing commas and colons are tolerated, + * values in invalid positions and unknown words are dropped, and text after the root value is ignored. The parser never + * throws. Callers that need the exact value of the finished text parse it once, e.g. with `parseToolCallArguments()`. + * + * Differences from `parseStreamingJson()` on incomplete text: trailing whitespace inside an unterminated string is + * kept (it was trimmed), a string that contains a raw control character or an invalid escape stays visible while it + * streams (it was dropped until the document was complete), and `NaN` and `Infinity` words are dropped. + * + * `append()` also returns the changes the piece made, for a writer that keeps its own copy of the value and applies + * them instead of re-assigning snapshots: `set` when a key, an array item or a finished value appears (a new string is + * set once with its first text, a container empty), `append` when the piece extended an open string. Unterminated + * numbers and literals are not reported until they end. A container nested more than 32 levels deep is reported as one + * `set` of its whole value when it closes, so paths stay short. + * + * Cost: copying the open path costs its member and element count. Each `append()` earns copy credit proportional to its + * length plus a constant; `snapshot()` spends it and returns the previous snapshot while the open path costs more than + * the credit. So a document that keeps a very large container open (thousands of elements) shows a preview that lags + * a little behind the text, and no input makes the total work grow faster than the text. + */ + +import type { JsonValue, ToolCall } from "../types.ts"; +import { parseToolCallArguments } from "./json-parse.ts"; + +type JsonContainer = { [key: string]: JsonValue } | JsonValue[]; + +/** Where a value sits in its parent; absent for a value in an invalid position, which is read but dropped. */ +type Slot = + | { readonly kind: "root" } + | { readonly kind: "member"; readonly key: string } + | { readonly kind: "element"; readonly index: number }; + +/** A path from the root value: member keys and array indices. */ +export type JsonPath = readonly (string | number)[]; + +/** One change a piece of text made to the value; see the module comment. */ +export type JsonChange = + | { readonly kind: "set"; readonly path: JsonPath; readonly value: JsonValue } + | { readonly kind: "append"; readonly path: JsonPath; readonly text: string }; + +/** A change still being extended by the piece being read. */ +type OpenChange = + | { kind: "set"; readonly path: JsonPath; value: JsonValue } + | { kind: "append"; readonly path: JsonPath; text: string }; + +/** An open object or array. Its `container` is never handed out while open; snapshots get copies. */ +type Frame = { + readonly container: JsonContainer; + readonly slot: Slot | undefined; + /** Where the container sits, when its changes are reported. */ + readonly path: JsonPath | undefined; + /** Whether changes inside it are reported; when not, its whole value is reported as it closes. */ + readonly reportsInside: boolean; + /** Members or elements; what a copy of the container costs. */ + size: number; + /** Objects: the key read last, where the member value goes. */ + key: string | undefined; + phase: "key" | "colon" | "value" | "comma"; +}; + +/** An unterminated string. */ +type OpenString = { + readonly target: "key" | "value"; + readonly slot: Slot | undefined; + text: string; + /** Escape read so far: "" for none, otherwise a backslash and up to five more characters. */ + escape: string; + /** Where a value string sits, when its changes are reported. */ + readonly path: JsonPath | undefined; + /** The change this string extends, while it is the newest change of the piece. */ + change: OpenChange | undefined; +}; + +// Changes are reported at most this deep, so a path costs O(1) to build. +const MAX_REPORTED_DEPTH = 32; + +const ROOT_SLOT: Slot = { kind: "root" }; +// Copy credit per append and per appended character. The floor keeps small documents fresh at every snapshot. +const CREDIT_PER_APPEND = 256; +const CREDIT_PER_CHARACTER = 8; +// An unterminated number longer than this is not shown until it ends, so `snapshot()` stays cheap. +const MAX_PENDING_SCALAR_LENGTH = 64; +const NUMBER = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/; +const NUMBER_WITHOUT_EXPONENT_DIGITS = /^(-?(?:0|[1-9]\d*)(?:\.\d+)?)[eE][+-]?$/; +const LITERALS: readonly [string, JsonValue][] = [ + ["true", true], + ["false", false], + ["null", null], +]; + +const QUOTE = 0x22; +const BACKSLASH = 0x5c; + +/** Incremental, tolerant JSON parser; see the module comment for its rules and costs. */ +export class StreamingJsonParser { + #root: JsonValue | undefined = undefined; + #done = false; + readonly #stack: Frame[] = []; + #string: OpenString | undefined = undefined; + #scalar = ""; + #scalarSlot: Slot | undefined = undefined; + /** Sum of `size + 1` over the open path: what a fresh snapshot copies. */ + #pathCost = 0; + #credit = 0; + #dirty = false; + #snapshot: JsonValue | undefined = undefined; + /** Changes of the piece being read. */ + #changes: OpenChange[] = []; + + /** + * Read the next piece of text; returns the changes it made, in order. Costs time linear in `text.length`. + */ + append(text: string): readonly JsonChange[] { + this.#credit += CREDIT_PER_APPEND + CREDIT_PER_CHARACTER * text.length; + this.#changes = []; + if (this.#string !== undefined) this.#string.change = undefined; + let index = 0; + while (index < text.length && !this.#done) { + const open = this.#string; + if (open !== undefined) index = this.#readString(open, text, index); + else if (this.#scalar !== "") index = this.#readScalar(text, index); + else this.#structural(text[index++]!); + } + return this.#changes; + } + + /** + * The value of the text read so far: `undefined` before a value starts, otherwise a plain JSON value that never + * changes afterwards. Returns the previous snapshot while the text read since did not change the value, or while a + * fresh copy of the open path is not yet paid for (see the module comment). + */ + snapshot(): JsonValue | undefined { + if (!this.#dirty) return this.#snapshot; + const pending = this.#pendingScalar(); + const cost = this.#pathCost + (pending === undefined ? 0 : 1); + if (cost > this.#credit) return this.#snapshot; + this.#credit -= cost; + this.#dirty = false; + this.#snapshot = this.#copyOpenPath(pending); + return this.#snapshot; + } + + /** Read string characters from `start` until the string ends or the text does; returns the next index. */ + #readString(open: OpenString, text: string, start: number): number { + let runStart = start; + for (let index = start; index < text.length; index++) { + const code = text.charCodeAt(index); + if (open.escape !== "") { + if (this.#continueEscape(open, text[index]!)) { + runStart = index + 1; + continue; + } + // Not part of the escape: read it as a normal string character below. + runStart = index; + } + if (code === QUOTE) { + if (index > runStart) this.#appendString(open, text.slice(runStart, index)); + this.#endString(open); + return index + 1; + } + if (code === BACKSLASH) { + if (index > runStart) this.#appendString(open, text.slice(runStart, index)); + open.escape = "\\"; + runStart = index + 1; + } + } + if (text.length > runStart) this.#appendString(open, text.slice(runStart)); + return text.length; + } + + /** Read number or literal characters from `start` up to the next delimiter; returns the delimiter's index. */ + #readScalar(text: string, start: number): number { + let end = start; + while (end < text.length && !isDelimiter(text[end]!)) end++; + if (end > start) { + this.#scalar += text.slice(start, end); + if (this.#scalarSlot !== undefined) this.#dirty = true; + } + if (end < text.length) this.#endScalar(); + return end; + } + + /** Handle one character outside strings. */ + #structural(char: string): void { + switch (char) { + case " ": + case "\t": + case "\n": + case "\r": + return; + case '"': + this.#startString(); + return; + case "{": + case "[": + this.#openContainer(char === "{" ? {} : []); + return; + case "}": + case "]": + this.#closeContainer(); + return; + case ":": { + const frame = this.#stack.at(-1); + if (frame !== undefined && !Array.isArray(frame.container) && frame.phase === "colon") + frame.phase = "value"; + return; + } + case ",": { + const frame = this.#stack.at(-1); + if (frame !== undefined) frame.phase = Array.isArray(frame.container) ? "value" : "key"; + return; + } + default: + this.#scalar = char; + this.#scalarSlot = this.#valueSlot(); + if (this.#scalarSlot !== undefined) this.#dirty = true; + } + } + + /** Where a value starting now goes, or `undefined` when this position takes no value. */ + #valueSlot(): Slot | undefined { + const frame = this.#stack.at(-1); + if (frame === undefined) return this.#root === undefined ? ROOT_SLOT : undefined; + if (Array.isArray(frame.container)) return { kind: "element", index: frame.container.length }; + // A missing colon is tolerated: `{"a" 1}` reads as `{"a": 1}`. + if ((frame.phase === "value" || frame.phase === "colon") && frame.key !== undefined) { + return { kind: "member", key: frame.key }; + } + return undefined; + } + + #startString(): void { + const frame = this.#stack.at(-1); + // A missing comma is tolerated: `{"a": 1 "b": 2}` reads the second member. + if ( + frame !== undefined && + !Array.isArray(frame.container) && + (frame.phase === "key" || frame.phase === "comma") + ) { + this.#string = { target: "key", slot: undefined, text: "", escape: "", path: undefined, change: undefined }; + return; + } + const slot = this.#valueSlot(); + const path = this.#reportedPath(slot); + this.#string = { target: "value", slot, text: "", escape: "", path, change: undefined }; + this.#place(slot, ""); + if (path !== undefined) this.#string.change = this.#report({ kind: "set", path, value: "" }); + } + + /** The path of a value at `slot` in the innermost open container, when its changes are reported. */ + #reportedPath(slot: Slot | undefined): JsonPath | undefined { + if (slot === undefined) return undefined; + if (slot.kind === "root") return []; + const frame = this.#stack.at(-1)!; + if (!frame.reportsInside) return undefined; + return [...frame.path!, slot.kind === "member" ? slot.key : slot.index]; + } + + #report(change: OpenChange): OpenChange { + this.#changes.push(change); + return change; + } + + /** Continue an escape with `char`; returns false when `char` is not part of it and must be read normally. */ + #continueEscape(open: OpenString, char: string): boolean { + if (open.escape === "\\") { + const decoded = SIMPLE_ESCAPES[char]; + if (decoded !== undefined) { + open.escape = ""; + this.#appendString(open, decoded); + return true; + } + if (char === "u") { + open.escape = "\\u"; + return true; + } + // Invalid escape: keep the backslash as text and read the character normally, as repairJson() does. + open.escape = ""; + this.#appendString(open, "\\"); + return false; + } + if (isHexDigit(char)) { + open.escape += char; + if (open.escape.length < 6) return true; + const decoded = String.fromCharCode(Number.parseInt(open.escape.slice(2), 16)); + open.escape = ""; + this.#appendString(open, decoded); + return true; + } + // `\u` without four hex digits: keep it as text and read the character normally, as repairJson() does. + const literal = open.escape; + open.escape = ""; + this.#appendString(open, literal); + return false; + } + + #appendString(open: OpenString, text: string): void { + open.text += text; + if (open.target !== "value") return; + this.#place(open.slot, open.text); + if (open.path === undefined) return; + // Extend this string's newest change while nothing came after it; otherwise report an append. + const change = open.change; + if (change !== undefined && this.#changes.at(-1) === change) { + if (change.kind === "set") change.value = (change.value as string) + text; + else change.text += text; + return; + } + open.change = this.#report({ kind: "append", path: open.path, text }); + } + + #endString(open: OpenString): void { + this.#string = undefined; + const frame = this.#stack.at(-1); + if (open.target === "key") { + if (frame === undefined) return; + frame.key = open.text; + frame.phase = "colon"; + return; + } + this.#valueEnded(frame); + } + + #endScalar(): void { + const token = this.#scalar; + const slot = this.#scalarSlot; + this.#scalar = ""; + this.#scalarSlot = undefined; + const value = scalarValue(token); + if (value !== undefined) { + this.#place(slot, value); + const path = this.#reportedPath(slot); + if (path !== undefined) this.#report({ kind: "set", path, value }); + } + this.#valueEnded(this.#stack.at(-1)); + } + + /** The value of the unterminated scalar at the end of the text, if it shows. */ + #pendingScalar(): { readonly slot: Slot; readonly value: JsonValue } | undefined { + const slot = this.#scalarSlot; + const token = this.#scalar; + if (slot === undefined || token === "" || token.length > MAX_PENDING_SCALAR_LENGTH) return undefined; + const value = pendingScalarValue(token); + return value === undefined ? undefined : { slot, value }; + } + + #openContainer(container: JsonContainer): void { + const slot = this.#valueSlot(); + const path = this.#reportedPath(slot); + this.#place(slot, container); + this.#stack.push({ + container, + slot, + path, + reportsInside: path !== undefined && path.length < MAX_REPORTED_DEPTH, + size: 0, + key: undefined, + phase: Array.isArray(container) ? "value" : "key", + }); + this.#pathCost += 1; + this.#dirty = true; + // A fresh container: the working one keeps changing, and a change never does. + if (path !== undefined) this.#report({ kind: "set", path, value: Array.isArray(container) ? [] : {} }); + } + + #closeContainer(): void { + const frame = this.#stack.pop(); + // A stray closer outside any container is ignored. + if (frame === undefined) return; + this.#pathCost -= frame.size + 1; + this.#dirty = true; + // Too deep to report inside: the whole value, final now that it is closed. + if (frame.path !== undefined && !frame.reportsInside) { + this.#report({ kind: "set", path: frame.path, value: frame.container }); + } + this.#valueEnded(this.#stack.at(-1)); + } + + /** A value in `frame`, or the root value when there is no frame, has ended. */ + #valueEnded(frame: Frame | undefined): void { + if (frame === undefined) { + // Text after the root value is ignored, but only once a root value exists. + if (this.#root !== undefined) this.#done = true; + return; + } + frame.phase = "comma"; + } + + /** Set the value at `slot` in the open path; nothing for a value in an invalid position. */ + #place(slot: Slot | undefined, value: JsonValue): void { + if (slot === undefined) return; + this.#dirty = true; + if (slot.kind === "root") { + this.#root = value; + return; + } + const frame = this.#stack.at(-1)!; + if (slot.kind === "element") { + const elements = frame.container as JsonValue[]; + if (slot.index === elements.length) { + frame.size++; + this.#pathCost++; + } + elements[slot.index] = value; + return; + } + const members = frame.container as { [key: string]: JsonValue }; + if (!Object.hasOwn(members, slot.key)) { + frame.size++; + this.#pathCost++; + } + setMember(members, slot.key, value); + } + + /** A fresh snapshot: copies of the open containers, innermost first, each holding the copy of its open child. */ + #copyOpenPath(pending: { readonly slot: Slot; readonly value: JsonValue } | undefined): JsonValue | undefined { + if (this.#stack.length === 0) return pending?.slot.kind === "root" ? pending.value : this.#root; + let child: JsonValue | undefined; + let childSlot: Slot | undefined; + for (let depth = this.#stack.length - 1; depth >= 0; depth--) { + const frame = this.#stack[depth]!; + const copy: JsonContainer = Array.isArray(frame.container) ? frame.container.slice() : { ...frame.container }; + if (depth === this.#stack.length - 1 && pending !== undefined) setSlot(copy, pending.slot, pending.value); + if (child !== undefined && childSlot !== undefined) setSlot(copy, childSlot, child); + child = copy; + childSlot = frame.slot; + } + // The outermost open container is the root unless it sits in an invalid position. + return childSlot?.kind === "root" ? child : this.#root; + } +} + +/** + * The arguments of one streaming tool call: the raw text, for the one full parse when the call ends, and a + * `StreamingJsonParser` for the partial arguments in between. Providers keep one per call in their own stream state, + * never on the content block, so the raw text never reaches partial messages or transcripts. + */ +export class ToolCallArgumentsStream { + #text = ""; + #parser = new StreamingJsonParser(); + #snapshot: JsonValue | undefined = undefined; + #arguments: ToolCall["arguments"] = {}; + + /** The raw argument text received so far. */ + get text(): string { + return this.#text; + } + + /** Append a piece of argument text; returns the partial arguments, a new object whenever they changed. */ + append(delta: string): ToolCall["arguments"] { + this.#text += delta; + this.#parser.append(delta); + return this.#partial(); + } + + /** + * Take the provider's complete argument text. When it continues the text so far, returns the missing piece as + * `delta`; otherwise starts over with `text`. Either way returns the partial arguments of `text`. + */ + replace(text: string): { readonly delta: string | undefined; readonly arguments: ToolCall["arguments"] } { + if (text.startsWith(this.#text)) { + const delta = text.slice(this.#text.length); + return { delta, arguments: delta === "" ? this.#partial() : this.append(delta) }; + } + this.#text = ""; + this.#parser = new StreamingJsonParser(); + return { delta: undefined, arguments: this.append(text) }; + } + + /** The final arguments: one full parse of the complete text, which reports malformed arguments. */ + finish(): Pick { + return parseToolCallArguments(this.#text); + } + + #partial(): ToolCall["arguments"] { + const snapshot = this.#parser.snapshot(); + if (snapshot === this.#snapshot) return this.#arguments; + this.#snapshot = snapshot; + // Tool arguments are an object; any other partial value shows as no arguments yet. + this.#arguments = + typeof snapshot === "object" && snapshot !== null && !Array.isArray(snapshot) + ? (snapshot as ToolCall["arguments"]) + : {}; + return this.#arguments; + } +} + +const SIMPLE_ESCAPES: Readonly> = { + '"': '"', + "\\": "\\", + "/": "/", + b: "\b", + f: "\f", + n: "\n", + r: "\r", + t: "\t", +}; + +function isDelimiter(char: string): boolean { + return ( + char === " " || + char === "\t" || + char === "\n" || + char === "\r" || + char === "," || + char === ":" || + char === "{" || + char === "}" || + char === "[" || + char === "]" || + char === '"' + ); +} + +function isHexDigit(char: string): boolean { + return (char >= "0" && char <= "9") || (char >= "a" && char <= "f") || (char >= "A" && char <= "F"); +} + +/** The value of a complete number or literal token; `undefined` for anything else, which is dropped. */ +function scalarValue(token: string): JsonValue | undefined { + for (const [literal, value] of LITERALS) if (token === literal) return value; + return NUMBER.test(token) ? Number(token) : undefined; +} + +/** The value an unterminated number or literal token shows. */ +function pendingScalarValue(token: string): JsonValue | undefined { + for (const [literal, value] of LITERALS) if (literal.startsWith(token)) return value; + if (NUMBER.test(token)) return Number(token); + const mantissa = NUMBER_WITHOUT_EXPONENT_DIGITS.exec(token); + return mantissa === null ? undefined : Number(mantissa[1]); +} + +function setSlot(container: JsonContainer, slot: Slot, value: JsonValue): void { + if (slot.kind === "element") (container as JsonValue[])[slot.index] = value; + else if (slot.kind === "member") setMember(container as { [key: string]: JsonValue }, slot.key, value); +} + +function setMember(members: { [key: string]: JsonValue }, key: string, value: JsonValue): void { + // `__proto__` must become an own member, as in JSON.parse(), not the prototype. + if (key === "__proto__") { + Object.defineProperty(members, key, { value, writable: true, enumerable: true, configurable: true }); + } else { + members[key] = value; + } +} diff --git a/packages/ai/src/utils/text.ts b/packages/ai/src/utils/text.ts index 5798ed346..8f90a1e9a 100644 --- a/packages/ai/src/utils/text.ts +++ b/packages/ai/src/utils/text.ts @@ -38,3 +38,13 @@ export function renderSystemMessageUpdate(message: SystemMessage): string { } return parts.join("\n\n"); } + +/** + * `text.slice(start, end)` as a string of its own. V8 represents a slice of 13 or more characters as a view that keeps + * all of `text` alive, so a short excerpt kept from a large text (a 500-character error message cut from a megabyte + * response body) would keep the whole text alive as long as the excerpt lives. Appending a character and slicing it off + * again makes V8 copy just the slice. + */ +export function ownedSlice(text: string, start: number, end = text.length): string { + return `${text.slice(start, end)} `.slice(0, -1); +} diff --git a/packages/ai/test/bedrock-stream-state.test.ts b/packages/ai/test/bedrock-stream-state.test.ts new file mode 100644 index 000000000..190a8389b --- /dev/null +++ b/packages/ai/test/bedrock-stream-state.test.ts @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { allocatedBytes } from "./memory-helpers.ts"; + +// #9265: Bedrock kept `index`, `partialJson` and the encrypted reasoning bytes on the content blocks while streaming, +// and re-parsed the accumulated tool-call arguments on every delta. + +const bedrockMock = vi.hoisted(() => ({ streamEvents: undefined as (() => Iterable) | undefined })); + +vi.mock("@aws-sdk/client-bedrock-runtime", () => { + class BedrockRuntimeServiceException extends Error {} + + class BedrockRuntimeClient { + send(): Promise { + const events = bedrockMock.streamEvents; + if (!events) return Promise.reject(new Error("mock send")); + return Promise.resolve({ + $metadata: { httpStatusCode: 200 }, + stream: (async function* () { + yield* events(); + })(), + }); + } + } + + class ConverseStreamCommand { + readonly input: unknown; + + constructor(input: unknown) { + this.input = input; + } + } + + return { + BedrockRuntimeClient, + BedrockRuntimeServiceException, + ConverseStreamCommand, + StopReason: { + END_TURN: "end_turn", + STOP_SEQUENCE: "stop_sequence", + MAX_TOKENS: "max_tokens", + TOOL_USE: "tool_use", + }, + CachePointType: { DEFAULT: "default" }, + CacheTTL: { ONE_HOUR: "ONE_HOUR" }, + ConversationRole: { ASSISTANT: "assistant", USER: "user" }, + ImageFormat: { JPEG: "jpeg", PNG: "png", GIF: "gif", WEBP: "webp" }, + ToolResultStatus: { ERROR: "error", SUCCESS: "success" }, + }; +}); + +import { stream as streamBedrock } from "../src/api/bedrock-converse-stream.ts"; +import type { AssistantMessage, Model } from "../src/types.ts"; +import { normalizeContext } from "../src/utils/transcript.ts"; + +const model: Model<"bedrock-converse-stream"> = { + id: "global.anthropic.claude-test", + name: "Claude Test", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 64_000, +}; + +const context = normalizeContext({ messages: [{ role: "user", content: "write", timestamp: 0 }] }); + +const content = Array.from({ length: 20_000 }, (_, line) => `const line${line} = "value ${line}"; // \\ done\n`).join( + "", +); + +function writeCall(size: number) { + const args = { path: "/src/file.ts", content: content.slice(0, size) }; + return { args, text: JSON.stringify(args) }; +} + +/** Encrypted reasoning, then a `write` tool call whose arguments arrive in 32-character deltas. */ +function* events(text: string): Generator { + yield { messageStart: { role: "assistant" } }; + yield { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { reasoningContent: { redactedContent: new Uint8Array([1, 2]) } }, + }, + }; + yield { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { reasoningContent: { redactedContent: new Uint8Array([3]) } }, + }, + }; + yield { contentBlockStop: { contentBlockIndex: 0 } }; + yield { contentBlockStart: { contentBlockIndex: 1, start: { toolUse: { toolUseId: "tool_1", name: "write" } } } }; + for (let index = 0; index < text.length; index += 32) { + yield { + contentBlockDelta: { contentBlockIndex: 1, delta: { toolUse: { input: text.slice(index, index + 32) } } }, + }; + } + yield { contentBlockStop: { contentBlockIndex: 1 } }; + yield { messageStop: { stopReason: "tool_use" } }; +} + +const BLOCK_KEYS: Record = { + text: ["type", "text", "textSignature"], + thinking: ["type", "thinking", "thinkingSignature", "redacted"], + toolCall: ["type", "id", "name", "arguments", "argumentsError"], +}; + +function expectNoStreamState(message: AssistantMessage): void { + for (const block of message.content) { + for (const key of Object.keys(block)) expect(BLOCK_KEYS[block.type], `${block.type}.${key}`).toContain(key); + } +} + +describe("Bedrock stream state", () => { + beforeEach(() => { + bedrockMock.streamEvents = undefined; + }); + + it("keeps stream state off partial blocks and finishes the tool call with its exact arguments", async () => { + const { args, text } = writeCall(5_000); + bedrockMock.streamEvents = () => events(text); + let shown = -1; + const stream = streamBedrock(model, context); + for await (const event of stream) { + if (event.type === "done" || event.type === "error") continue; + expectNoStreamState(event.partial); + if (event.type !== "toolcall_delta") continue; + const call = event.partial.content[event.contentIndex]; + const partial = call?.type === "toolCall" ? call.arguments.content : undefined; + if (typeof partial !== "string") continue; + expect(args.content.startsWith(partial)).toBe(true); + expect(partial.length).toBeGreaterThanOrEqual(shown); + shown = partial.length; + } + const message = await stream.result(); + expect(message.stopReason, message.errorMessage).toBe("toolUse"); + expectNoStreamState(message); + expect(message.content).toEqual([ + { type: "thinking", thinking: "[Reasoning redacted]", thinkingSignature: "AQID", redacted: true }, + { type: "toolCall", id: "tool_1", name: "write", arguments: args }, + ]); + }); + + it("allocates linearly in the argument text", async () => { + const run = async (size: number) => { + const { text } = writeCall(size); + bedrockMock.streamEvents = () => events(text); + return allocatedBytes(() => streamBedrock(model, context).result()); + }; + const small = await run(25_000); + const large = await run(250_000); + expect(large / small).toBeLessThan(25); + expect(large / writeCall(250_000).text.length).toBeLessThan(5_000); + }); +}); diff --git a/packages/ai/test/constrained-sampling.test.ts b/packages/ai/test/constrained-sampling.test.ts index 8eb9ea8da..d31807413 100644 --- a/packages/ai/test/constrained-sampling.test.ts +++ b/packages/ai/test/constrained-sampling.test.ts @@ -3,6 +3,7 @@ import { Type } from "typebox"; import { describe, expect, it } from "vitest"; import { appendGrammarToolInputJsonDelta, + closeGrammarToolInputJson, makeStrictJsonSchema, resolveJsonSchemaStrictSampling, } from "../src/api/constrained-sampling.ts"; @@ -14,6 +15,7 @@ import { import type { AssistantMessage, Model, Tool, ToolCall } from "../src/types.ts"; import { AssistantMessageEventStream } from "../src/utils/event-stream.ts"; import { normalizeContext } from "../src/utils/transcript.ts"; +import { allocatedBytes } from "./memory-helpers.ts"; function makeModel(): Model<"openai-responses"> { return { @@ -292,14 +294,81 @@ describe("constrained tool sampling", () => { it("keeps grammar input JSON deltas append-only", () => { const buffer = { input: "", started: false, closed: false }; - const first = appendGrammarToolInputJsonDelta(buffer, "payload", 'a"', false); - const second = appendGrammarToolInputJsonDelta(buffer, "payload", 'a"\nb', true); + const first = appendGrammarToolInputJsonDelta(buffer, "payload", 'a"'); + const second = closeGrammarToolInputJson(buffer, "payload", 'a"\nb'); expect(JSON.parse(`${first}${second}`)).toEqual({ payload: 'a"\nb' }); - expect(appendGrammarToolInputJsonDelta(buffer, "payload", 'a"\nb', true)).toBeUndefined(); - expect(() => appendGrammarToolInputJsonDelta(buffer, "payload", "changed", true)).toThrow( + expect(closeGrammarToolInputJson(buffer, "payload", 'a"\nb')).toBeUndefined(); + expect(() => closeGrammarToolInputJson(buffer, "payload", "changed")).toThrow( 'grammar tool input for property "payload" changed after it was closed', ); + expect(() => appendGrammarToolInputJsonDelta(buffer, "payload", "x")).toThrow( + 'grammar tool input for property "payload" changed after it was closed', + ); + }); + + it("rejects a final grammar input that does not continue the streamed input", () => { + const buffer = { input: "", started: false, closed: false }; + appendGrammarToolInputJsonDelta(buffer, "payload", "abc"); + expect(() => closeGrammarToolInputJson(buffer, "payload", "abd")).toThrow( + 'grammar tool input for property "payload" changed non-monotonically', + ); + }); + + // #9265 + it("streams custom tool input in time and memory linear in its length", async () => { + const run = async (size: number) => { + const deltas = Array.from({ length: size / 32 }, (_, index) => + `chunk ${index} "quoted" \\ \n`.padEnd(32, "x"), + ); + const events = [ + { + type: "response.output_item.added", + output_index: 0, + item: { type: "custom_tool_call", call_id: "call_1", id: "ctc_1", name: "sample_tool", input: "" }, + }, + ...deltas.map((delta) => ({ + type: "response.custom_tool_call_input.delta", + output_index: 0, + item_id: "ctc_1", + delta, + })), + { type: "response.custom_tool_call_input.done", output_index: 0, item_id: "ctc_1", input: deltas.join("") }, + { + type: "response.output_item.done", + output_index: 0, + item: { + type: "custom_tool_call", + call_id: "call_1", + id: "ctc_1", + name: "sample_tool", + input: deltas.join(""), + }, + }, + { + type: "response.completed", + response: { status: "completed", usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 } }, + }, + ] as ResponseStreamEvent[]; + const output = makeOutput(); + const stream = new AssistantMessageEventStream(); + const jsonDeltas: string[] = []; + const bytes = await allocatedBytes(async () => { + const consumed = (async () => { + for await (const event of stream) if (event.type === "toolcall_delta") jsonDeltas.push(event.delta); + })(); + await processResponsesStream(iterateEvents(events), output, stream, makeModel(), { + grammarToolInputProperties: new Map([["sample_tool", "payload"]]), + }); + stream.end(); + await consumed; + }); + expect(JSON.parse(jsonDeltas.join(""))).toEqual({ payload: deltas.join("") }); + return bytes; + }; + const small = await run(25_600); + const large = await run(256_000); + expect(large / small).toBeLessThan(25); }); it("starts custom Responses tool calls with their initial input", async () => { diff --git a/packages/ai/test/data/json-test-suite.json b/packages/ai/test/data/json-test-suite.json new file mode 100644 index 000000000..4dbe7d575 --- /dev/null +++ b/packages/ai/test/data/json-test-suite.json @@ -0,0 +1,322 @@ +{ + "source": "https://github.com/nst/JSONTestSuite (test_parsing, files up to 2 KB)", + "license": "MIT License, Copyright (c) 2016 Nicolas Seriot", + "files": { + "i_number_double_huge_neg_exp.json": "WzEyMy40NTZlLTc4OV0=", + "i_number_huge_exp.json": "WzAuNGUwMDY2OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk2OTk5OTk5OTAwNl0=", + "i_number_neg_int_huge_exp.json": "Wy0xZSs5OTk5XQ==", + "i_number_pos_double_huge_exp.json": "WzEuNWUrOTk5OV0=", + "i_number_real_neg_overflow.json": "Wy0xMjMxMjNlMTAwMDAwXQ==", + "i_number_real_pos_overflow.json": "WzEyMzEyM2UxMDAwMDBd", + "i_number_real_underflow.json": "WzEyM2UtMTAwMDAwMDBd", + "i_number_too_big_neg_int.json": "Wy0xMjMxMjMxMjMxMjMxMjMxMjMxMjMxMjMxMjMxMjNd", + "i_number_too_big_pos_int.json": "WzEwMDAwMDAwMDAwMDAwMDAwMDAwMF0=", + "i_number_very_big_negative_int.json": "Wy0yMzc0NjIzNzQ2NzMyNzY4OTQyNzk4MzI3NDk4MzI0MjM0Nzk4MjMyNDYzMjc4NDZd", + "i_object_key_lone_2nd_surrogate.json": "eyJcdURGQUEiOjB9", + "i_string_1st_surrogate_but_2nd_missing.json": "WyJcdURBREEiXQ==", + "i_string_1st_valid_surrogate_2nd_invalid.json": "WyJcdUQ4ODhcdTEyMzQiXQ==", + "i_string_UTF-16LE_with_BOM.json": "//5bACIA6QAiAF0A", + "i_string_UTF-8_invalid_sequence.json": "WyLml6XRiPoiXQ==", + "i_string_UTF8_surrogate_U+D800.json": "WyLtoIAiXQ==", + "i_string_incomplete_surrogate_and_escape_valid.json": "WyJcdUQ4MDBcbiJd", + "i_string_incomplete_surrogate_pair.json": "WyJcdURkMWVhIl0=", + "i_string_incomplete_surrogates_escape_valid.json": "WyJcdUQ4MDBcdUQ4MDBcbiJd", + "i_string_invalid_lonely_surrogate.json": "WyJcdWQ4MDAiXQ==", + "i_string_invalid_surrogate.json": "WyJcdWQ4MDBhYmMiXQ==", + "i_string_invalid_utf-8.json": "WyL/Il0=", + "i_string_inverted_surrogates_U+1D11E.json": "WyJcdURkMWVcdUQ4MzQiXQ==", + "i_string_iso_latin_1.json": "WyLpIl0=", + "i_string_lone_second_surrogate.json": "WyJcdURGQUEiXQ==", + "i_string_lone_utf8_continuation_byte.json": "WyKBIl0=", + "i_string_not_in_unicode_range.json": "WyL0v7+/Il0=", + "i_string_overlong_sequence_2_bytes.json": "WyLAryJd", + "i_string_overlong_sequence_6_bytes.json": "WyL8g7+/v78iXQ==", + "i_string_overlong_sequence_6_bytes_null.json": "WyL8gICAgIAiXQ==", + "i_string_truncated-utf-8.json": "WyLg/yJd", + "i_string_utf16BE_no_BOM.json": "AFsAIgDpACIAXQ==", + "i_string_utf16LE_no_BOM.json": "WwAiAOkAIgBdAA==", + "i_structure_500_nested_arrays.json": "W1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tdXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXQ==", + "i_structure_UTF-8_BOM_empty_object.json": "77u/e30=", + "n_array_1_true_without_comma.json": "WzEgdHJ1ZV0=", + "n_array_a_invalid_utf8.json": "W2HlXQ==", + "n_array_colon_instead_of_comma.json": "WyIiOiAxXQ==", + "n_array_comma_after_close.json": "WyIiXSw=", + "n_array_comma_and_number.json": "WywxXQ==", + "n_array_double_comma.json": "WzEsLDJd", + "n_array_double_extra_comma.json": "WyJ4IiwsXQ==", + "n_array_extra_close.json": "WyJ4Il1d", + "n_array_extra_comma.json": "WyIiLF0=", + "n_array_incomplete.json": "WyJ4Ig==", + "n_array_incomplete_invalid_value.json": "W3g=", + "n_array_inner_array_no_comma.json": "WzNbNF1d", + "n_array_invalid_utf8.json": "W/9d", + "n_array_items_separated_by_semicolon.json": "WzE6Ml0=", + "n_array_just_comma.json": "Wyxd", + "n_array_just_minus.json": "Wy1d", + "n_array_missing_value.json": "WyAgICwgIiJd", + "n_array_newlines_unclosed.json": "WyJhIiwKNAosMSw=", + "n_array_number_and_comma.json": "WzEsXQ==", + "n_array_number_and_several_commas.json": "WzEsLF0=", + "n_array_spaces_vertical_tab_formfeed.json": "WyILYSJcZl0=", + "n_array_star_inside.json": "Wypd", + "n_array_unclosed.json": "WyIi", + "n_array_unclosed_trailing_comma.json": "WzEs", + "n_array_unclosed_with_new_lines.json": "WzEsCjEKLDE=", + "n_array_unclosed_with_object_inside.json": "W3t9", + "n_incomplete_false.json": "W2ZhbHNd", + "n_incomplete_null.json": "W251bF0=", + "n_incomplete_true.json": "W3RydV0=", + "n_multidigit_number_then_00.json": "MTIzAA==", + "n_number_++.json": "WysrMTIzNF0=", + "n_number_+1.json": "WysxXQ==", + "n_number_+Inf.json": "WytJbmZd", + "n_number_-01.json": "Wy0wMV0=", + "n_number_-1.0..json": "Wy0xLjAuXQ==", + "n_number_-2..json": "Wy0yLl0=", + "n_number_-NaN.json": "Wy1OYU5d", + "n_number_.-1.json": "Wy4tMV0=", + "n_number_.2e-3.json": "Wy4yZS0zXQ==", + "n_number_0.1.2.json": "WzAuMS4yXQ==", + "n_number_0.3e+.json": "WzAuM2UrXQ==", + "n_number_0.3e.json": "WzAuM2Vd", + "n_number_0.e1.json": "WzAuZTFd", + "n_number_0_capital_E+.json": "WzBFK10=", + "n_number_0_capital_E.json": "WzBFXQ==", + "n_number_0e+.json": "WzBlK10=", + "n_number_0e.json": "WzBlXQ==", + "n_number_1.0e+.json": "WzEuMGUrXQ==", + "n_number_1.0e-.json": "WzEuMGUtXQ==", + "n_number_1.0e.json": "WzEuMGVd", + "n_number_1_000.json": "WzEgMDAwLjBd", + "n_number_1eE2.json": "WzFlRTJd", + "n_number_2.e+3.json": "WzIuZSszXQ==", + "n_number_2.e-3.json": "WzIuZS0zXQ==", + "n_number_2.e3.json": "WzIuZTNd", + "n_number_9.e+.json": "WzkuZStd", + "n_number_Inf.json": "W0luZl0=", + "n_number_NaN.json": "W05hTl0=", + "n_number_U+FF11_fullwidth_digit_one.json": "W++8kV0=", + "n_number_expression.json": "WzErMl0=", + "n_number_hex_1_digit.json": "WzB4MV0=", + "n_number_hex_2_digits.json": "WzB4NDJd", + "n_number_infinity.json": "W0luZmluaXR5XQ==", + "n_number_invalid+-.json": "WzBlKy0xXQ==", + "n_number_invalid-negative-real.json": "Wy0xMjMuMTIzZm9vXQ==", + "n_number_invalid-utf-8-in-bigger-int.json": "WzEyM+Vd", + "n_number_invalid-utf-8-in-exponent.json": "WzFlMeVd", + "n_number_invalid-utf-8-in-int.json": "WzDlXQo=", + "n_number_minus_infinity.json": "Wy1JbmZpbml0eV0=", + "n_number_minus_sign_with_trailing_garbage.json": "Wy1mb29d", + "n_number_minus_space_1.json": "Wy0gMV0=", + "n_number_neg_int_starting_with_zero.json": "Wy0wMTJd", + "n_number_neg_real_without_int_part.json": "Wy0uMTIzXQ==", + "n_number_neg_with_garbage_at_end.json": "Wy0xeF0=", + "n_number_real_garbage_after_e.json": "WzFlYV0=", + "n_number_real_with_invalid_utf8_after_e.json": "WzFl5V0=", + "n_number_real_without_fractional_part.json": "WzEuXQ==", + "n_number_starting_with_dot.json": "Wy4xMjNd", + "n_number_with_alpha.json": "WzEuMmEtM10=", + "n_number_with_alpha_char.json": "WzEuODAxMTY3MDAzMzM3NjUxNEgtMzA4XQ==", + "n_number_with_leading_zero.json": "WzAxMl0=", + "n_object_bad_value.json": "WyJ4IiwgdHJ1dGhd", + "n_object_bracket_key.json": "e1s6ICJ4In0K", + "n_object_comma_instead_of_colon.json": "eyJ4IiwgbnVsbH0=", + "n_object_double_colon.json": "eyJ4Ijo6ImIifQ==", + "n_object_emoji.json": "e/Cfh6jwn4etfQ==", + "n_object_garbage_at_end.json": "eyJhIjoiYSIgMTIzfQ==", + "n_object_key_with_single_quotes.json": "e2tleTogJ3ZhbHVlJ30=", + "n_object_lone_continuation_byte_in_key_and_trailing_comma.json": "eyK5IjoiMCIsfQ==", + "n_object_missing_colon.json": "eyJhIiBifQ==", + "n_object_missing_key.json": "ezoiYiJ9", + "n_object_missing_semicolon.json": "eyJhIiAiYiJ9", + "n_object_missing_value.json": "eyJhIjo=", + "n_object_no-colon.json": "eyJhIg==", + "n_object_non_string_key.json": "ezE6MX0=", + "n_object_non_string_key_but_huge_number_instead.json": "ezk5OTlFOTk5OToxfQ==", + "n_object_repeated_null_null.json": "e251bGw6bnVsbCxudWxsOm51bGx9", + "n_object_several_trailing_commas.json": "eyJpZCI6MCwsLCwsfQ==", + "n_object_single_quote.json": "eydhJzowfQ==", + "n_object_trailing_comma.json": "eyJpZCI6MCx9", + "n_object_trailing_comment.json": "eyJhIjoiYiJ9LyoqLw==", + "n_object_trailing_comment_open.json": "eyJhIjoiYiJ9LyoqLy8=", + "n_object_trailing_comment_slash_open.json": "eyJhIjoiYiJ9Ly8=", + "n_object_trailing_comment_slash_open_incomplete.json": "eyJhIjoiYiJ9Lw==", + "n_object_two_commas_in_a_row.json": "eyJhIjoiYiIsLCJjIjoiZCJ9", + "n_object_unquoted_key.json": "e2E6ICJiIn0=", + "n_object_unterminated-value.json": "eyJhIjoiYQ==", + "n_object_with_single_string.json": "eyAiZm9vIiA6ICJiYXIiLCAiYSIgfQ==", + "n_object_with_trailing_garbage.json": "eyJhIjoiYiJ9Iw==", + "n_single_space.json": "IA==", + "n_string_1_surrogate_then_escape.json": "WyJcdUQ4MDBcIl0=", + "n_string_1_surrogate_then_escape_u.json": "WyJcdUQ4MDBcdSJd", + "n_string_1_surrogate_then_escape_u1.json": "WyJcdUQ4MDBcdTEiXQ==", + "n_string_1_surrogate_then_escape_u1x.json": "WyJcdUQ4MDBcdTF4Il0=", + "n_string_accentuated_char_no_quotes.json": "W8OpXQ==", + "n_string_backslash_00.json": "WyJcACJd", + "n_string_escape_x.json": "WyJceDAwIl0=", + "n_string_escaped_backslash_bad.json": "WyJcXFwiXQ==", + "n_string_escaped_ctrl_char_tab.json": "WyJcCSJd", + "n_string_escaped_emoji.json": "WyJc8J+MgCJd", + "n_string_incomplete_escape.json": "WyJcIl0=", + "n_string_incomplete_escaped_character.json": "WyJcdTAwQSJd", + "n_string_incomplete_surrogate.json": "WyJcdUQ4MzRcdURkIl0=", + "n_string_incomplete_surrogate_escape_invalid.json": "WyJcdUQ4MDBcdUQ4MDBceCJd", + "n_string_invalid-utf-8-in-escape.json": "WyJcdeUiXQ==", + "n_string_invalid_backslash_esc.json": "WyJcYSJd", + "n_string_invalid_unicode_escape.json": "WyJcdXFxcXEiXQ==", + "n_string_invalid_utf8_after_escape.json": "WyJc5SJd", + "n_string_leading_uescaped_thinspace.json": "W1x1MDAyMCJhc2QiXQ==", + "n_string_no_quotes_with_bad_escape.json": "W1xuXQ==", + "n_string_single_doublequote.json": "Ig==", + "n_string_single_quote.json": "WydzaW5nbGUgcXVvdGUnXQ==", + "n_string_single_string_no_double_quotes.json": "YWJj", + "n_string_start_escape_unclosed.json": "WyJc", + "n_string_unescaped_ctrl_char.json": "WyJhAGEiXQ==", + "n_string_unescaped_newline.json": "WyJuZXcKbGluZSJd", + "n_string_unescaped_tab.json": "WyIJIl0=", + "n_string_unicode_CapitalU.json": "IlxVQTY2RCI=", + "n_string_with_trailing_garbage.json": "IiJ4", + "n_structure_U+2060_word_joined.json": "W+KBoF0=", + "n_structure_UTF8_BOM_no_data.json": "77u/", + "n_structure_angle_bracket_..json": "PC4+", + "n_structure_angle_bracket_null.json": "WzxudWxsPl0=", + "n_structure_array_trailing_garbage.json": "WzFdeA==", + "n_structure_array_with_extra_array_close.json": "WzFdXQ==", + "n_structure_array_with_unclosed_string.json": "WyJhc2Rd", + "n_structure_ascii-unicode-identifier.json": "YcOl", + "n_structure_capitalized_True.json": "W1RydWVd", + "n_structure_close_unopened_array.json": "MV0=", + "n_structure_comma_instead_of_closing_brace.json": "eyJ4IjogdHJ1ZSw=", + "n_structure_double_array.json": "W11bXQ==", + "n_structure_end_array.json": "XQ==", + "n_structure_incomplete_UTF8_BOM.json": "77t7fQ==", + "n_structure_lone-invalid-utf-8.json": "5Q==", + "n_structure_lone-open-bracket.json": "Ww==", + "n_structure_no_data.json": "", + "n_structure_null-byte-outside-string.json": "WwBd", + "n_structure_number_with_trailing_garbage.json": "MkA=", + "n_structure_object_followed_by_closing_object.json": "e319", + "n_structure_object_unclosed_no_value.json": "eyIiOg==", + "n_structure_object_with_comment.json": "eyJhIjovKmNvbW1lbnQqLyJiIn0=", + "n_structure_object_with_trailing_garbage.json": "eyJhIjogdHJ1ZX0gIngi", + "n_structure_open_array_apostrophe.json": "Wyc=", + "n_structure_open_array_comma.json": "Wyw=", + "n_structure_open_array_open_object.json": "W3s=", + "n_structure_open_array_open_string.json": "WyJh", + "n_structure_open_array_string.json": "WyJhIg==", + "n_structure_open_object.json": "ew==", + "n_structure_open_object_close_array.json": "e10=", + "n_structure_open_object_comma.json": "eyw=", + "n_structure_open_object_open_array.json": "e1s=", + "n_structure_open_object_open_string.json": "eyJh", + "n_structure_open_object_string_with_apostrophes.json": "eydhJw==", + "n_structure_open_open.json": "WyJce1siXHtbIlx7WyJcew==", + "n_structure_single_eacute.json": "6Q==", + "n_structure_single_star.json": "Kg==", + "n_structure_trailing_#.json": "eyJhIjoiYiJ9I3t9", + "n_structure_uescaped_LF_before_string.json": "W1x1MDAwQSIiXQ==", + "n_structure_unclosed_array.json": "WzE=", + "n_structure_unclosed_array_partial_null.json": "WyBmYWxzZSwgbnVs", + "n_structure_unclosed_array_unfinished_false.json": "WyB0cnVlLCBmYWxz", + "n_structure_unclosed_array_unfinished_true.json": "WyBmYWxzZSwgdHJ1", + "n_structure_unclosed_object.json": "eyJhc2QiOiJhc2Qi", + "n_structure_unicode-identifier.json": "w6U=", + "n_structure_whitespace_U+2060_word_joiner.json": "W+KBoF0=", + "n_structure_whitespace_formfeed.json": "Wwxd", + "y_array_arraysWithSpaces.json": "W1tdICAgXQ==", + "y_array_empty-string.json": "WyIiXQ==", + "y_array_empty.json": "W10=", + "y_array_ending_with_newline.json": "WyJhIl0=", + "y_array_false.json": "W2ZhbHNlXQ==", + "y_array_heterogeneous.json": "W251bGwsIDEsICIxIiwge31d", + "y_array_null.json": "W251bGxd", + "y_array_with_1_and_newline.json": "WzEKXQ==", + "y_array_with_leading_space.json": "IFsxXQ==", + "y_array_with_several_null.json": "WzEsbnVsbCxudWxsLG51bGwsMl0=", + "y_array_with_trailing_space.json": "WzJdIA==", + "y_number.json": "WzEyM2U2NV0=", + "y_number_0e+1.json": "WzBlKzFd", + "y_number_0e1.json": "WzBlMV0=", + "y_number_after_space.json": "WyA0XQ==", + "y_number_double_close_to_zero.json": "Wy0wLjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMV0K", + "y_number_int_with_exp.json": "WzIwZTFd", + "y_number_minus_zero.json": "Wy0wXQ==", + "y_number_negative_int.json": "Wy0xMjNd", + "y_number_negative_one.json": "Wy0xXQ==", + "y_number_negative_zero.json": "Wy0wXQ==", + "y_number_real_capital_e.json": "WzFFMjJd", + "y_number_real_capital_e_neg_exp.json": "WzFFLTJd", + "y_number_real_capital_e_pos_exp.json": "WzFFKzJd", + "y_number_real_exponent.json": "WzEyM2U0NV0=", + "y_number_real_fraction_exponent.json": "WzEyMy40NTZlNzhd", + "y_number_real_neg_exp.json": "WzFlLTJd", + "y_number_real_pos_exponent.json": "WzFlKzJd", + "y_number_simple_int.json": "WzEyM10=", + "y_number_simple_real.json": "WzEyMy40NTY3ODld", + "y_object.json": "eyJhc2QiOiJzZGYiLCAiZGZnIjoiZmdoIn0=", + "y_object_basic.json": "eyJhc2QiOiJzZGYifQ==", + "y_object_duplicated_key.json": "eyJhIjoiYiIsImEiOiJjIn0=", + "y_object_duplicated_key_and_value.json": "eyJhIjoiYiIsImEiOiJiIn0=", + "y_object_empty.json": "e30=", + "y_object_empty_key.json": "eyIiOjB9", + "y_object_escaped_null_in_key.json": "eyJmb29cdTAwMDBiYXIiOiA0Mn0=", + "y_object_extreme_numbers.json": "eyAibWluIjogLTEuMGUrMjgsICJtYXgiOiAxLjBlKzI4IH0=", + "y_object_long_strings.json": "eyJ4IjpbeyJpZCI6ICJ4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4In1dLCAiaWQiOiAieHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eCJ9", + "y_object_simple.json": "eyJhIjpbXX0=", + "y_object_string_unicode.json": "eyJ0aXRsZSI6Ilx1MDQxZlx1MDQzZVx1MDQzYlx1MDQ0Mlx1MDQzZVx1MDQ0MFx1MDQzMCBcdTA0MTdcdTA0MzVcdTA0M2NcdTA0M2JcdTA0MzVcdTA0M2FcdTA0M2VcdTA0M2ZcdTA0MzAiIH0=", + "y_object_with_newlines.json": "ewoiYSI6ICJiIgp9", + "y_string_1_2_3_bytes_UTF-8_sequences.json": "WyJcdTAwNjBcdTAxMmFcdTEyQUIiXQ==", + "y_string_accepted_surrogate_pair.json": "WyJcdUQ4MDFcdWRjMzciXQ==", + "y_string_accepted_surrogate_pairs.json": "WyJcdWQ4M2RcdWRlMzlcdWQ4M2RcdWRjOGQiXQ==", + "y_string_allowed_escapes.json": "WyJcIlxcXC9cYlxmXG5cclx0Il0=", + "y_string_backslash_and_u_escaped_zero.json": "WyJcXHUwMDAwIl0=", + "y_string_backslash_doublequotes.json": "WyJcIiJd", + "y_string_comments.json": "WyJhLypiKi9jLypkLy9lIl0=", + "y_string_double_escape_a.json": "WyJcXGEiXQ==", + "y_string_double_escape_n.json": "WyJcXG4iXQ==", + "y_string_escaped_control_character.json": "WyJcdTAwMTIiXQ==", + "y_string_escaped_noncharacter.json": "WyJcdUZGRkYiXQ==", + "y_string_in_array.json": "WyJhc2QiXQ==", + "y_string_in_array_with_leading_space.json": "WyAiYXNkIl0=", + "y_string_last_surrogates_1_and_2.json": "WyJcdURCRkZcdURGRkYiXQ==", + "y_string_nbsp_uescaped.json": "WyJuZXdcdTAwQTBsaW5lIl0=", + "y_string_nonCharacterInUTF-8_U+10FFFF.json": "WyL0j7+/Il0=", + "y_string_nonCharacterInUTF-8_U+FFFF.json": "WyLvv78iXQ==", + "y_string_null_escape.json": "WyJcdTAwMDAiXQ==", + "y_string_one-byte-utf-8.json": "WyJcdTAwMmMiXQ==", + "y_string_pi.json": "WyLPgCJd", + "y_string_reservedCharacterInUTF-8_U+1BFFF.json": "WyLwm7+/Il0=", + "y_string_simple_ascii.json": "WyJhc2QgIl0=", + "y_string_space.json": "IiAi", + "y_string_surrogates_U+1D11E_MUSICAL_SYMBOL_G_CLEF.json": "WyJcdUQ4MzRcdURkMWUiXQ==", + "y_string_three-byte-utf-8.json": "WyJcdTA4MjEiXQ==", + "y_string_two-byte-utf-8.json": "WyJcdTAxMjMiXQ==", + "y_string_u+2028_line_sep.json": "WyLigKgiXQ==", + "y_string_u+2029_par_sep.json": "WyLigKkiXQ==", + "y_string_uEscape.json": "WyJcdTAwNjFcdTMwYWZcdTMwRUFcdTMwYjkiXQ==", + "y_string_uescaped_newline.json": "WyJuZXdcdTAwMEFsaW5lIl0=", + "y_string_unescaped_char_delete.json": "WyJ/Il0=", + "y_string_unicode.json": "WyJcdUE2NkQiXQ==", + "y_string_unicodeEscapedBackslash.json": "WyJcdTAwNUMiXQ==", + "y_string_unicode_2.json": "WyLijYLjiLTijYIiXQ==", + "y_string_unicode_U+10FFFE_nonchar.json": "WyJcdURCRkZcdURGRkUiXQ==", + "y_string_unicode_U+1FFFE_nonchar.json": "WyJcdUQ4M0ZcdURGRkUiXQ==", + "y_string_unicode_U+200B_ZERO_WIDTH_SPACE.json": "WyJcdTIwMEIiXQ==", + "y_string_unicode_U+2064_invisible_plus.json": "WyJcdTIwNjQiXQ==", + "y_string_unicode_U+FDD0_nonchar.json": "WyJcdUZERDAiXQ==", + "y_string_unicode_U+FFFE_nonchar.json": "WyJcdUZGRkUiXQ==", + "y_string_unicode_escaped_double_quote.json": "WyJcdTAwMjIiXQ==", + "y_string_utf8.json": "WyLigqzwnYSeIl0=", + "y_string_with_del_character.json": "WyJhf2EiXQ==", + "y_structure_lonely_false.json": "ZmFsc2U=", + "y_structure_lonely_int.json": "NDI=", + "y_structure_lonely_negative_real.json": "LTAuMQ==", + "y_structure_lonely_null.json": "bnVsbA==", + "y_structure_lonely_string.json": "ImFzZCI=", + "y_structure_lonely_true.json": "dHJ1ZQ==", + "y_structure_string_empty.json": "IiI=", + "y_structure_trailing_newline.json": "WyJhIl0K", + "y_structure_true_in_array.json": "W3RydWVd", + "y_structure_whitespace_array.json": "IFtdIA==" + } +} \ No newline at end of file diff --git a/packages/ai/test/error-body.test.ts b/packages/ai/test/error-body.test.ts index 15cf5e11c..c2824e8be 100644 --- a/packages/ai/test/error-body.test.ts +++ b/packages/ai/test/error-body.test.ts @@ -5,8 +5,15 @@ // Bedrock ServiceException), plus the non-Error fallback, truncation, the empty // parsed-body edge case, and the formatProviderError compose helper. +import { getHeapStatistics } from "node:v8"; import { describe, expect, it } from "vitest"; -import { formatProviderError, MAX_PROVIDER_ERROR_BODY_CHARS, normalizeProviderError } from "../src/utils/error-body.ts"; +import { + formatProviderError, + MAX_PROVIDER_ERROR_BODY_CHARS, + normalizeProviderError, + truncateErrorText, +} from "../src/utils/error-body.ts"; +import { collectGarbage } from "./memory-helpers.ts"; describe("normalizeProviderError", () => { it("extracts status and body from a Mistral-shaped error", () => { @@ -224,3 +231,20 @@ describe("formatProviderError", () => { expect(formatProviderError(norm)).toBe('{"reason":"boom"}'); }); }); + +// #9265 +describe("truncateErrorText", () => { + it("keeps an excerpt that does not hold the whole body alive", async () => { + await collectGarbage(); + const before = getHeapStatistics().used_heap_size; + const kept = (() => { + const body = "".repeat(1_000_000); + return truncateErrorText(body, MAX_PROVIDER_ERROR_BODY_CHARS); + })(); + await collectGarbage(); + expect(kept.startsWith("")).toBe(true); + expect(kept.endsWith(`[truncated ${6_000_000 - MAX_PROVIDER_ERROR_BODY_CHARS} chars]`)).toBe(true); + // A slice would keep the 6 MB body alive with the excerpt. + expect(getHeapStatistics().used_heap_size - before).toBeLessThan(1_000_000); + }); +}); diff --git a/packages/ai/test/memory-helpers.ts b/packages/ai/test/memory-helpers.ts new file mode 100644 index 000000000..cf025a15a --- /dev/null +++ b/packages/ai/test/memory-helpers.ts @@ -0,0 +1,39 @@ +// GC-based measurements for cost guards and leak tests: bytes a function allocates, and collecting garbage on demand. +import { GCProfiler, getHeapStatistics, setFlagsFromString } from "node:v8"; +import { runInNewContext } from "node:vm"; + +setFlagsFromString("--expose-gc"); +const gc = runInNewContext("gc") as () => void; + +/** Run a full garbage collection a few times, yielding in between so finalizers and weak references settle. */ +export async function collectGarbage(): Promise { + for (let round = 0; round < 4; round++) { + await new Promise((resolve) => setImmediate(resolve)); + gc(); + } +} + +/** + * Bytes `run` allocates on the JavaScript heap, including garbage collected while it ran: the heap growth plus what + * every collection during the run freed. + */ +export async function allocatedBytes(run: () => unknown): Promise { + gc(); + const profiler = new GCProfiler(); + profiler.start(); + const before = getHeapStatistics().used_heap_size; + await run(); + const after = getHeapStatistics().used_heap_size; + const { statistics } = profiler.stop(); + let freed = 0; + for (const collection of statistics) { + freed += collection.beforeGC.heapStatistics.usedHeapSize - collection.afterGC.heapStatistics.usedHeapSize; + } + return after - before + freed; +} + +/** Whether `reference` is collected once nothing else holds its target. */ +export async function isCollected(reference: WeakRef): Promise { + await collectGarbage(); + return reference.deref() === undefined; +} diff --git a/packages/ai/test/streaming-json.test.ts b/packages/ai/test/streaming-json.test.ts new file mode 100644 index 000000000..e2be9bce0 --- /dev/null +++ b/packages/ai/test/streaming-json.test.ts @@ -0,0 +1,848 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import type { JsonValue } from "../src/types.ts"; +import { parseStreamingJson, parseToolCallArguments } from "../src/utils/json-parse.ts"; +import { type JsonChange, StreamingJsonParser, ToolCallArgumentsStream } from "../src/utils/streaming-json.ts"; +import { allocatedBytes, collectGarbage } from "./memory-helpers.ts"; + +// #9265: streamed tool-call arguments were re-parsed in full on every delta. + +/** Seeded random numbers, so every run checks the same documents. */ +function random(seed: number): () => number { + let state = seed; + return () => { + state = (state * 1103515245 + 12345) & 0x7fffffff; + return state / 0x7fffffff; + }; +} + +type Random = () => number; + +const pick = (rand: Random, items: readonly T[]): T => items[Math.floor(rand() * items.length)]!; + +const STRING_PIECES = [ + "a", + "b", + "x y", + " ", + "\n", + "\t", + "\\", + '"', + "/", + "é", + "😀", + "\u0001", + "\u001f", + "\u2028", + "{", + "]", + ",", +]; + +function randomString(rand: Random): string { + let text = ""; + const length = Math.floor(rand() * 6); + for (let index = 0; index < length; index++) text += pick(rand, STRING_PIECES); + return text; +} + +function randomScalar(rand: Random): JsonValue { + return pick(rand, [ + () => randomString(rand), + () => Math.floor(rand() * 2000) - 1000, + () => rand() * 100, + () => 1e21, + () => -5e-8, + () => -0, + () => 0, + () => true, + () => false, + () => null, + ])(); +} + +function randomValue(rand: Random, depth: number): JsonValue { + const roll = rand(); + if (depth > 3 || roll < 0.3) return randomScalar(rand); + if (roll < 0.65) { + const members: { [key: string]: JsonValue } = {}; + const count = Math.floor(rand() * 4); + for (let index = 0; index < count; index++) members[randomString(rand)] = randomValue(rand, depth + 1); + return members; + } + return Array.from({ length: Math.floor(rand() * 4) }, () => randomValue(rand, depth + 1)); +} + +/** JSON text of `value`, with random insignificant whitespace when `spaced`. */ +function render(value: JsonValue, rand: Random, spaced: boolean): string { + const space = () => (spaced ? pick(rand, ["", "", " ", "\n ", "\t", "\r\n"]) : ""); + if (Array.isArray(value)) { + return `[${space()}${value.map((item) => render(item, rand, spaced)).join(`${space()},${space()}`)}${space()}]`; + } + if (value !== null && typeof value === "object") { + const members = Object.entries(value).map( + ([key, item]) => `${JSON.stringify(key)}${space()}:${space()}${render(item, rand, spaced)}`, + ); + return `{${space()}${members.join(`${space()},${space()}`)}${space()}}`; + } + // JSON.stringify(-0) is "0"; keep the sign so the text parses back to the same value. + return Object.is(value, -0) ? "-0" : JSON.stringify(value); +} + +function randomDocuments(seed: number, count: number, spaced: boolean): string[] { + const rand = random(seed); + return Array.from({ length: count }, () => + render( + rand() < 0.8 ? { path: randomString(rand), args: randomValue(rand, 0) } : randomValue(rand, 0), + rand, + spaced, + ), + ); +} + +/** Whether the reference shows unterminated numbers and literals, as snapshots do; changes leave them out. */ +let pendingScalars = true; + +/** + * Reference for `StreamingJsonParser` on a prefix of valid JSON (strings may also hold raw control characters and + * invalid escapes): a plain recursive-descent reading of the whole prefix that follows the documented rules. It is + * written independently of the incremental parser so the two check each other. + */ +function referenceSnapshot(text: string, options: { pendingScalars?: boolean } = {}): JsonValue | undefined { + pendingScalars = options.pendingScalars ?? true; + const read = readValue(text, skipSpace(text, 0)); + return read === undefined ? undefined : read.value; +} + +type Read = { readonly value: JsonValue; readonly complete: boolean; readonly end: number }; + +const SPACE = " \t\n\r"; + +function skipSpace(text: string, start: number): number { + let index = start; + while (index < text.length && SPACE.includes(text[index]!)) index++; + return index; +} + +function readValue(text: string, start: number): Read | undefined { + if (start >= text.length) return undefined; + const char = text[start]; + if (char === '"') return readString(text, start); + if (char === "{") return readObject(text, start); + if (char === "[") return readArray(text, start); + return readScalar(text, start); +} + +const SIMPLE: Record = { '"': '"', "\\": "\\", "/": "/", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t" }; + +function readString(text: string, start: number): Read & { readonly value: string } { + let value = ""; + let index = start + 1; + while (index < text.length) { + const char = text[index]!; + if (char === '"') return { value, complete: true, end: index + 1 }; + if (char !== "\\") { + value += char; + index++; + continue; + } + const next = text[index + 1]; + if (next === undefined) return { value, complete: false, end: text.length }; + if (SIMPLE[next] !== undefined) { + value += SIMPLE[next]; + index += 2; + continue; + } + if (next === "u") { + let end = index + 2; + while (end < index + 6 && end < text.length && /[0-9a-fA-F]/.test(text[end]!)) end++; + if (end === index + 6) { + value += String.fromCharCode(Number.parseInt(text.slice(index + 2, end), 16)); + index = end; + continue; + } + if (end === text.length) return { value, complete: false, end }; + // Not four hex digits: the escape stays literal and the next character is read normally. + value += text.slice(index, end); + index = end; + continue; + } + // Invalid escape: the backslash stays literal and the next character is read normally. + value += "\\"; + index++; + } + return { value, complete: false, end: text.length }; +} + +function setOwn(members: { [key: string]: JsonValue }, key: string, value: JsonValue): void { + Object.defineProperty(members, key, { value, writable: true, enumerable: true, configurable: true }); +} + +function readObject(text: string, start: number): Read { + const members: { [key: string]: JsonValue } = {}; + const partial = { value: members, complete: false, end: text.length }; + let index = skipSpace(text, start + 1); + if (index >= text.length) return partial; + if (text[index] === "}") return { value: members, complete: true, end: index + 1 }; + for (;;) { + if (text[index] !== '"') throw new Error(`reference reads valid JSON only: ${JSON.stringify(text)}`); + const key = readString(text, index); + if (!key.complete) return partial; + index = skipSpace(text, key.end); + if (index >= text.length) return partial; + index = skipSpace(text, index + 1); + const member = readValue(text, index); + if (member === undefined) return partial; + setOwn(members, key.value, member.value); + if (!member.complete) return partial; + index = skipSpace(text, member.end); + if (index >= text.length) return partial; + if (text[index] === "}") return { value: members, complete: true, end: index + 1 }; + index = skipSpace(text, index + 1); + if (index >= text.length) return partial; + } +} + +function readArray(text: string, start: number): Read { + const elements: JsonValue[] = []; + const partial = { value: elements, complete: false, end: text.length }; + let index = skipSpace(text, start + 1); + if (index >= text.length) return partial; + if (text[index] === "]") return { value: elements, complete: true, end: index + 1 }; + for (;;) { + const element = readValue(text, index); + if (element === undefined) return partial; + elements.push(element.value); + if (!element.complete) return partial; + index = skipSpace(text, element.end); + if (index >= text.length) return partial; + if (text[index] === "]") return { value: elements, complete: true, end: index + 1 }; + index = skipSpace(text, index + 1); + if (index >= text.length) return partial; + } +} + +function readScalar(text: string, start: number): Read | undefined { + let end = start; + while (end < text.length && !`${SPACE},:{}[]"`.includes(text[end]!)) end++; + const token = text.slice(start, end); + if (end < text.length) return { value: JSON.parse(token) as JsonValue, complete: true, end }; + if (!pendingScalars) return undefined; + // At the end of the text: a literal prefix, a number, or a number whose exponent has no digits yet. + for (const [literal, value] of [ + ["true", true], + ["false", false], + ["null", null], + ] as const) { + if (literal.startsWith(token)) return { value, complete: false, end }; + } + if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(token)) + return { value: Number(token), complete: false, end }; + const mantissa = /^(-?(?:0|[1-9]\d*)(?:\.\d+)?)[eE][+-]?$/.exec(token); + return mantissa === null ? undefined : { value: Number(mantissa[1]), complete: false, end }; +} + +type Step = { readonly read: string; readonly snapshot: JsonValue | undefined; readonly copy: JsonValue | undefined }; + +/** Feed `text` in pieces; returns the snapshot after each piece, a copy taken right then, and the text read by then. */ +function stream(text: string, chunkSize: number | Random): Step[] { + const parser = new StreamingJsonParser(); + const steps: Step[] = []; + for (let index = 0; index < text.length; ) { + const size = typeof chunkSize === "number" ? chunkSize : 1 + Math.floor(chunkSize() * 12); + parser.append(text.slice(index, index + size)); + index += size; + const snapshot = parser.snapshot(); + steps.push({ read: text.slice(0, index), snapshot, copy: structuredClone(snapshot) }); + } + return steps; +} + +function finalSnapshot(text: string, chunkSize = text.length || 1): JsonValue | undefined { + return stream(text, chunkSize).at(-1)?.snapshot; +} + +const CHUNK_SIZES: readonly (number | "random")[] = [1, 2, 3, 7, "random"]; + +/** A stable seed from the text, so random chunk sizes differ per document. */ +function read32(text: string): number { + let hash = 7; + for (let index = 0; index < text.length; index++) hash = (hash * 31 + text.charCodeAt(index)) | 0; + return Math.abs(hash) + 1; +} + +describe("StreamingJsonParser differential fuzzing", () => { + it("matches the reference after every piece, at every chunk size, with insignificant whitespace", () => { + for (const text of randomDocuments(1, 250, true)) { + for (const size of CHUNK_SIZES) { + for (const { read, snapshot } of stream(text, size === "random" ? random(read32(text)) : size)) { + expect(snapshot, `${JSON.stringify(read)} (chunk size ${size})`).toEqual(referenceSnapshot(read)); + } + } + } + }); + + it("matches today's parseStreamingJson at every prefix of compact documents", () => { + for (const text of randomDocuments(2, 400, false)) { + for (const { read, snapshot } of stream(text, 1)) { + // Documented differences: parseStreamingJson trims trailing whitespace, and shows an unfinished root + // `null` as {}. + if (/\s$/.test(read)) continue; + const expected = parseStreamingJson(read); + if (snapshot === null && "null".startsWith(read)) continue; + expect(snapshot ?? {}, JSON.stringify(read)).toEqual(expected); + } + } + }); + + it("reads complete documents exactly like JSON.parse", () => { + for (const text of [...randomDocuments(3, 300, true), ...randomDocuments(4, 300, false)]) { + const expected = JSON.parse(text) as JsonValue; + for (const size of CHUNK_SIZES) { + expect(finalSnapshot(text, size === "random" ? 5 : size), text).toEqual(expected); + } + } + }); + + it("never changes a snapshot once it is returned", () => { + for (const text of randomDocuments(5, 150, true)) { + for (const { snapshot, copy } of stream(text, random(read32(text)))) expect(snapshot).toEqual(copy); + } + }); + + it("repairs raw control characters and invalid escapes like parseStreamingJson, and shows them while streaming", () => { + const rand = random(6); + const malformed = ["\n", "\t", "\u0000", "\\d", "\\x41", "\\u12", "\\uZZZZ", "\\u00e9", "\\\\", "\\q"]; + for (let round = 0; round < 300; round++) { + let content = ""; + for (let index = 0; index < 6; index++) content += pick(rand, [...malformed, "a", " ", "é"]); + const text = `{"path":"p","content":"${content}","n":${round}}`; + // Complete text: the same repaired value as today's parser. + expect(finalSnapshot(text, 1), JSON.stringify(text)).toEqual(parseStreamingJson(text)); + for (const { read, snapshot } of stream(text, 1)) { + expect(snapshot, JSON.stringify(read)).toEqual(referenceSnapshot(read)); + } + } + }); +}); + +describe("StreamingJsonParser strings", () => { + it("holds an escape back until it is complete, at every split position", () => { + for (const [text, value] of [ + [String.raw`{"a":"x\u00e9y"}`, "xéy"], + [String.raw`{"a":"x\ny"}`, "x\ny"], + [String.raw`{"a":"x\\y"}`, "x\\y"], + [String.raw`{"a":"x\"y"}`, 'x"y'], + [String.raw`{"a":"x\/y"}`, "x/y"], + ] as const) { + for (let split = 0; split <= text.length; split++) { + const parser = new StreamingJsonParser(); + parser.append(text.slice(0, split)); + expect(parser.snapshot(), text.slice(0, split)).toEqual(referenceSnapshot(text.slice(0, split))); + parser.append(text.slice(split)); + expect(parser.snapshot()).toEqual({ a: value }); + } + } + }); + + it("joins surrogate pairs split across pieces, escaped or raw", () => { + const escaped = String.raw`{"a":"\ud83d\ude00!"}`; + const raw = '{"a":"😀!"}'; + for (const text of [escaped, raw]) { + for (let split = 0; split <= text.length; split++) { + const parser = new StreamingJsonParser(); + parser.append(text.slice(0, split)); + parser.snapshot(); + parser.append(text.slice(split)); + expect(parser.snapshot()).toEqual({ a: "😀!" }); + } + } + // A high surrogate whose low half has not arrived shows alone, like JSON.parse() of the escape. + const parser = new StreamingJsonParser(); + parser.append(String.raw`{"a":"\ud83d`); + expect(parser.snapshot()).toEqual({ a: "\ud83d" }); + }); + + it("keeps trailing whitespace of an unterminated string", () => { + const parser = new StreamingJsonParser(); + parser.append('{"code":"x\t\t '); + expect(parser.snapshot()).toEqual({ code: "x\t\t " }); + }); + + it("keeps raw control characters and invalid escapes visible while streaming", () => { + const parser = new StreamingJsonParser(); + parser.append('{"a":"x\ny \\d \\u12"'); + expect(parser.snapshot()).toEqual({ a: "x\ny \\d \\u12" }); + }); + + it("keeps invalid \\u escapes literal without dropping fields (#10868)", () => { + expect(finalSnapshot(String.raw`{"path":"a.tex","content":"\usepackage{x}","mode":"w"}`, 1)).toEqual({ + path: "a.tex", + content: "\\usepackage{x}", + mode: "w", + }); + expect(finalSnapshot(String.raw`{"text":"caf\u00e9 \url"}`, 3)).toEqual({ text: "café \\url" }); + const parser = new StreamingJsonParser(); + parser.append(String.raw`{"text":"ab\u00`); + expect(parser.snapshot()).toEqual({ text: "ab" }); + }); + + it("makes `__proto__` an own member, as JSON.parse does", () => { + const text = '{"__proto__":{"polluted":true},"b":{"__proto__":"x"}}'; + for (let split = 0; split <= text.length; split++) { + const parser = new StreamingJsonParser(); + parser.append(text.slice(0, split)); + const partial = parser.snapshot() as { [key: string]: JsonValue } | undefined; + if (partial !== undefined) expect(Object.getPrototypeOf(partial)).toBe(Object.prototype); + parser.append(text.slice(split)); + const value = parser.snapshot() as { [key: string]: JsonValue }; + expect(value).toEqual(JSON.parse(text)); + expect(Object.getPrototypeOf(value)).toBe(Object.prototype); + expect(Object.hasOwn(value, "__proto__")).toBe(true); + } + expect(({} as { polluted?: boolean }).polluted).toBeUndefined(); + }); + + it("keeps the last value of a duplicate key in the first key's position, as JSON.parse does", () => { + const text = '{"a":1,"b":2,"a":"three"}'; + expect(Object.keys(finalSnapshot(text, 1) as object)).toEqual(Object.keys(JSON.parse(text))); + expect(finalSnapshot(text, 1)).toEqual(JSON.parse(text)); + }); +}); + +/** Expected values of the inputs, from other partial-JSON parsers' public test suites (see each table's source). */ +type Vector = readonly [input: string, expected: JsonValue | undefined]; + +// partial-json (MIT, https://github.com/promplate/partial-json-parser-js/blob/main/tests/examples.test.js), with every +// partial type allowed, as parseStreamingJson uses it. +const PARTIAL_JSON_VECTORS: readonly Vector[] = [ + ['"', ""], + ['["', [""]], + ['{"": "', { "": "" }], + ["n", null], + ["t", true], + ["f", false], + ["0", 0], + ["-1.25e+4", -1.25e4], + ["-1.25e+", -1.25], + ["-1.25e", -1.25], + // Differences, documented in streaming-json.ts: an invalid escape stays visible (partial-json cuts the string at + // the backslash), and `Infinity`/`NaN` words are not JSON and are dropped. + ['" \\x12', " \\x12"], + ["I", undefined], + ["-I", undefined], + ["N", undefined], +]; + +// Vercel AI SDK fixJson (Apache-2.0, https://github.com/vercel/ai/blob/main/packages/ai/src/util/fix-json.test.ts): +// the expected values are JSON.parse() of its repaired texts. +const FIX_JSON_VECTORS: readonly Vector[] = [ + ["", undefined], + ["nul", null], + ["t", true], + ["fals", false], + ["12.2", 12.2], + ["-12", -12], + ["-", undefined], + ["2.5e", 2.5], + ["2.5e-", 2.5], + ["2.5e3", 2.5e3], + ["-2.5e3", -2.5e3], + ["2.5E", 2.5], + ["2.5E-", 2.5], + ["2.5E3", 2.5e3], + ["-2.5E3", -2.5e3], + ["12.34e", 12.34], + ["5e", 5], + ['"abc', "abc"], + ['"value with \\"quoted\\" text and \\\\ escape', 'value with "quoted" text and \\ escape'], + ['"value with \\', "value with "], + ['"\\u', ""], + ['"\\u12', ""], + ['"text \\u00', "text "], + ['{"a":"\\u12', { a: "" }], + ['"value with unicode \u003C"', "value with unicode <"], + ["[", []], + ["[[1], [2", [[1], [2]]], + ['[["1"], ["2', [["1"], ["2"]]], + ["[[false], [nu", [[false], [null]]], + ["[[[]], [[]", [[[]], [[]]]], + ["[[{}], [{", [[{}], [{}]]], + ["[1, ", [1]], + ["[[], 123", [[], 123]], + ['{"key":', {}], + ['{"a": {"b": 1}, "c": {"d": 2', { a: { b: 1 }, c: { d: 2 } }], + ['{"a": {"b": "1"}, "c": {"d": 2', { a: { b: "1" }, c: { d: 2 } }], + ['{"a": {"b": false}, "c": {"d": 2', { a: { b: false }, c: { d: 2 } }], + ['{"a": {"b": []}, "c": {"d": 2', { a: { b: [] }, c: { d: 2 } }], + ['{"a": {"b": {}}, "c": {"d": 2', { a: { b: {} }, c: { d: 2 } }], + ['{"ke', {}], + ['{"k1": 1, "k2', { k1: 1 }], + ['{"k1": 1, "k2":', { k1: 1 }], + ['{"key": "value" ', { key: "value" }], + ['{"a": {"b": {}', { a: { b: {} } }], + ["[1, [2, 3, [", [1, [2, 3, []]]], + ["[false, [true, [", [false, [true, []]]], + ['{"key": {"subKey":', { key: {} }], + ['{"key": 123, "key2": {"subKey":', { key: 123, key2: {} }], + ['{"key": null, "key2": {"subKey":', { key: null, key2: {} }], + ['{"key": [1, 2, {', { key: [1, 2, {}] }], + ['[1, 2, {"key": "value",', [1, 2, { key: "value" }]], + ['{"a": {"b": ["c", {"d": "e",', { a: { b: ["c", { d: "e" }] } }], + ['{"a": {"b": {"c": {"d":', { a: { b: { c: {} } } }], + ['{"a": 1, "b": [', { a: 1, b: [] }], + ['{"a": 1, "b": {', { a: 1, b: {} }], + ['{"a": 1, "b": "', { a: 1, b: "" }], + [ + '{\n "a": [\n {\n "a1": "v1",\n "a2": "v2",\n "a3": "v3"\n }\n ],\n "b": [\n {\n "b1": "n', + { a: [{ a1: "v1", a2: "v2", a3: "v3" }], b: [{ b1: "n" }] }, + ], + ['{"type":"div","children":[{"type":"Card","props":{}', { type: "div", children: [{ type: "Card", props: {} }] }], + // Difference: fixJson completes `12.` to 12; like parseStreamingJson, a number cut after its dot is omitted. + ["12.", undefined], + ["12.e", undefined], +]; + +describe("StreamingJsonParser ported test vectors", () => { + it.each([...PARTIAL_JSON_VECTORS, ...FIX_JSON_VECTORS])("reads %j", (input, expected) => { + for (const size of [input.length || 1, 1, 2]) { + expect(finalSnapshot(input, size)).toEqual(expected); + } + }); + + it("reads every JSONTestSuite document JSON.parse accepts exactly like it, and never throws on the rest", () => { + const suite = JSON.parse(readFileSync(new URL("./data/json-test-suite.json", import.meta.url), "utf8")) as { + files: Record; + }; + for (const [name, base64] of Object.entries(suite.files)) { + const text = Buffer.from(base64, "base64").toString("utf8"); + let expected: JsonValue | undefined; + try { + expected = JSON.parse(text) as JsonValue; + } catch { + expected = undefined; + } + for (const size of [text.length || 1, 1, 3]) { + const snapshot = finalSnapshot(text, size); + if (expected !== undefined) expect(snapshot, name).toEqual(expected); + else expectJson(snapshot, name); + } + } + }); +}); + +/** Asserts `value` is plain, finite JSON data: what consumers such as Durable's strict JSON copy accept. */ +function expectJson(value: JsonValue | undefined, label: string): void { + if (value === undefined || value === null || typeof value === "string" || typeof value === "boolean") return; + if (typeof value === "number") { + expect(Number.isFinite(value), label).toBe(true); + return; + } + expect([Object.prototype, Array.prototype], label).toContain(Object.getPrototypeOf(value)); + for (const item of Object.values(value)) expectJson(item, label); +} + +describe("StreamingJsonParser malformed text", () => { + const MALFORMED = [ + '{"a":1 "b":2}', + '{"a" 1, "b": 2}', + "{a:1, 'b': 2}", + '{"a":1,,"b":2,}', + "[1 2, , 3,]", + '{"a":[1,2}, "b":3]', + "]]]}}}{", + '{"a":NaN,"b":Infinity,"c":-Infinity,"d":undefined,"e":1}', + '{"a":01,"b":1.,"c":.5,"d":+1,"e":0x10,"f":1e}', + '{"a":"x"} trailing garbage {"b":2}', + '{"a": tru, "b": nulll, "c": falsey}', + '{"a":{"b":{"c":', + '{"a\\": 1}', + '"unterminated \\', + '// comment\n{"a":1}', + '\u0000{\u0001"a"\u0002:\u00031}', + '{"a":"x"}}}]]]', + ]; + + it("never throws, keeps snapshots plain JSON and unchanged, at every chunk size", () => { + for (const text of MALFORMED) { + for (const size of CHUNK_SIZES) { + const steps = stream(text, size === "random" ? random(read32(text)) : size); + for (const { snapshot, copy } of steps) { + expectJson(snapshot, text); + expect(snapshot).toEqual(copy); + } + // Every chunk size reads malformed text the same way. + expect(steps.at(-1)?.snapshot, text).toEqual(finalSnapshot(text)); + } + } + }); + + it("tolerates missing commas and colons and drops values in invalid positions", () => { + expect(finalSnapshot('{"a":1 "b":2}')).toEqual({ a: 1, b: 2 }); + expect(finalSnapshot('{"a" 1, "b": 2}')).toEqual({ a: 1, b: 2 }); + expect(finalSnapshot("[1 2, , 3,]")).toEqual([1, 2, 3]); + expect(finalSnapshot('{"a":NaN,"b":Infinity,"e":1}')).toEqual({ e: 1 }); + expect(finalSnapshot('{"a":"x"} trailing garbage {"b":2}')).toEqual({ a: "x" }); + }); + + it("survives random corruption of valid documents", () => { + const rand = random(7); + const noise = ['"', "\\", "{", "}", "[", "]", ",", ":", "x", "1", "-", "\n", "t"]; + for (const text of randomDocuments(8, 300, true)) { + const characters = [...text]; + for (let edit = 0; edit < 3; edit++) { + const at = Math.floor(rand() * (characters.length + 1)); + if (rand() < 0.5) characters.splice(at, 1); + else characters.splice(at, 0, pick(rand, noise)); + } + const corrupted = characters.join(""); + for (const { snapshot } of stream(corrupted, random(read32(corrupted)))) expectJson(snapshot, corrupted); + // The changes keep a copy equal to the parser's own value, malformed or not. + const parser = new StreamingJsonParser(); + const mirror = new Mirror(); + for (let index = 0; index < corrupted.length; index += 4) + mirror.apply(parser.append(corrupted.slice(index, index + 4))); + // A space ends a number or literal at the end, which snapshots show and changes do not report yet. + mirror.apply(parser.append(" ")); + expect(mirror.value, corrupted).toEqual(parser.snapshot()); + } + }); +}); + +/** A copy of a value kept current by applying the parser's changes, as a writer such as Durable keeps one. */ +class Mirror { + value: JsonValue | undefined = undefined; + + apply(changes: readonly JsonChange[]): void { + for (const change of changes) { + const key = change.path.at(-1); + let parent = this.value as Record; + for (const segment of change.path.slice(0, -1)) parent = parent[segment] as Record; + if (change.kind === "set") { + const value = structuredClone(change.value); + if (key === undefined) this.value = value; + else parent[key] = value; + } else if (key === undefined) { + this.value = `${this.value as string}${change.text}`; + } else { + parent[key] = `${parent[key] as string}${change.text}`; + } + } + } +} + +/** Feed `text` in pieces into a parser and a mirror of its changes; checks the mirror after each piece. */ +function mirrorStream(text: string, size: number | Random): Mirror { + const parser = new StreamingJsonParser(); + const mirror = new Mirror(); + for (let index = 0; index < text.length; ) { + const length = typeof size === "number" ? size : 1 + Math.floor(size() * 12); + mirror.apply(parser.append(text.slice(index, index + length))); + index += length; + // Changes leave out unterminated numbers and literals, which snapshots show. + expect(mirror.value, JSON.stringify(text.slice(0, index))).toEqual( + referenceSnapshot(text.slice(0, index), { pendingScalars: false }), + ); + } + return mirror; +} + +describe("StreamingJsonParser changes", () => { + it("keep a copy equal to the value read so far, at every chunk size, and to JSON.parse at the end", () => { + for (const text of randomDocuments(9, 200, true)) { + for (const size of CHUNK_SIZES) { + const mirror = mirrorStream(text, size === "random" ? random(read32(text)) : size); + expect(mirror.value, text).toEqual(JSON.parse(text)); + } + } + }); + + it("report a number that ends and a string that starts in one piece, in order", () => { + const parser = new StreamingJsonParser(); + expect(parser.append('{"n":1')).toEqual([{ kind: "set", path: [], value: {} }]); + expect(parser.append('2,"s":"ab')).toEqual([ + { kind: "set", path: ["n"], value: 12 }, + { kind: "set", path: ["s"], value: "ab" }, + ]); + expect(parser.append('c","t":tru')).toEqual([{ kind: "append", path: ["s"], text: "c" }]); + expect(parser.append("e}")).toEqual([{ kind: "set", path: ["t"], value: true }]); + expect(parser.append(' {"ignored":1}')).toEqual([]); + }); + + it("report nested objects and arrays as empty containers, then their members and items", () => { + const parser = new StreamingJsonParser(); + expect(parser.append('{"a":[1,{"b":["x"')).toEqual([ + { kind: "set", path: [], value: {} }, + { kind: "set", path: ["a"], value: [] }, + { kind: "set", path: ["a", 0], value: 1 }, + { kind: "set", path: ["a", 1], value: {} }, + { kind: "set", path: ["a", 1, "b"], value: [] }, + { kind: "set", path: ["a", 1, "b", 0], value: "x" }, + ]); + expect(parser.append(',"y')).toEqual([{ kind: "set", path: ["a", 1, "b", 1], value: "y" }]); + expect(parser.append('z"]}],"a":"again"}')).toEqual([ + { kind: "append", path: ["a", 1, "b", 1], text: "z" }, + { kind: "set", path: ["a"], value: "again" }, + ]); + }); + + it("hold split escapes back and report their decoded text once complete", () => { + const text = String.raw`{"a":"x\u00e9\n\\\"\ud83d\ude00\q"}`; + const expected = (JSON.parse(String.raw`{"a":"x\u00e9\n\\\"\ud83d\ude00\\q"}`) as { a: string }).a; + for (let first = 1; first < text.length; first++) { + for (let second = first; second < text.length; second++) { + const parser = new StreamingJsonParser(); + const mirror = new Mirror(); + for (const piece of [text.slice(0, first), text.slice(first, second), text.slice(second)]) { + mirror.apply(parser.append(piece)); + } + expect(mirror.value).toEqual({ a: expected }); + } + } + const parser = new StreamingJsonParser(); + parser.append('{"a":"x'); + expect(parser.append("\\u00")).toEqual([]); + expect(parser.append("e9")).toEqual([{ kind: "append", path: ["a"], text: "é" }]); + }); + + it("report a container nested deeper than 32 levels once, whole, when it closes", () => { + const depth = 40; + const text = `${'{"a":'.repeat(depth)}"deep"${"}".repeat(depth)}`; + const parser = new StreamingJsonParser(); + const mirror = new Mirror(); + for (let index = 0; index < text.length; index += 3) mirror.apply(parser.append(text.slice(index, index + 3))); + expect(mirror.value).toEqual(JSON.parse(text)); + const changes = new StreamingJsonParser().append(text); + // The containers down to path length 32, then the one at length 32 again, whole, as it closes. + expect(changes).toHaveLength(34); + const last = changes.at(-1)!; + expect(last.kind === "set" && last.path.length).toBe(32); + expect(last.kind === "set" && last.value).toEqual(JSON.parse(text.slice(32 * 5, -32))); + }); + + it("report nothing for values in invalid positions and text after the root", () => { + const parser = new StreamingJsonParser(); + expect(parser.append('{"a":1 2, "b" "x"}')).toEqual([ + { kind: "set", path: [], value: {} }, + { kind: "set", path: ["a"], value: 1 }, + { kind: "set", path: ["b"], value: "x" }, + ]); + expect(parser.append('{"c":3}')).toEqual([]); + }); +}); + +describe("StreamingJsonParser cost", () => { + const content = Array.from( + { length: 20_000 }, + (_, line) => `const line${line} = "value ${line}"; // \\ done\n`, + ).join(""); + + /** `write`-style arguments with `size` characters of file content, streamed in 32-character deltas. */ + const writeArguments = (size: number) => JSON.stringify({ path: "/src/file.ts", content: content.slice(0, size) }); + + async function streamAllocation(text: string, delta: number): Promise { + return allocatedBytes(() => { + const args = new ToolCallArgumentsStream(); + for (let index = 0; index < text.length; index += delta) args.append(text.slice(index, index + delta)); + return args.finish(); + }); + } + + it("allocates linearly in the streamed text, never per accumulated text", async () => { + const small = writeArguments(50_000); + const large = writeArguments(500_000); + const smallBytes = await streamAllocation(small, 32); + const largeBytes = await streamAllocation(large, 32); + // Measured about 15 bytes per character; re-parsing the accumulated text cost about 1 MB per character. + expect(largeBytes / large.length).toBeLessThan(200); + // Ten times the text: linear growth is about 10x, quadratic about 100x. + expect(largeBytes / smallBytes).toBeLessThan(20); + }); + + it("keeps very large open arrays linear by letting their preview lag", async () => { + const elements = (count: number) => JSON.stringify({ items: Array.from({ length: count }, (_, index) => index) }); + const small = elements(10_000); + const large = elements(100_000); + const smallBytes = await streamAllocation(small, 16); + const largeBytes = await streamAllocation(large, 16); + expect(largeBytes / large.length).toBeLessThan(2000); + expect(largeBytes / smallBytes).toBeLessThan(20); + + const parser = new StreamingJsonParser(); + let shown = 0; + for (let index = 0; index < large.length; index += 16) { + parser.append(large.slice(index, index + 16)); + const items = (parser.snapshot() as { items?: number[] } | undefined)?.items ?? []; + // The preview only moves forward. + expect(items.length).toBeGreaterThanOrEqual(shown); + shown = items.length; + } + expect(parser.snapshot()).toEqual(JSON.parse(large)); + }); + + it("reads deep nesting without recursion and in linear time", () => { + const depth = 200_000; + const text = `${"[".repeat(depth)}1${"]".repeat(depth)}`; + const started = performance.now(); + const parser = new StreamingJsonParser(); + for (let index = 0; index < text.length; index += 7) { + parser.append(text.slice(index, index + 7)); + parser.snapshot(); + } + let value = parser.snapshot(); + for (let level = 0; level < depth; level++) value = (value as JsonValue[])[0]!; + expect(value).toBe(1); + expect(performance.now() - started).toBeLessThan(5_000); + }); + + it("reads one huge piece, such as a provider's complete arguments, in linear time", async () => { + const text = writeArguments(500_000); + const bytes = await allocatedBytes(() => { + const parser = new StreamingJsonParser(); + parser.append(text); + return parser.snapshot(); + }); + expect(bytes / text.length).toBeLessThan(100); + }); +}); + +describe("ToolCallArgumentsStream", () => { + it("shows partial arguments as an object and finishes with one full parse", () => { + const args = new ToolCallArgumentsStream(); + expect(args.append(" ")).toEqual({}); + expect(args.append('{"path":"a.ts","content":"x')).toEqual({ path: "a.ts", content: "x" }); + const before = args.append("y"); + expect(args.append("")).toBe(before); + expect(args.text).toBe(' {"path":"a.ts","content":"xy'); + expect(args.finish()).toEqual(parseToolCallArguments(' {"path":"a.ts","content":"xy')); + args.append('"}'); + expect(args.finish()).toEqual({ arguments: { path: "a.ts", content: "xy" } }); + }); + + it("shows non-object roots as no arguments yet", () => { + const args = new ToolCallArgumentsStream(); + expect(args.append("[1,2")).toEqual({}); + args.append("]"); + expect(args.finish().argumentsError).toMatch(/^Tool arguments must be a JSON object/); + }); + + it("replaces the text with the provider's complete text, returning the missing piece", () => { + const args = new ToolCallArgumentsStream(); + args.append('{"a":"x'); + expect(args.replace('{"a":"xyz"}')).toEqual({ delta: 'yz"}', arguments: { a: "xyz" } }); + expect(args.replace('{"b":2}')).toEqual({ delta: undefined, arguments: { b: 2 } }); + expect(args.text).toBe('{"b":2}'); + }); + + it("is collected with its snapshots once the stream is dropped", async () => { + const references: WeakRef[] = []; + (() => { + const args = new ToolCallArgumentsStream(); + for (let index = 0; index < 100; index++) references.push(new WeakRef(args.append(`{"a${index}":"${index}`))); + references.push(new WeakRef(args)); + })(); + await collectGarbage(); + expect(references.filter((reference) => reference.deref() !== undefined)).toEqual([]); + }); +}); diff --git a/packages/ai/test/streaming-tool-arguments.test.ts b/packages/ai/test/streaming-tool-arguments.test.ts new file mode 100644 index 000000000..5b685e577 --- /dev/null +++ b/packages/ai/test/streaming-tool-arguments.test.ts @@ -0,0 +1,420 @@ +import type { ResponseStreamEvent } from "openai/resources/responses/responses.js"; +import { describe, expect, it } from "vitest"; +import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts"; +import { stream as streamMistral } from "../src/api/mistral-conversations.ts"; +import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts"; +import { processResponsesStream } from "../src/api/openai-responses-shared.ts"; +import { stream as streamPiMessages } from "../src/api/pi-messages.ts"; +import { fauxAssistantMessage, fauxProvider, fauxToolCall } from "../src/providers/faux.ts"; +import type { AssistantMessage, AssistantMessageEvent, Model, TranscriptContext } from "../src/types.ts"; +import { AssistantMessageEventStream } from "../src/utils/event-stream.ts"; +import { normalizeContext } from "../src/utils/transcript.ts"; +import { allocatedBytes, collectGarbage } from "./memory-helpers.ts"; + +// #9265: every provider re-parsed the accumulated tool-call arguments on each delta and kept the raw text on the +// content block, where it reached partial messages, Durable commits and transcripts. + +const content = Array.from({ length: 20_000 }, (_, line) => `const line${line} = "value ${line}"; // \\ done\n`).join( + "", +); + +/** `write` arguments with `size` characters of file content, and their JSON text. */ +function writeCall(size: number) { + const args = { path: "/src/file.ts", content: content.slice(0, size) }; + return { args, text: JSON.stringify(args) }; +} + +function deltasOf(text: string, size: number): string[] { + const deltas: string[] = []; + for (let index = 0; index < text.length; index += size) deltas.push(text.slice(index, index + size)); + return deltas; +} + +/** A fetch whose response streams `events` as server-sent events, produced lazily. */ +function sseFetch(events: () => Iterable): typeof fetch { + return async () => { + const iterator = events()[Symbol.iterator](); + const encoder = new TextEncoder(); + const body = new ReadableStream({ + pull(controller) { + let chunk = ""; + for (let count = 0; count < 16; count++) { + const next = iterator.next(); + if (next.done) { + if (chunk) controller.enqueue(encoder.encode(chunk)); + controller.close(); + return; + } + chunk += next.value; + } + controller.enqueue(encoder.encode(chunk)); + }, + }); + return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } }); + }; +} + +const sse = (data: unknown, event?: string) => `${event ? `event: ${event}\n` : ""}data: ${JSON.stringify(data)}\n\n`; + +const baseModel = { + reasoning: false, + input: ["text" as const], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 64_000, +}; + +const context: TranscriptContext = normalizeContext({ messages: [{ role: "user", content: "write", timestamp: 0 }] }); + +function* anthropicEvents(deltas: readonly string[]): Generator { + yield sse( + { + type: "message_start", + message: { id: "msg_1", model: "claude-test", usage: { input_tokens: 1, output_tokens: 1 } }, + }, + "message_start", + ); + yield sse( + { + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "toolu_1", name: "write", input: {} }, + }, + "content_block_start", + ); + for (const delta of deltas) { + yield sse( + { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: delta } }, + "content_block_delta", + ); + } + yield sse({ type: "content_block_stop", index: 0 }, "content_block_stop"); + yield sse( + { type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 10 } }, + "message_delta", + ); + yield sse({ type: "message_stop" }, "message_stop"); +} + +function* chatEvents(deltas: readonly string[], withIdEveryChunk: boolean): Generator { + const base = { id: "chat_1", object: "chat.completion.chunk", created: 1, model: "test" }; + const call = (fields: object) => ({ + ...base, + choices: [{ index: 0, delta: { tool_calls: [{ index: 0, ...fields }] } }], + }); + yield sse(call({ id: "call_1", type: "function", function: { name: "write", arguments: "" } })); + for (const delta of deltas) { + yield sse( + call( + withIdEveryChunk + ? { id: "call_1", function: { name: "write", arguments: delta } } + : { function: { arguments: delta } }, + ), + ); + } + yield sse({ + ...base, + choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }); + yield "data: [DONE]\n\n"; +} + +function* piMessagesEvents(deltas: readonly string[], args: object): Generator { + yield sse({ type: "start" }); + yield sse({ type: "toolcall_start", contentIndex: 0, id: "call_1", toolName: "write" }); + for (const delta of deltas) yield sse({ type: "toolcall_delta", contentIndex: 0, delta }); + yield sse({ + type: "toolcall_end", + contentIndex: 0, + toolCall: { type: "toolCall", id: "call_1", name: "write", arguments: args }, + }); + yield sse({ + type: "done", + reason: "toolUse", + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }); +} + +function* responsesEvents(deltas: readonly string[], text: string): Generator { + const item = { + type: "function_call", + id: "fc_1", + call_id: "call_1", + name: "write", + arguments: "", + status: "in_progress", + }; + yield { type: "response.output_item.added", output_index: 0, sequence_number: 0, item } as ResponseStreamEvent; + for (const delta of deltas) { + yield { + type: "response.function_call_arguments.delta", + output_index: 0, + item_id: "fc_1", + sequence_number: 0, + delta, + } as ResponseStreamEvent; + } + yield { + type: "response.function_call_arguments.done", + output_index: 0, + item_id: "fc_1", + sequence_number: 0, + arguments: text, + } as ResponseStreamEvent; + yield { + type: "response.output_item.done", + output_index: 0, + sequence_number: 0, + item: { ...item, arguments: text, status: "completed" }, + } as ResponseStreamEvent; + yield { + type: "response.completed", + sequence_number: 0, + response: { + id: "resp_1", + status: "completed", + output: [], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + } as unknown as ResponseStreamEvent; +} + +type Path = { + readonly name: string; + /** Stream one `write` call whose argument text arrives as `deltas`. */ + readonly stream: (deltas: readonly string[], args: object, text: string) => AssistantMessageEventStream; +}; + +const PATHS: readonly Path[] = [ + { + name: "anthropic-messages", + stream: (deltas) => + streamAnthropic( + { + ...baseModel, + id: "claude-test", + name: "c", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + }, + context, + { apiKey: "sk-ant-test", fetch: sseFetch(() => anthropicEvents(deltas)) }, + ), + }, + { + name: "openai-completions", + stream: (deltas) => + streamOpenAICompletions( + { + ...baseModel, + id: "gpt-test", + name: "g", + api: "openai-completions", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + }, + context, + { apiKey: "sk-test", fetch: sseFetch(() => chatEvents(deltas, false)) }, + ), + }, + { + name: "openai-responses", + stream: (deltas, _args, text) => { + const output: AssistantMessage = { + role: "assistant", + content: [], + api: "openai-responses", + provider: "openai", + model: "gpt-test", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "pending", + timestamp: 0, + }; + const events = new AssistantMessageEventStream(); + const model = { + ...baseModel, + id: "gpt-test", + name: "g", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + } satisfies Model<"openai-responses">; + void (async () => { + events.push({ type: "start", partial: output }); + await processResponsesStream( + (async function* () { + yield* responsesEvents(deltas, text); + })(), + output, + events, + model, + ); + events.push({ type: "done", reason: "toolUse", message: output }); + events.end(); + })(); + return events; + }, + }, + { + name: "mistral-conversations", + stream: (deltas) => + streamMistral( + { + ...baseModel, + id: "devstral", + name: "d", + api: "mistral-conversations", + provider: "mistral", + baseUrl: "https://api.mistral.ai", + }, + context, + { apiKey: "test", fetch: sseFetch(() => chatEvents(deltas, true)) }, + ), + }, + { + name: "pi-messages", + stream: (deltas, args) => + streamPiMessages( + { + ...baseModel, + id: "auto", + name: "a", + api: "pi-messages", + provider: "radius", + baseUrl: "https://example.test/v1", + }, + context, + { apiKey: "test", fetch: sseFetch(() => piMessagesEvents(deltas, args)) }, + ), + }, + { + name: "faux", + stream: (_deltas, args) => { + const faux = fauxProvider({ tokenSize: { min: 8, max: 8 } }); + faux.setResponses([ + fauxAssistantMessage([fauxToolCall("write", args as never, { id: "call_1" })], { stopReason: "toolUse" }), + ]); + return faux.provider.streamSimple!(faux.getModel(), context, {}) as AssistantMessageEventStream; + }, + }, +]; + +/** Own keys a content block may carry; anything else is stream state leaking into partial messages. */ +const BLOCK_KEYS: Record = { + text: ["type", "text", "textSignature"], + thinking: ["type", "thinking", "thinkingSignature", "redacted"], + toolCall: ["type", "id", "name", "arguments", "argumentsError", "thoughtSignature", "namespace"], +}; + +function expectNoStreamState(message: AssistantMessage, label: string): void { + for (const block of message.content) { + for (const key of Object.keys(block)) + expect(BLOCK_KEYS[block.type], `${label}: ${block.type}.${key}`).toContain(key); + } +} + +async function consume( + events: AssistantMessageEventStream, + onEvent: (event: AssistantMessageEvent) => void = () => {}, +) { + for await (const event of events) onEvent(event); + return events.result(); +} + +describe.each(PATHS)("$name tool-call argument streaming", (path) => { + it("shows growing partial arguments, keeps stream state off every partial, and finishes with the exact arguments", async () => { + const { args, text } = writeCall(5_000); + let shown = -1; + let partials = 0; + const message = await consume(path.stream(deltasOf(text, 24), args, text), (event) => { + const partial = event.type === "done" ? event.message : event.type === "error" ? event.error : event.partial; + expectNoStreamState(partial, event.type); + if (event.type !== "toolcall_delta") return; + const call = partial.content[event.contentIndex]; + const shownContent = call?.type === "toolCall" ? call.arguments.content : undefined; + if (typeof shownContent !== "string") return; + partials++; + expect(args.content.startsWith(shownContent)).toBe(true); + expect(shownContent.length).toBeGreaterThanOrEqual(shown); + shown = shownContent.length; + }); + expect(message.stopReason).toBe("toolUse"); + expect(partials).toBeGreaterThan(100); + expect(message.content).toEqual([{ type: "toolCall", id: expect.any(String), name: "write", arguments: args }]); + }); + + it("allocates linearly in the argument text", async () => { + const run = (size: number) => { + const { args, text } = writeCall(size); + return allocatedBytes(() => consume(path.stream(deltasOf(text, 32), args, text))); + }; + const small = await run(25_000); + const large = await run(250_000); + // Ten times the text: linear growth is about 10x, re-parsing the accumulated text about 100x. + expect(large / small).toBeLessThan(25); + expect(large / writeCall(250_000).text.length).toBeLessThan(5_000); + }); + + it("releases the stream, its partial messages and argument snapshots once the caller drops them", async () => { + const references: WeakRef[] = []; + await (async () => { + const { args, text } = writeCall(2_000); + const events = path.stream(deltasOf(text, 16), args, text); + references.push(new WeakRef(events)); + await consume(events, (event) => { + if (event.type !== "toolcall_delta") return; + const call = event.partial.content[event.contentIndex]; + if (call?.type === "toolCall") references.push(new WeakRef(call.arguments)); + }); + })(); + await collectGarbage(); + expect(references.filter((reference) => reference.deref() !== undefined)).toHaveLength(0); + }); +}); + +describe("tool-call stream state on errors", () => { + it("leaves no stream state on the error message of a stream cut off mid-call", async () => { + const { text } = writeCall(2_000); + const cut = deltasOf(text, 16).slice(0, 20); + const message = await consume( + streamAnthropic( + { + ...baseModel, + id: "claude-test", + name: "c", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + }, + context, + { + apiKey: "sk-ant-test", + fetch: sseFetch(function* () { + const events = [...anthropicEvents(cut)]; + // Drop the block stop and the message end, as a dropped connection does. + yield* events.slice(0, -3); + }), + }, + ), + ); + expect(message.stopReason).toBe("error"); + expectNoStreamState(message, "error"); + expect(message.content[0]).toMatchObject({ type: "toolCall", arguments: { path: "/src/file.ts" } }); + }); +}); diff --git a/packages/ai/test/telemetry.test.ts b/packages/ai/test/telemetry.test.ts index 41b9ba457..6412ffe22 100644 --- a/packages/ai/test/telemetry.test.ts +++ b/packages/ai/test/telemetry.test.ts @@ -1,3 +1,4 @@ +import { getHeapStatistics } from "node:v8"; import { InMemoryTelemetryContext, type TelemetrySpan } from "@earendil-works/pi-telemetry"; import { describe, expect, it, vi } from "vitest"; import { lazyStream } from "../src/api/lazy.ts"; @@ -5,6 +6,7 @@ import { AI_TELEMETRY_SCHEMA, createModels, createProvider } from "../src/models import { observeModelRequest } from "../src/telemetry.ts"; import type { AssistantMessage, AssistantMessageEvent, DeferredHandle, Model } from "../src/types.ts"; import { AssistantMessageEventStream } from "../src/utils/event-stream.ts"; +import { collectGarbage } from "./memory-helpers.ts"; const model: Model<"telemetry-test"> = { id: "model", @@ -195,6 +197,27 @@ describe("AI telemetry", () => { expect(JSON.stringify(span.events)).not.toContain("xxx"); }); + // #9265 + it("keeps a bounded error message that does not hold the provider's whole message alive", async () => { + const telemetry = new InMemoryTelemetryContext(); + await collectGarbage(); + const before = getHeapStatistics().used_heap_size; + await (async () => { + const result = message("error"); + result.errorMessage = `${"x".repeat(4_000_000)}tail`; + const { models } = setup(result); + await telemetry.startSpan({ name: "caller" }, (span) => + models.fetchDeferred(model, handle, { telemetry: span }), + ); + await settled(telemetry); + })(); + await collectGarbage(); + const [, span] = telemetry.getSpans(); + expect(span?.status).toEqual({ status: "error", error: { name: "provider_error", message: "x".repeat(500) } }); + // A slice of the 4 MB message would keep all of it alive with the span. + expect(getHeapStatistics().used_heap_size - before).toBeLessThan(2_000_000); + }); + it("includes setup failures and omits non-scalar native reasoning", async () => { const telemetry = new InMemoryTelemetryContext(); const models = createModels(); diff --git a/packages/chord/CHANGELOG.md b/packages/chord/CHANGELOG.md index 92189ad9d..aa6634e23 100644 --- a/packages/chord/CHANGELOG.md +++ b/packages/chord/CHANGELOG.md @@ -13,6 +13,11 @@ - `Watch`, 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. - `Watchable`: a value that changes and can be watched (`value`, `watch(context)` returning a `WatchHandle`), the common interface of documents, derives and other observable values. - `NativeWatchHandle` and its implementation `NativeWatch`: the same lifecycle for in-process values, with listener `(value, context)` and no ops; an overflow collapses to the newest value. +- `ownedSlice(text, start, end?)` in `@earendil-works/chord/delta`: a slice of a string that does not keep the whole string alive ([#9265](https://github.com/earendil-works/pi/issues/9265)) + +### Fixed + +- Fixed append ops from the delta tracker and `diffRevisions()` keeping the whole string they were cut from alive (V8 represents a slice as a view of its source), so a delta chain, a watch backlog or a replica built from appends to a growing text held one copy of the text per commit ([#9265](https://github.com/earendil-works/pi/issues/9265)) ## [1.4.0] - 2026-10-21 diff --git a/packages/chord/src/delta/diff.ts b/packages/chord/src/delta/diff.ts index 774ad1b59..ff0bf2121 100644 --- a/packages/chord/src/delta/diff.ts +++ b/packages/chord/src/delta/diff.ts @@ -1,5 +1,5 @@ import type { JsonValue } from "../json.ts"; -import { type NonEmptyPath, type Op, overlap, type Path, RESERVED_SEGMENTS, type Seg } from "./index.ts"; +import { type NonEmptyPath, type Op, overlap, ownedSlice, type Path, RESERVED_SEGMENTS, type Seg } from "./index.ts"; const DEFAULT_OVERLAP_SCAN = 65_536; const MAX_DELTA_OPERATIONS = 4_096; @@ -60,10 +60,11 @@ const permutation = (before: readonly JsonValue[], after: readonly JsonValue[]): return result; }; +// O(length): comparing two revisions reads both strings, and slicing a rope makes V8 flatten all of it. const emitString = (before: string, after: string, path: NonEmptyPath, operations: Op[]): void => { if (before === after) return; if (after.length > before.length && after.slice(0, before.length) === before) { - emitOperation(operations, ["a", path, after.slice(before.length)]); + emitOperation(operations, ["a", path, ownedSlice(after, before.length)]); return; } const shared = overlap(before, after, DEFAULT_OVERLAP_SCAN); @@ -72,7 +73,7 @@ const emitString = (before: string, after: string, path: NonEmptyPath, operation return; } emitOperation(operations, ["t", path, before.length - shared]); - if (after.length > shared) emitOperation(operations, ["a", path, after.slice(shared)]); + if (after.length > shared) emitOperation(operations, ["a", path, ownedSlice(after, shared)]); }; type ArrayMatch = readonly [before: number, after: number]; diff --git a/packages/chord/src/delta/index.ts b/packages/chord/src/delta/index.ts index ad4ed57d6..11d94b47c 100644 --- a/packages/chord/src/delta/index.ts +++ b/packages/chord/src/delta/index.ts @@ -109,6 +109,16 @@ export function overlap(a: string, b: string, scan: number, probe = 64, maxCandi return 0; } +/** + * `text.slice(start, end)` as a string of its own. V8 represents a slice of 13 or more characters as a view that keeps + * all of `text` alive, so an append op cut from a long, growing string would retain that whole string for as long as + * the op lives: in a delta chain, a watch backlog, or a value built by applying the op. Appending a character and + * slicing it off again makes V8 copy just the slice. + */ +export function ownedSlice(text: string, start: number, end = text.length): string { + return `${text.slice(start, end)} `.slice(0, -1); +} + // ─── Immutable revision tracking ───────────────────────────────────────────── export { diffRevisions } from "./diff.ts"; diff --git a/packages/chord/src/delta/tracker.ts b/packages/chord/src/delta/tracker.ts index 8e01705fa..bb267bd78 100644 --- a/packages/chord/src/delta/tracker.ts +++ b/packages/chord/src/delta/tracker.ts @@ -2,7 +2,15 @@ import type { JsonValue } from "../json.ts"; import { copyJson } from "../json.ts"; import { applyImmutableTrusted } from "./apply-immutable-trusted.ts"; import type { Draft } from "./draft.ts"; -import { applyImmutable, type NonEmptyPath, type Op, overlap, type Path, RESERVED_SEGMENTS } from "./index.ts"; +import { + applyImmutable, + type NonEmptyPath, + type Op, + overlap, + ownedSlice, + type Path, + RESERVED_SEGMENTS, +} from "./index.ts"; export type { Draft, JsonValue, Op }; @@ -2045,16 +2053,17 @@ function emitChangedValue( if (!isContainer(after) && before === after) return false; if (isContainer(before) && isContainer(after) && equalTrustedJson(before, after)) return false; if (typeof before === "string" && typeof after === "string") { - // Slice/equality stays on V8's rope fast path; startsWith scans the entire - // accumulated prefix on every append. + // O(length): any slice of a rope makes V8 flatten all of it, so this prefix check copies the whole string once per + // change, even when the writer only appended. The op text gets a copy of its own, so it does not keep the whole + // of `after` alive. if (after.length > before.length && after.slice(0, before.length) === before) { - operations.push(["a", path, after.slice(before.length)]); + operations.push(["a", path, ownedSlice(after, before.length)]); return true; } const shared = overlap(before, after, 65_536); if (shared > 0) { operations.push(["t", path, before.length - shared]); - if (after.length > shared) operations.push(["a", path, after.slice(shared)]); + if (after.length > shared) operations.push(["a", path, ownedSlice(after, shared)]); return true; } } diff --git a/packages/chord/test/delta-op-text-retention.test.ts b/packages/chord/test/delta-op-text-retention.test.ts new file mode 100644 index 000000000..906feb56b --- /dev/null +++ b/packages/chord/test/delta-op-text-retention.test.ts @@ -0,0 +1,80 @@ +import { getHeapStatistics, setFlagsFromString } from "node:v8"; +import { runInNewContext } from "node:vm"; +import { describe, expect, it } from "vitest"; +import { applyImmutable, diffRevisions, type Op, track } from "../src/delta/index.ts"; + +// #9265: an append op's text was a V8 sliced string of the whole new string, so every op kept a full copy of the +// string it was cut from alive: a delta chain or a replica built from N appends to a growing text held N copies. + +const gc = (() => { + setFlagsFromString("--expose-gc"); + return runInNewContext("gc") as () => void; +})(); + +/** Heap bytes `build` leaves reachable through its result, and the result. */ +function retained(build: () => T): { bytes: number; kept: T } { + gc(); + const before = getHeapStatistics().used_heap_size; + const kept = build(); + gc(); + return { bytes: getHeapStatistics().used_heap_size - before, kept }; +} + +const BASE = "x".repeat(100_000); +const COMMITS = 300; +const piece = (index: number) => `line ${index} of a long streamed text\n`; + +describe("append op text", () => { + it("tracker append ops do not retain the string they were cut from", () => { + const { bytes } = retained(() => { + const tracker = track({ text: BASE }); + const ops: Op[] = []; + for (let index = 0; index < COMMITS; index++) { + const change = tracker.beginChange(); + // A rope, as a streaming writer builds it. + change.state.text = tracker.value.text + piece(index); + const prepared = change.prepare(); + ops.push(...prepared.ops); + tracker.adopt(prepared); + } + expect(ops.every((op) => op[0] === "a")).toBe(true); + return { ops, value: tracker.value }; + }); + // One copy of the text plus the op texts: well under a megabyte. Sliced op texts held one copy per op (30 MB). + expect(bytes).toBeLessThan(2_000_000); + }); + + it("diffRevisions append ops do not retain the string they were cut from", () => { + const { bytes } = retained(() => { + const ops: Op[] = []; + let text = BASE; + for (let index = 0; index < COMMITS; index++) { + const next = text + piece(index); + ops.push(...diffRevisions({ text }, { text: next })); + text = next; + } + expect(ops.every((op) => op[0] === "a")).toBe(true); + return { ops, text }; + }); + expect(bytes).toBeLessThan(2_000_000); + }); + + it("a replica that applies a long stream of appends retains the text once, not once per commit", () => { + const { bytes, kept } = retained(() => { + const tracker = track({ text: BASE }); + // Like a watch, a remote replica or Durable's conversation view: a value kept current by applying ops. + let replica: { text: string } = { text: BASE }; + for (let index = 0; index < COMMITS; index++) { + const change = tracker.beginChange(); + change.state.text = tracker.value.text + piece(index); + const prepared = change.prepare(); + tracker.adopt(prepared); + replica = applyImmutable(replica, prepared.ops); + } + // Comparing here would flatten the replica's text and drop what it retains; compare after measuring. + return { replica, tracker }; + }); + expect(bytes).toBeLessThan(2_000_000); + expect(kept.replica).toEqual(kept.tracker.value); + }); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c1b5fee7a..246c1225e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -20,6 +20,7 @@ - Fixed HTML export showing a blank page for sessions with thousands of entries ([#11225](https://github.com/earendil-works/pi/issues/11225), [#11293](https://github.com/earendil-works/pi/pull/11293)) - Fixed HTML export not showing system messages: the system prompt and tools, and later section updates such as after `/reload`. `pi --export` now includes the system prompt and tools too ([#11261](https://github.com/earendil-works/pi/issues/11261), [#3464](https://github.com/earendil-works/pi/issues/3464), [#11290](https://github.com/earendil-works/pi/pull/11290)) - Fixed aborting a bash command on macOS sometimes running the next command of a list such as `long_step; next_step`. pi now stops the process group before killing it ([#11252](https://github.com/earendil-works/pi/issues/11252), [#11289](https://github.com/earendil-works/pi/pull/11289)) +- Fixed streamed tool calls re-running their call renderer on every argument delta; the interactive mode now renders the latest arguments once per frame, so a large streamed `write` no longer re-highlights its whole content per delta ([#9265](https://github.com/earendil-works/pi/issues/9265)) ## [1.4.0] - 2026-10-21 diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/index.ts b/packages/coding-agent/examples/extensions/custom-provider-anthropic/index.ts index 5f116651f..c0c18f505 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/index.ts +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/index.ts @@ -42,7 +42,7 @@ import { type TextContent, type ThinkingContent, type Tool, - type ToolCall, + ToolCallArgumentsStream, type ToolResultMessage, type TranscriptContext, } from "@earendil-works/pi-ai"; @@ -454,8 +454,9 @@ function streamCustomAnthropic( const anthropicStream = client.messages.stream({ ...params }, { signal: options?.signal }); stream.push({ type: "start", partial: output }); - type Block = (ThinkingContent | TextContent | (ToolCall & { partialJson: string })) & { index: number }; - const blocks = output.content as Block[]; + // Stream state per Anthropic block index, kept off the content blocks so it never reaches partial messages. + // ToolCallArgumentsStream reads each argument delta once and shows the arguments parsed so far. + const openBlocks = new Map(); for await (const event of anthropicStream) { if (event.type === "message_start") { @@ -468,15 +469,12 @@ function streamCustomAnthropic( calculateCost(model, output.usage); } else if (event.type === "content_block_start") { if (event.content_block.type === "text") { - output.content.push({ type: "text", text: "", index: event.index } as any); + output.content.push({ type: "text", text: "" }); + openBlocks.set(event.index, { contentIndex: output.content.length - 1 }); stream.push({ type: "text_start", contentIndex: output.content.length - 1, partial: output }); } else if (event.content_block.type === "thinking") { - output.content.push({ - type: "thinking", - thinking: "", - thinkingSignature: "", - index: event.index, - } as any); + output.content.push({ type: "thinking", thinking: "", thinkingSignature: "" }); + openBlocks.set(event.index, { contentIndex: output.content.length - 1 }); stream.push({ type: "thinking_start", contentIndex: output.content.length - 1, partial: output }); } else if (event.content_block.type === "tool_use") { output.content.push({ @@ -484,15 +482,18 @@ function streamCustomAnthropic( id: event.content_block.id, name: isOAuth ? fromClaudeCodeName(event.content_block.name, tools) : event.content_block.name, arguments: {}, - partialJson: "", - index: event.index, - } as any); + }); + openBlocks.set(event.index, { + contentIndex: output.content.length - 1, + arguments: new ToolCallArgumentsStream(), + }); stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output }); } } else if (event.type === "content_block_delta") { - const index = blocks.findIndex((b) => b.index === event.index); - const block = blocks[index]; - if (!block) continue; + const open = openBlocks.get(event.index); + if (!open) continue; + const index = open.contentIndex; + const block = output.content[index]!; if (event.delta.type === "text_delta" && block.type === "text") { block.text += event.delta.text; @@ -505,11 +506,8 @@ function streamCustomAnthropic( delta: event.delta.thinking, partial: output, }); - } else if (event.delta.type === "input_json_delta" && block.type === "toolCall") { - (block as any).partialJson += event.delta.partial_json; - try { - block.arguments = JSON.parse((block as any).partialJson); - } catch {} + } else if (event.delta.type === "input_json_delta" && block.type === "toolCall" && open.arguments) { + block.arguments = open.arguments.append(event.delta.partial_json); stream.push({ type: "toolcall_delta", contentIndex: index, @@ -520,20 +518,19 @@ function streamCustomAnthropic( block.thinkingSignature = (block.thinkingSignature || "") + (event.delta as any).signature; } } else if (event.type === "content_block_stop") { - const index = blocks.findIndex((b) => b.index === event.index); - const block = blocks[index]; - if (!block) continue; + const open = openBlocks.get(event.index); + if (!open) continue; + openBlocks.delete(event.index); + const index = open.contentIndex; + const block = output.content[index]!; - delete (block as any).index; if (block.type === "text") { stream.push({ type: "text_end", contentIndex: index, content: block.text, partial: output }); } else if (block.type === "thinking") { stream.push({ type: "thinking_end", contentIndex: index, content: block.thinking, partial: output }); - } else if (block.type === "toolCall") { - try { - block.arguments = JSON.parse((block as any).partialJson); - } catch {} - delete (block as any).partialJson; + } else if (block.type === "toolCall" && open.arguments) { + // One full parse of the complete text; malformed arguments are reported in `argumentsError`. + Object.assign(block, open.arguments.finish()); stream.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: output }); } } else if (event.type === "message_delta") { @@ -563,7 +560,6 @@ function streamCustomAnthropic( stream.push({ type: "done", reason: output.stopReason, message: output }); stream.end(); } catch (error) { - for (const block of output.content) delete (block as any).index; output.stopReason = options?.signal?.aborted ? "aborted" : "error"; output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); stream.push({ type: "error", reason: output.stopReason, error: output }); diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index bf1cd27f9..e739fc503 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -54,6 +54,8 @@ export class ToolExecutionComponent extends Container { private cwd: string; private executionStarted = false; private argsComplete = false; + /** `updateArgs()` ran since the last display update; the next render applies it. */ + private argsPending = false; private result?: { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; isError: boolean; @@ -167,9 +169,14 @@ export class ToolExecutionComponent extends Container { }); } + /** + * Show new partial arguments. Applied at the next render, so a call streaming many argument deltas between two + * frames runs its renderer once per frame, not once per delta (#9265). + */ updateArgs(args: any): void { this.args = args; - this.updateDisplay(); + this.argsPending = true; + this.ui.requestRender(); } markExecutionStarted(): void { @@ -225,6 +232,7 @@ export class ToolExecutionComponent extends Container { } override render(width: number): string[] { + if (this.argsPending) this.updateDisplay(); if (this.hideComponent) { return []; } @@ -268,6 +276,7 @@ export class ToolExecutionComponent extends Container { } private updateDisplay(): void { + this.argsPending = false; const bgFn = this.isPartial ? (text: string) => theme.bg("toolPendingBg", text) : this.result?.isError diff --git a/packages/coding-agent/test/tool-execution-component.test.ts b/packages/coding-agent/test/tool-execution-component.test.ts index f334f7d6b..f88091049 100644 --- a/packages/coding-agent/test/tool-execution-component.test.ts +++ b/packages/coding-agent/test/tool-execution-component.test.ts @@ -531,6 +531,59 @@ describe("ToolExecutionComponent parity", () => { expect(rendered).not.toContain("two\n\n"); }); + // #9265: every argument delta re-ran the call renderer; a streamed `write` re-highlighted its whole content each time. + test("renders streamed partial arguments once per frame, with the latest arguments", () => { + const write = createWriteToolDefinition(process.cwd()); + let renderCalls = 0; + const counted: typeof write = { + ...write, + renderCall: (...args) => { + renderCalls++; + return write.renderCall!(...args); + }, + }; + let renderRequests = 0; + const tui = { requestRender: () => renderRequests++ } as unknown as TUI; + const component = new ToolExecutionComponent("write", "tool-9", {}, {}, counted, tui, "."); + const lines = Array.from({ length: 200 }, (_, index) => `const line${index} = ${index};`); + const callsBefore = renderCalls; + let content = ""; + for (const line of lines) { + content += `${line}\n`; + component.updateArgs({ path: "src/file.ts", content }); + } + expect(renderCalls).toBe(callsBefore); + expect(renderRequests).toBeGreaterThan(0); + + const streamed = stripAnsi(component.render(120).join("\n")); + expect(renderCalls - callsBefore).toBe(1); + // The same preview as rendering the final arguments directly. + const direct = new ToolExecutionComponent( + "write", + "tool-10", + { path: "src/file.ts", content }, + {}, + write, + tui, + ".", + ); + expect(streamed).toBe(stripAnsi(direct.render(120).join("\n"))); + + // More deltas before the next frame: one more renderer run, which shows them all. + component.updateArgs({ path: "src/file.ts", content: `${content}const tail = 1;\n` }); + const final = { path: "src/file.ts", content: `${content}const tail = 2;\n` }; + component.updateArgs(final); + const next = stripAnsi(component.render(120).join("\n")); + expect(renderCalls - callsBefore).toBe(2); + expect(next).toContain("201 total"); + expect(next).toBe( + stripAnsi(new ToolExecutionComponent("write", "t", final, {}, write, tui, ".").render(120).join("\n")), + ); + // Rendering again without new arguments does not run the renderer. + component.render(120); + expect(renderCalls - callsBefore).toBe(2); + }); + test("trims trailing blank display lines from read results", () => { const component = new ToolExecutionComponent( "read", diff --git a/packages/durable/CHANGELOG.md b/packages/durable/CHANGELOG.md index 76511515a..41cf5091a 100644 --- a/packages/durable/CHANGELOG.md +++ b/packages/durable/CHANGELOG.md @@ -17,6 +17,12 @@ - Document, conversation view, task graph, and event watches use Chord's `Watch` implementation; behaviour is unchanged. +### Fixed + +- Fixed a conversation watcher (`watchEvents()`, conversation views) holding a copy of a streamed text or tool call per partial commit, through Chord append ops that kept the whole string alive; a 140 KB answer followed by a 140 KB tool call held well over 100 MB ([#9265](https://github.com/earendil-works/pi/issues/9265)) +- Fixed a nested call's error summary keeping the call's whole output alive through a slice of it ([#9265](https://github.com/earendil-works/pi/issues/9265)) +- Fixed task span error messages and codemode call previews keeping the whole text they were cut from alive ([#9265](https://github.com/earendil-works/pi/issues/9265)) + ## [1.4.0] - 2026-10-21 ### Breaking Changes diff --git a/packages/durable/src/codemode/codemode.ts b/packages/durable/src/codemode/codemode.ts index d1f04d223..dba1d1e57 100644 --- a/packages/durable/src/codemode/codemode.ts +++ b/packages/durable/src/codemode/codemode.ts @@ -35,6 +35,7 @@ * call with an error result. */ import { type Context, copyJson, type JsonValue } from "@earendil-works/chord"; +import { ownedSlice } from "@earendil-works/chord/delta"; import type { TextContent } from "@earendil-works/pi-ai"; import { type CodemodeHostCall, @@ -198,7 +199,8 @@ const ARGS_PREVIEW_CHARS = 200; const ERROR_PREVIEW_CHARS = 500; function cut(text: string, max: number): string { - return text.length > max ? `${text.slice(0, max - 3)}...` : text; + // A copy: the row outlives the arguments or error text it previews. + return text.length > max ? `${ownedSlice(text, 0, max - 3)}...` : text; } /** A function scripts call as a top-level global; its result is journaled. */ diff --git a/packages/durable/src/harness/scheduler.ts b/packages/durable/src/harness/scheduler.ts index 0ce1b3de3..8fde30b63 100644 --- a/packages/durable/src/harness/scheduler.ts +++ b/packages/durable/src/harness/scheduler.ts @@ -1,5 +1,6 @@ import { type Context, copyJson, type JsonValue } from "@earendil-works/chord"; import { awaitWithContext, withAbortSignal, withoutAbortSignal, withTelemetry } from "@earendil-works/chord/context"; +import { ownedSlice } from "@earendil-works/chord/delta"; import type { Models } from "@earendil-works/pi-ai"; import { createTypedMetrics, @@ -1801,7 +1802,7 @@ export class TaskScheduler { const message = terminal.status === "orphaned" ? terminal.reason : terminal.error.message; span.setStatus({ status: "error", - error: { name: `task_${terminal.status}`, message: message.slice(0, 500) }, + error: { name: `task_${terminal.status}`, message: ownedSlice(message, 0, 500) }, }); } else if (outcome === "aborted") { span.setStatus({ status: "ok" }); diff --git a/packages/durable/src/harness/tool.ts b/packages/durable/src/harness/tool.ts index 52bc4a3ad..215e8f063 100644 --- a/packages/durable/src/harness/tool.ts +++ b/packages/durable/src/harness/tool.ts @@ -1,6 +1,6 @@ import { type Context, copyJson, type Draft, type JsonRepresentation, type JsonValue } from "@earendil-works/chord"; import { awaitWithContext } from "@earendil-works/chord/context"; -import { overlap } from "@earendil-works/chord/delta"; +import { overlap, ownedSlice } from "@earendil-works/chord/delta"; import { boundOutput, OutputBuffer, @@ -864,9 +864,12 @@ function truncated( /** Error text a nested call's summary keeps. */ const MAX_SUMMARY_ERROR_CHARS = 500; -/** How a nested call ended, for its slot: error text, from its diagnostics or else its `output`, bounded. */ +/** + * How a nested call ended, for its slot: error text, from its diagnostics or else its `output`, bounded. The bound is a + * copy, so the slot never keeps a large output alive through a slice of it. + */ function summaryOf(result: NestedToolExecutionResult, output: Content): NestedToolSummary { - const error = result.isError ? errorTextOf(result, output).slice(0, MAX_SUMMARY_ERROR_CHARS) : ""; + const error = result.isError ? ownedSlice(errorTextOf(result, output), 0, MAX_SUMMARY_ERROR_CHARS) : ""; return { isError: result.isError, ...(result.durationMs === undefined ? {} : { durationMs: result.durationMs }), diff --git a/packages/durable/test/harness-events.test.ts b/packages/durable/test/harness-events.test.ts index 7171f7a86..ce136a674 100644 --- a/packages/durable/test/harness-events.test.ts +++ b/packages/durable/test/harness-events.test.ts @@ -1,3 +1,5 @@ +import { getHeapStatistics, setFlagsFromString } from "node:v8"; +import { runInNewContext } from "node:vm"; import type { Draft } from "@earendil-works/chord"; import { type AssistantMessage, @@ -620,3 +622,90 @@ describe("agent events", () => { await harness.close(context); }); }); + +const gc = (() => { + setFlagsFromString("--expose-gc"); + return runInNewContext("gc") as () => void; +})(); + +/** Heap bytes in use after a full collection. */ +async function heapInUse(): Promise { + for (let round = 0; round < 3; round++) { + await drained(); + gc(); + } + return getHeapStatistics().used_heap_size; +} + +// #9265 +describe("streamed partials", () => { + it("streams a tool-call partial as toolcall_delta appends at its argument paths", async () => { + const setup = chatSetup({ tokensPerSecond: 500, tokenSize: { min: 1, max: 1 } }); + setup.settings.progress = { partialIntervalMs: 20, outputIntervalMs: 100 }; + const args = { path: "src/file.ts", content: "const a = 1;\n".repeat(150) }; + setup.faux.setResponses([ + fauxAssistantMessage([fauxToolCall("missing", args, { id: "c1" })], { stopReason: "toolUse" }), + fauxAssistantMessage([fauxText("done")]), + ]); + const { harness, root } = await openChat(new MemoryStorage(), setup); + const partials = partialsOf(harness); + const { stream, events } = await listen(harness, root); + await (await root.submit({ type: "input", content: "go" }, context)).wait(context); + await drained(); + const changes = events().flatMap((event) => (event.type === "message_update" ? event.changes : [])); + const deltas = changes.filter((change) => change.type === "toolcall_delta"); + expect(deltas.length).toBeGreaterThan(10); + expect(deltas.every((change) => ["path", "content"].includes(String(change.path)))).toBe(true); + // The call is resent whole only when a member appears (at most once per argument); growing strings are appends. + // With the raw argument text on the block, every commit resent the whole call. + expect(changes.filter((change) => change.type === "message")).toEqual([]); + expect(changes.filter((change) => change.type === "block").length).toBeLessThanOrEqual(2); + // The committed partials carry only the parsed arguments, never the raw argument text. + for (const partial of partials) { + for (const block of partial.content) { + expect(Object.keys(block).sort()).toEqual(["arguments", "id", "name", "type"]); + } + } + await stream.stop(); + await harness.close(context); + }); + + it("keeps a watcher's memory proportional to a long streamed text and tool call, not to their commits", async () => { + const setup = chatSetup({ tokensPerSecond: 16_000, tokenSize: { min: 16, max: 16 } }); + setup.settings.progress = { partialIntervalMs: 1, outputIntervalMs: 100 }; + const line = (index: number) => `line ${index}: some streamed source text with "quotes" and \\ escapes\n`; + const text = Array.from({ length: 2_000 }, (_, index) => line(index)).join(""); + const content = Array.from({ length: 2_000 }, (_, index) => line(index)).join(""); + setup.faux.setResponses([ + fauxAssistantMessage([fauxText(text), fauxToolCall("missing", { path: "a.ts", content }, { id: "c1" })], { + stopReason: "toolUse", + }), + fauxAssistantMessage([fauxText("done")]), + ]); + const { harness, root } = await openChat(new MemoryStorage(), setup); + const { stream, batches } = await listen(harness, root); + const before = await heapInUse(); + const submission = await root.submit({ type: "input", content: "go" }, context); + const partialContent = async () => + (await harness.snapshot(LiveDoc, root.id, context))?.generation?.message?.content; + const shown = async (index: number): Promise => { + const block = (await partialContent())?.[index]; + if (block?.type === "text") return block.text.length; + return block?.type === "toolCall" ? String(block.arguments.content ?? "").length : 0; + }; + const retained: number[] = []; + // Measure while the text, then the tool call, is almost complete: the watcher's view holds the most then. + await waitFor(async () => (await shown(0)) > text.length * 0.95, 30_000); + retained.push((await heapInUse()) - before); + await waitFor(async () => (await shown(1)) > content.length * 0.95, 30_000); + retained.push((await heapInUse()) - before); + await submission.wait(context); + const commits = batches.length; + expect(commits).toBeGreaterThan(500); + // The two 140 KB strings, the watcher's recorded batches and the rest of the stream: about 3 MB. Sliced append ops + // made the watcher's view hold a copy of the text per commit: well over 100 MB. + for (const bytes of retained) expect(bytes).toBeLessThan(20_000_000); + await stream.stop(); + await harness.close(context); + }, 60_000); +}); diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index 412df6f3f..74908a649 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - OAuth metadata parsing and `startAuthorization()` now accept only `http:` and `https:` URLs. Before, a server could supply a `file:` or custom protocol authorization endpoint (for example `search-ms:`), which the platform URL handler would launch on sign-in. +- Fixed the excerpt of an HTTP error body in Streamable HTTP errors keeping the whole response body alive ([#9265](https://github.com/earendil-works/pi/issues/9265)) ## [1.4.0] - 2026-10-21 diff --git a/packages/mcp/src/transports/streamable-http.ts b/packages/mcp/src/transports/streamable-http.ts index af9c922d2..5ea76682e 100644 --- a/packages/mcp/src/transports/streamable-http.ts +++ b/packages/mcp/src/transports/streamable-http.ts @@ -338,7 +338,8 @@ export class StreamableHttpTransport extends TransportEvents implements McpTrans private async checkResponse(response: Response): Promise { if (response.ok) return; - const body = (await response.text().catch(() => "")).slice(0, MAX_ERROR_BODY_BYTES); + // A copy, not a slice: V8 slices keep the whole body alive, and the error keeps this excerpt. + const body = `${(await response.text().catch(() => "")).slice(0, MAX_ERROR_BODY_BYTES)} `.slice(0, -1); if (response.status === 401) throw new McpAuthRequiredError(response, body); if (response.status === 404 && this.sessionIdValue) throw new McpSessionExpiredError(body); throw new McpHttpError(response.status, describeHttpFailure(response.status, body), body); From da412b0392e30def79a31768621605cc11c1d33f Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 24 Oct 2026 08:21:41 +0000 Subject: [PATCH 30/33] feat(chord,durable): appendText for cheap string appends; Durable partial commits append instead of reassigning Follow-up to #9265. - chord: Delta appendText(draft, key, text) / StringKeys record an append op without the O(length) prefix compare that a plain `draft[key] += text` costs in prepare() - durable: partial commits append text and thinking deltas with appendText (assignJson plan, checked by length), and apply tool-call argument changes from their own StreamingJsonParser of the raw toolcall_delta text instead of reassigning the arguments snapshot - durable "." entry-graph budget 73 -> 76 for the parser it now imports --- packages/chord/CHANGELOG.md | 1 + packages/chord/src/delta/diff.ts | 3 +- packages/chord/src/delta/index.ts | 2 +- packages/chord/src/delta/tracker.ts | 48 +++- packages/chord/test/delta-append-text.test.ts | 190 ++++++++++++++++ packages/durable/CHANGELOG.md | 1 + packages/durable/src/harness/generation.ts | 29 ++- packages/durable/src/harness/json.ts | 33 ++- .../durable/src/harness/partial-changes.ts | 166 ++++++++++++++ packages/durable/test/harness-events.test.ts | 48 +++- .../test/harness-partial-changes.test.ts | 213 ++++++++++++++++++ scripts/check-entry-graphs.mjs | 6 +- 12 files changed, 719 insertions(+), 21 deletions(-) create mode 100644 packages/chord/test/delta-append-text.test.ts create mode 100644 packages/durable/src/harness/partial-changes.ts create mode 100644 packages/durable/test/harness-partial-changes.test.ts diff --git a/packages/chord/CHANGELOG.md b/packages/chord/CHANGELOG.md index aa6634e23..bce91731e 100644 --- a/packages/chord/CHANGELOG.md +++ b/packages/chord/CHANGELOG.md @@ -14,6 +14,7 @@ - `Watchable`: a value that changes and can be watched (`value`, `watch(context)` returning a `WatchHandle`), the common interface of documents, derives and other observable values. - `NativeWatchHandle` and its implementation `NativeWatch`: the same lifecycle for in-process values, with listener `(value, context)` and no ops; an overflow collapses to the newest value. - `ownedSlice(text, start, end?)` in `@earendil-works/chord/delta`: a slice of a string that does not keep the whole string alive ([#9265](https://github.com/earendil-works/pi/issues/9265)) +- `appendText(draft, key, text)` in `@earendil-works/chord/delta`: append to a string in a draft at O(text) cost, recorded as an append op without comparing the old and new strings (a plain `draft[key] += text` makes `prepare()` read the whole string, O(length) per change); `StringKeys` names the keys it accepts ([#9265](https://github.com/earendil-works/pi/issues/9265)) ### Fixed diff --git a/packages/chord/src/delta/diff.ts b/packages/chord/src/delta/diff.ts index ff0bf2121..5fbebe05b 100644 --- a/packages/chord/src/delta/diff.ts +++ b/packages/chord/src/delta/diff.ts @@ -60,7 +60,8 @@ const permutation = (before: readonly JsonValue[], after: readonly JsonValue[]): return result; }; -// O(length): comparing two revisions reads both strings, and slicing a rope makes V8 flatten all of it. +// O(length): comparing two revisions reads both strings, and slicing a rope makes V8 flatten all of it. A writer that +// knows what it appended records it with appendText() instead of diffing. const emitString = (before: string, after: string, path: NonEmptyPath, operations: Op[]): void => { if (before === after) return; if (after.length > before.length && after.slice(0, before.length) === before) { diff --git a/packages/chord/src/delta/index.ts b/packages/chord/src/delta/index.ts index 11d94b47c..c961cf5c9 100644 --- a/packages/chord/src/delta/index.ts +++ b/packages/chord/src/delta/index.ts @@ -123,7 +123,7 @@ export function ownedSlice(text: string, start: number, end = text.length): stri export { diffRevisions } from "./diff.ts"; export type { Draft } from "./draft.ts"; -export { type Change, type Prepared, type Tracker, track } from "./tracker.ts"; +export { appendText, type Change, type Prepared, type StringKeys, type Tracker, track } from "./tracker.ts"; // ─── Path safety ───────────────────────────────────────────────────────────── diff --git a/packages/chord/src/delta/tracker.ts b/packages/chord/src/delta/tracker.ts index bb267bd78..db5eb6b66 100644 --- a/packages/chord/src/delta/tracker.ts +++ b/packages/chord/src/delta/tracker.ts @@ -14,6 +14,11 @@ import { export type { Draft, JsonValue, Op }; +/** Keys of `T` whose value type is exactly `string`: required, and not a string literal type. */ +export type StringKeys = T extends readonly unknown[] + ? never + : { [Key in keyof T]: string extends T[Key] ? (T[Key] extends string ? Key : never) : never }[keyof T]; + type Primitive = null | boolean | number | string; type Container = JsonValue[] | Record; type Stored = JsonValue; @@ -77,6 +82,11 @@ type OverlayNode = { deleteKey?: string; deletes?: Set; readded?: Set; + /** + * Keys whose only changes in this transaction are `appendText()` calls, with the text appended so far. `prepare()` + * records each as one append op without comparing the old and new strings. + */ + appends?: Map; array?: ArrayOverlay; dirty?: boolean; subtreeDirty?: boolean; @@ -330,6 +340,33 @@ export function track(initial: T): Tracker { return new TrackerImpl(initial); } +/** + * Append `text` to the string at `draft[key]`, costing O(text.length). The change is recorded as one append op + * (several appends to the same key in one change join into one op) without comparing the old and new strings. A plain + * assignment such as `draft[key] += text` gives the same op, but `prepare()` then has to check that the new string + * extends the old one, which reads the whole string: O(length) per change. A plain assignment or deletion of the key in + * the same change falls back to that check. + */ +export function appendText(draft: Draft, key: StringKeys, text: string): void { + // Trust boundary: JavaScript callers can pass any object and key; the types cover TypeScript callers. + const node = (draft as Record)[NODE] as OverlayNode | undefined; + if (node === undefined) throw new TypeError("appendText() needs a draft"); + assertWritable(node.context); + const name = String(key); + const current = Array.isArray(nodeBase(node)) || !objectHas(node, name) ? undefined : objectValue(node, name); + if (typeof current !== "string") throw new TypeError(`appendText() needs a string at ${JSON.stringify(name)}`); + if (text.length === 0) return; + // The op and the new value keep the text; a copy, so they never keep a larger string the caller cut it from alive. + const owned = ownedSlice(text, 0); + const appended = node.appends?.get(name) ?? (hasObjectWrite(node, name) ? undefined : ""); + setObjectWrite(node, name, current + owned); + markDirty(node); + if (appended !== undefined) { + node.appends ??= new Map(); + node.appends.set(name, appended + owned); + } +} + function materializePrepared(context: OverlayContext): PreparedImpl { const operations = ensureOperations(context); const base = context.baseValue as T; @@ -588,6 +625,7 @@ function setProperty(node: OverlayNode, property: PropertyKey, supplied: unknown const current = objectValue(node, key); const wasDeleted = isObjectDeleted(node, key); if (!wasDeleted && !isContainer(stored) && current === stored) return true; + node.appends?.delete(key); setObjectWrite(node, key, stored); if (wasDeleted && Object.hasOwn(nodeBase(node), key)) { if (node.readded === undefined) node.readded = new Set(); @@ -604,6 +642,7 @@ function deleteProperty(node: OverlayNode, property: PropertyKey): boolean { if (Array.isArray(nodeBase(node))) throw new TypeError("Overlay arrays cannot contain holes"); const key = String(property); if (!objectHas(node, key)) return true; + node.appends?.delete(key); deleteObjectWrite(node, key); node.readded?.delete(key); setObjectDeletion(node, key); @@ -1846,6 +1885,11 @@ function hasPlacementAncestor(node: OverlayNode): boolean { function emitObjectWrite(node: OverlayNode, path: Path, operations: Op[], key: string, value: Stored): boolean { const nextPath = [...path, key] as unknown as NonEmptyPath; + const appended = node.appends?.get(key); + if (appended !== undefined) { + operations.push(["a", nextPath, appended]); + return false; + } const before = node.readded?.has(key) || !Object.hasOwn(nodeBase(node), key) ? undefined @@ -2054,8 +2098,8 @@ function emitChangedValue( if (isContainer(before) && isContainer(after) && equalTrustedJson(before, after)) return false; if (typeof before === "string" && typeof after === "string") { // O(length): any slice of a rope makes V8 flatten all of it, so this prefix check copies the whole string once per - // change, even when the writer only appended. The op text gets a copy of its own, so it does not keep the whole - // of `after` alive. + // change, even when the writer only appended. Streaming writers use appendText(), which skips it. The op text gets + // a copy of its own, so it does not keep the whole of `after` alive. if (after.length > before.length && after.slice(0, before.length) === before) { operations.push(["a", path, ownedSlice(after, before.length)]); return true; diff --git a/packages/chord/test/delta-append-text.test.ts b/packages/chord/test/delta-append-text.test.ts new file mode 100644 index 000000000..51822c611 --- /dev/null +++ b/packages/chord/test/delta-append-text.test.ts @@ -0,0 +1,190 @@ +import { GCProfiler, getHeapStatistics, setFlagsFromString } from "node:v8"; +import { runInNewContext } from "node:vm"; +import { describe, expect, it } from "vitest"; +import { appendText, applyImmutable, type Draft, type Op, type StringKeys, track } from "../src/delta/index.ts"; + +// #9265: streaming writers appended to growing strings by assignment, and the tracker's prefix check copied the whole +// string on every change. + +type Block = { type: "text"; text: string; signature?: string; tags: string[] }; +type Doc = { title: string; content: Block[]; meta: { note: string } }; + +function doc(): Doc { + return { title: "t", content: [{ type: "text", text: "hello", tags: ["a"] }], meta: { note: "n" } }; +} + +/** Run `mutate` in one change, adopt it, and return its ops; checks that replaying the ops gives the new value. */ +function commit(tracker: ReturnType>, mutate: (draft: Draft) => void): Op[] { + const base = tracker.value; + const change = tracker.beginChange(); + mutate(change.state); + const prepared = change.prepare(); + expect(applyImmutable(base, prepared.ops)).toEqual(prepared.value); + tracker.adopt(prepared); + return [...prepared.ops]; +} + +describe("appendText", () => { + it("records an append op and the appended value", () => { + const tracker = track(doc()); + const before = tracker.value; + expect(commit(tracker, (draft) => appendText(draft.content[0]!, "text", " world"))).toEqual([ + ["a", ["content", 0, "text"], " world"], + ]); + expect(tracker.value.content[0]!.text).toBe("hello world"); + expect(before.content[0]!.text).toBe("hello"); + expect(commit(tracker, (draft) => appendText(draft, "title", "!"))).toEqual([["a", ["title"], "!"]]); + expect(commit(tracker, (draft) => appendText(draft.meta, "note", "ote"))).toEqual([ + ["a", ["meta", "note"], "ote"], + ]); + expect(tracker.value).toEqual({ + title: "t!", + content: [{ type: "text", text: "hello world", tags: ["a"] }], + meta: { note: "note" }, + }); + }); + + it("joins several appends to one key in one change into one op, next to other changes", () => { + const tracker = track(doc()); + const ops = commit(tracker, (draft) => { + appendText(draft.content[0]!, "text", " a"); + draft.title = "new"; + appendText(draft.content[0]!, "text", " b"); + appendText(draft.content[0]!, "text", ""); + appendText(draft.content[0]!, "text", " c"); + }); + expect(ops).toContainEqual(["a", ["content", 0, "text"], " a b c"]); + expect(ops).toContainEqual(["s", ["title"], "new"]); + expect(ops).toHaveLength(2); + expect(tracker.value.content[0]!.text).toBe("hello a b c"); + }); + + it("falls back to the string comparison when the same key is also assigned or deleted", () => { + const tracker = track(doc()); + expect( + commit(tracker, (draft) => { + appendText(draft.content[0]!, "text", " x"); + draft.content[0]!.text += " y"; + }), + ).toEqual([["a", ["content", 0, "text"], " x y"]]); + expect( + commit(tracker, (draft) => { + appendText(draft.content[0]!, "text", " z"); + draft.content[0]!.text = "replaced"; + }), + ).toEqual([["s", ["content", 0, "text"], "replaced"]]); + expect( + commit(tracker, (draft) => { + draft.title = "assigned"; + appendText(draft, "title", " then appended"); + }), + ).toEqual([["s", ["title"], "assigned then appended"]]); + expect( + commit(tracker, (draft) => { + appendText(draft.meta, "note", "x"); + delete (draft.meta as { note?: string }).note; + }), + ).toEqual([["d", ["meta", "note"]]]); + }); + + it("appends inside a block the same change inserted or replaced", () => { + const tracker = track(doc()); + commit(tracker, (draft) => { + draft.content.push({ type: "text", text: "new", tags: [] }); + appendText(draft.content[1]!, "text", " block"); + draft.content[0] = { type: "text", text: "other", tags: [] }; + appendText(draft.content[0]!, "text", " one"); + }); + expect(tracker.value.content.map((block) => block.text)).toEqual(["other one", "new block"]); + }); + + it("rejects what a JavaScript caller can get wrong", () => { + const tracker = track(doc()); + const append = appendText as (draft: unknown, key: string, text: string) => void; + expect(() => append({ text: "plain object" }, "text", "x")).toThrow("appendText() needs a draft"); + const change = tracker.beginChange(); + expect(() => append(change.state, "content", "x")).toThrow('appendText() needs a string at "content"'); + expect(() => append(change.state, "missing", "x")).toThrow('appendText() needs a string at "missing"'); + expect(() => append(change.state.content, "0", "x")).toThrow('appendText() needs a string at "0"'); + const block = change.state.content[0]!; + change.prepare(); + expect(() => appendText(block, "text", "x")).toThrow("Cannot use a settled overlay"); + const aborted = tracker.beginChange(); + const state = aborted.state; + aborted.abort(); + expect(() => appendText(state, "title", "x")).toThrow("Cannot use a settled overlay"); + }); + + it("accepts only keys whose type is exactly string", () => { + type Keys = StringKeys; + const keys: Keys[] = ["text"]; + expect(keys).toEqual(["text"]); + const tracker = track(doc()); + const change = tracker.beginChange(); + const block = change.state.content[0]!; + // @ts-expect-error a string literal type + expect(() => appendText(block, "type", "x")).not.toThrow(); + // @ts-expect-error an optional string + expect(() => appendText(block, "signature", "x")).toThrow(); + // @ts-expect-error an array + expect(() => appendText(block, "tags", "x")).toThrow(); + // @ts-expect-error array keys + expect(() => appendText(change.state.content, 0, "x")).toThrow(); + change.abort(); + }); + + it("keeps an op text that does not hold a larger string it was cut from alive", () => { + setFlagsFromString("--expose-gc"); + const gc = runInNewContext("gc") as () => void; + gc(); + const before = getHeapStatistics().used_heap_size; + const tracker = track(doc()); + const ops: Op[] = []; + for (let index = 0; index < 50; index++) { + const source = `${index}`.repeat(100_000); + ops.push(...commit(tracker, (draft) => appendText(draft.content[0]!, "text", source.slice(0, 40)))); + } + gc(); + expect(getHeapStatistics().used_heap_size - before).toBeLessThan(1_000_000); + expect(ops).toHaveLength(50); + }); +}); + +describe("appendText cost", () => { + /** Bytes `run` allocates, counting what collections during it freed. */ + function allocatedBytes(run: () => void): number { + const profiler = new GCProfiler(); + profiler.start(); + const before = getHeapStatistics().used_heap_size; + run(); + const after = getHeapStatistics().used_heap_size; + let freed = 0; + for (const collection of profiler.stop().statistics) { + freed += collection.beforeGC.heapStatistics.usedHeapSize - collection.afterGC.heapStatistics.usedHeapSize; + } + return after - before + freed; + } + + /** One change per 40-character chunk, appended to one streamed text until it has `length` characters. */ + function stream(length: number): number { + const tracker = track(doc()); + const chunk = "0123456789abcdefghij0123456789abcdefghij"; + return allocatedBytes(() => { + for (let streamed = 0; streamed < length; streamed += chunk.length) { + const change = tracker.beginChange(); + appendText(change.state.content[0]!, "text", chunk); + tracker.adopt(change.prepare()); + } + }); + } + + it("costs O(chunk) per change: a 500 KB stream allocates linearly", () => { + const small = stream(50_000); + const large = stream(500_000); + // Ten times the text: linear growth is about 10x. Measured about 10 KB per change, mostly the change itself. + expect(large / small).toBeLessThan(20); + expect(large / 12_500).toBeLessThan(40_000); + // The same stream through `draft.text += chunk` makes prepare() compare the whole string on every change: on + // Node 24, 6 GB allocated and about 2 s for 500 KB, against 120 MB and under 0.1 s here. Not asserted. + }); +}); diff --git a/packages/durable/CHANGELOG.md b/packages/durable/CHANGELOG.md index 41cf5091a..ea1481081 100644 --- a/packages/durable/CHANGELOG.md +++ b/packages/durable/CHANGELOG.md @@ -22,6 +22,7 @@ - Fixed a conversation watcher (`watchEvents()`, conversation views) holding a copy of a streamed text or tool call per partial commit, through Chord append ops that kept the whole string alive; a 140 KB answer followed by a 140 KB tool call held well over 100 MB ([#9265](https://github.com/earendil-works/pi/issues/9265)) - Fixed a nested call's error summary keeping the call's whole output alive through a slice of it ([#9265](https://github.com/earendil-works/pi/issues/9265)) - Fixed task span error messages and codemode call previews keeping the whole text they were cut from alive ([#9265](https://github.com/earendil-works/pi/issues/9265)) +- Fixed partial commits of a streamed answer copying every growing text, thinking and tool-call argument string in full on each commit (about 20 s of CPU and a 100 MB heap peak for a 500 KB tool call): they now append new text with Chord's `appendText()`, and apply the argument changes of their own parse of the streamed argument text ([#9265](https://github.com/earendil-works/pi/issues/9265)) ## [1.4.0] - 2026-10-21 diff --git a/packages/durable/src/harness/generation.ts b/packages/durable/src/harness/generation.ts index c8d26791c..d617713bd 100644 --- a/packages/durable/src/harness/generation.ts +++ b/packages/durable/src/harness/generation.ts @@ -31,8 +31,8 @@ import type { import { addTools, sectionExtension } from "./agent.ts"; import { compactionPolicy, createCompaction, estimateContext, selectCut } from "./compaction.ts"; import { applyBoundary, type Boundary, prepareBoundary, withdrawQueuedInputs } from "./inbox.ts"; -import { assignJson } from "./json.ts"; import { endRun, LiveDoc, type LiveState, type ToolSlot } from "./live.ts"; +import { PartialChanges } from "./partial-changes.ts"; import { planSystemEntries, renderSections, replaySections } from "./prompt.ts"; import { ensureProviderSessionId } from "./provider.ts"; import { appendToolResult, harnessError, ToolTask, type ToolTaskResult } from "./tool.ts"; @@ -441,7 +441,8 @@ export async function convertPartial(tx: Tx, live: Draft, conversatio /** * Stream one request and return the terminal message. Partials commit as trailing writes at most every * `progress.partialIntervalMs` (default 100 ms) with one commit in flight; `finally` stops the throttle and awaits that - * commit, so no stale partial lands after the outcome. + * commit, so no stale partial lands after the outcome. A commit appends what text and thinking grew by and applies + * the argument changes since the last one (`PartialChanges`), so its cost follows the new text, not the strings' length. */ async function streamResponse( runtime: Runtime, @@ -456,20 +457,27 @@ async function streamResponse( let timer: (() => void) | undefined; let inFlight: Promise | undefined; let stopped = false; + const changes = new PartialChanges(); const flush = (): void => { timer = undefined; const partial = pending; pending = undefined; if (partial === undefined || stopped) return; inFlight = (async () => { - // Copy synchronously: the provider keeps mutating its partial. - const message = copyJson(partial, { omitUndefinedProperties: true }); - await runtime.commit(async (tx) => { - const live = await tx.doc(LiveDoc, runtime.conversationId); - live.generation ??= { attempt }; - assignJson(live.generation as Draft>, "message", message); - return undefined; - }, context); + // Copy synchronously: the provider keeps mutating its partial. The changes since the last commit belong to it. + const message = copyJson(partial, { omitUndefinedProperties: true }) as { content: JsonValue[] }; + const taken = changes.take(message); + try { + await runtime.commit(async (tx) => { + const live = await tx.doc(LiveDoc, runtime.conversationId); + live.generation ??= { attempt }; + changes.commit(live.generation as Draft>, message, taken); + return undefined; + }, context); + } catch (error) { + changes.restore(taken); + throw error; + } })() .catch((error: unknown) => { // Rejections after an abort mark or close are expected; the committed state stays consistent. @@ -486,6 +494,7 @@ async function streamResponse( // A partial without content, such as pi-ai's opening `start` event, shows nothing; a deferred response // never gets past it, so it never leaves a partial. if (event.type === "done" || event.type === "error" || event.partial.content.length === 0) continue; + changes.observe(event); pending = event.partial; if (timer === undefined && inFlight === undefined) timer = runtime.setTimer(flush, interval); } diff --git a/packages/durable/src/harness/json.ts b/packages/durable/src/harness/json.ts index 66cfcd891..c96119fe8 100644 --- a/packages/durable/src/harness/json.ts +++ b/packages/durable/src/harness/json.ts @@ -1,28 +1,53 @@ import type { JsonValue } from "@earendil-works/chord"; +import { appendText, type Draft } from "@earendil-works/chord/delta"; type JsonContainer = Record | JsonValue[]; +/** How `assignJson()` writes one value instead of assigning it leaf by leaf. */ +export type AssignPlan = + /** The string grew by `text` since it was last assigned: append it, when the lengths agree. */ + | { readonly kind: "append"; readonly text: string } + /** Leave the value as it is; the caller writes it. */ + | { readonly kind: "keep" } + /** Plans for some members or items of a container. */ + | { readonly kind: "fields"; readonly fields: ReadonlyMap }; + /** * Assign `value` at `target[key]` leaf by leaf. Chord records a container assignment as one full set and only emits an * append when a string leaf is reassigned with a longer string, so writing the partial whole would store and publish the - * complete message on every flush. + * complete message on every flush. That append costs a compare of the whole string, though; where `plan` says what a + * string member grew by, and the committed string plus that text has the new string's length, the text is appended + * with `appendText()` instead. */ -export function assignJson(target: JsonContainer, key: string | number, value: JsonValue): void { +export function assignJson(target: JsonContainer, key: string | number, value: JsonValue, plan?: AssignPlan): void { + if (plan?.kind === "keep") return; const slots = target as Record; const current = slots[key]; + const fields = plan?.kind === "fields" ? plan.fields : undefined; if (isRecord(current) && isRecord(value)) { for (const name of Object.keys(current)) if (!Object.hasOwn(value, name)) delete current[name]; - for (const [name, child] of Object.entries(value)) assignJson(current, name, child); + for (const [name, child] of Object.entries(value)) assignJson(current, name, child, fields?.get(name)); return; } if (Array.isArray(current) && Array.isArray(value) && current.length <= value.length) { const items = current as JsonValue[]; for (let index = 0; index < value.length; index++) { - if (index < items.length) assignJson(items, index, value[index]!); + if (index < items.length) assignJson(items, index, value[index]!, fields?.get(index)); else items.push(value[index]!); } return; } + if ( + plan?.kind === "append" && + typeof current === "string" && + typeof value === "string" && + !Array.isArray(target) && + current.length + plan.text.length === value.length + ) { + // The cast names what the checks above established: `target[key]` is a string. + appendText(target as Draft>, String(key), plan.text); + return; + } if (current !== value) slots[key] = value; } diff --git a/packages/durable/src/harness/partial-changes.ts b/packages/durable/src/harness/partial-changes.ts new file mode 100644 index 000000000..355a9c93c --- /dev/null +++ b/packages/durable/src/harness/partial-changes.ts @@ -0,0 +1,166 @@ +// What a streamed answer changed in its partial message between two partial commits, so a commit costs the new text. +import type { JsonValue } from "@earendil-works/chord"; +import { appendText, type Draft } from "@earendil-works/chord/delta"; +import type { AssistantMessageEvent } from "@earendil-works/pi-ai"; +import { type JsonChange, StreamingJsonParser } from "@earendil-works/pi-ai/utils/streaming-json"; +import { type AssignPlan, assignJson } from "./json.ts"; + +type JsonContainer = Record | JsonValue[]; + +/** What one partial commit writes besides the partial message itself; from `PartialChanges.take()`. */ +export type PartialCommit = { + /** The plan `assignJson()` writes the message with: text appends, and the arguments it leaves to `changes`. */ + readonly plan: AssignPlan; + /** Each tracked tool call by content index: its argument changes since the last commit, in order, and its snapshot. */ + readonly toolCalls: ReadonlyMap; +}; + +/** What a tracked tool call changed since the last commit, and whether any commit took its changes yet. */ +type ToolCallState = { readonly parser: StreamingJsonParser; changes: JsonChange[]; committed: boolean }; + +/** + * Collects, from a response's events, what changed in its partial message since the last commit, so the commit appends + * new text instead of reassigning growing strings. Reassigning makes Chord compare the old and new string, which costs + * O(length) per commit. + * + * - Text and thinking: the `text_delta` and `thinking_delta` events. The commit appends them where the committed string + * plus the deltas has the new string's length, and compares otherwise. A block's `*_end` event drops them, because + * providers may replace the text when a block ends. + * - Tool-call arguments: a `StreamingJsonParser` per call, fed with the raw argument text of each `toolcall_delta`, + * reports what each piece changed. The commit applies those changes to the committed arguments in order and leaves + * the arguments out of the message assignment, so the committed arguments are exactly the parsed text so far. + * `toolcall_end` ends the tracking; the call's last commit assigns its final arguments. + */ +export class PartialChanges { + #text = new Map(); + readonly #toolCalls = new Map(); + + /** Record what `event` changed. */ + observe(event: AssistantMessageEvent): void { + switch (event.type) { + case "text_delta": + case "thinking_delta": { + const field = event.type === "text_delta" ? "text" : "thinking"; + const pending = this.#text.get(event.contentIndex); + if (pending?.field === field) pending.text += event.delta; + else this.#text.set(event.contentIndex, { field, text: event.delta }); + return; + } + case "toolcall_start": + this.#toolCalls.set(event.contentIndex, { + parser: new StreamingJsonParser(), + changes: [], + committed: false, + }); + return; + case "toolcall_delta": { + const call = this.#toolCalls.get(event.contentIndex); + if (call === undefined) return; + for (const change of call.parser.append(event.delta)) { + // Arguments are an object: any other root shows as no arguments, so the snapshot is committed instead. + if (change.path.length === 0 && !isRecord(change.kind === "set" ? value(change) : null)) { + this.#toolCalls.delete(event.contentIndex); + return; + } + call.changes.push(change); + } + return; + } + case "text_end": + case "thinking_end": + this.#text.delete(event.contentIndex); + return; + case "toolcall_end": + this.#toolCalls.delete(event.contentIndex); + return; + } + } + + /** + * Take what changed since the last call, for a commit of `message`, a copy of the partial. Clears the arguments of + * tracked tool calls in `message`: the commit writes them from the changes. Call `restore()` if the commit fails. + */ + take(message: { readonly content: readonly JsonValue[] }): PartialCommit { + const blocks = new Map(); + for (const [index, { field, text }] of this.#text) { + blocks.set(index, { kind: "fields", fields: new Map([[field, { kind: "append", text }]]) }); + } + this.#text = new Map(); + const toolCalls = new Map(); + for (const [index, call] of this.#toolCalls) { + const block = message.content[index] as Record; + toolCalls.set(index, { changes: call.changes, snapshot: block.arguments! }); + block.arguments = {}; + blocks.set(index, { kind: "fields", fields: new Map([["arguments", { kind: "keep" }]]) }); + call.changes = []; + } + return { + plan: { kind: "fields", fields: new Map([["content", { kind: "fields", fields: blocks }]]) }, + toolCalls, + }; + } + + /** + * Write `message` with what `commit` took into `generation.message`: the message leaf by leaf with the plan, then the + * argument changes. A tracked call whose block is new here gets its arguments from its changes, which then start at + * its first piece; when the committed message lost a call whose changes were already committed (another writer reset + * it), the call falls back to its snapshot from now on. + */ + commit(generation: Draft>, message: JsonValue, commit: PartialCommit): void { + const committedBlocks = (generation.message as { content?: unknown[] } | undefined)?.content?.length ?? 0; + const apply: [number, readonly JsonChange[]][] = []; + for (const [index, { changes, snapshot }] of commit.toolCalls) { + const call = this.#toolCalls.get(index); + if (call === undefined) continue; + if (index >= committedBlocks && call.committed) { + // The earlier changes are not in the committed message: commit the snapshot and stop tracking the call. + this.#toolCalls.delete(index); + (message as { content: Record[] }).content[index]!.arguments = snapshot; + continue; + } + call.committed = true; + apply.push([index, changes]); + } + assignJson(generation, "message", message, commit.plan); + const content = (generation.message as unknown as { content: Draft>[] }).content; + for (const [index, changes] of apply) { + for (const change of changes) applyChange(content[index]!, change); + } + } + + /** Put back what a failed commit took, so the next commit applies it first. */ + restore(commit: PartialCommit): void { + for (const [index, { changes }] of commit.toolCalls) { + const call = this.#toolCalls.get(index); + if (call !== undefined) call.changes = [...changes, ...call.changes]; + } + } +} + +/** Apply one argument change to the committed arguments of `block`. */ +function applyChange(block: Draft>, change: JsonChange): void { + const key = change.path.at(-1); + if (key === undefined) { + // Only an object root is tracked, and only `set` starts it. + if (change.kind === "set") block.arguments = value(change); + return; + } + let parent = block.arguments as Draft; + for (const segment of change.path.slice(0, -1)) { + parent = (parent as Record>)[segment]!; + } + const slots = parent as Record; + if (change.kind === "set") slots[key] = value(change); + // appendText() takes object members; a string in an array is reassigned, which compares it whole. + else if (Array.isArray(parent)) slots[key] = `${slots[key] as string}${change.text}`; + else appendText(parent as Draft>, String(key), change.text); +} + +/** The value a `set` change reports: JSON data, which pi-ai types with read-only arrays. */ +function value(change: Extract): JsonValue { + return change.value as JsonValue; +} + +function isRecord(value: JsonValue | undefined): boolean { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/durable/test/harness-events.test.ts b/packages/durable/test/harness-events.test.ts index ce136a674..e12d7d295 100644 --- a/packages/durable/test/harness-events.test.ts +++ b/packages/durable/test/harness-events.test.ts @@ -1,4 +1,4 @@ -import { getHeapStatistics, setFlagsFromString } from "node:v8"; +import { GCProfiler, getHeapStatistics, setFlagsFromString } from "node:v8"; import { runInNewContext } from "node:vm"; import type { Draft } from "@earendil-works/chord"; import { @@ -628,6 +628,21 @@ const gc = (() => { return runInNewContext("gc") as () => void; })(); +/** Bytes `run` allocates, counting what collections during it freed. */ +async function allocatedBytes(run: () => Promise): Promise { + gc(); + const profiler = new GCProfiler(); + profiler.start(); + const before = getHeapStatistics().used_heap_size; + await run(); + const after = getHeapStatistics().used_heap_size; + let freed = 0; + for (const collection of profiler.stop().statistics) { + freed += collection.beforeGC.heapStatistics.usedHeapSize - collection.afterGC.heapStatistics.usedHeapSize; + } + return after - before + freed; +} + /** Heap bytes in use after a full collection. */ async function heapInUse(): Promise { for (let round = 0; round < 3; round++) { @@ -670,6 +685,37 @@ describe("streamed partials", () => { await harness.close(context); }); + it("commits a growing text and tool call at a cost that follows the new text, not the string lengths", async () => { + /** Bytes allocated while streaming one answer of `size` characters of text and as much tool-call content. */ + const stream = async (size: number) => { + const setup = chatSetup({ tokensPerSecond: 64_000, tokenSize: { min: 64, max: 64 } }); + setup.settings.progress = { partialIntervalMs: 1, outputIntervalMs: 100 }; + const text = 'streamed text with "quotes" and \\ escapes\n'.repeat(size / 40); + setup.faux.setResponses([ + fauxAssistantMessage( + [fauxText(text), fauxToolCall("missing", { path: "a.ts", content: text }, { id: "c1" })], + { + stopReason: "toolUse", + }, + ), + fauxAssistantMessage([fauxText("done")]), + ]); + const { harness, root } = await openChat(new MemoryStorage(), setup); + let commits = 0; + harness.subscribeCommits(() => commits++); + const bytes = await allocatedBytes(async () => { + await (await root.submit({ type: "input", content: "go" }, context)).wait(context); + }); + await harness.close(context); + return { bytes, commits }; + }; + const small = await stream(40_000); + const large = await stream(400_000); + expect(large.commits).toBeGreaterThan(small.commits * 5); + // Per commit, the cost stays flat: comparing the whole strings on every commit made it grow with their length. + expect(large.bytes / large.commits / (small.bytes / small.commits)).toBeLessThan(2); + }, 60_000); + it("keeps a watcher's memory proportional to a long streamed text and tool call, not to their commits", async () => { const setup = chatSetup({ tokensPerSecond: 16_000, tokenSize: { min: 16, max: 16 } }); setup.settings.progress = { partialIntervalMs: 1, outputIntervalMs: 100 }; diff --git a/packages/durable/test/harness-partial-changes.test.ts b/packages/durable/test/harness-partial-changes.test.ts new file mode 100644 index 000000000..3b61133b7 --- /dev/null +++ b/packages/durable/test/harness-partial-changes.test.ts @@ -0,0 +1,213 @@ +import type { JsonValue } from "@earendil-works/chord"; +import { type Op, track } from "@earendil-works/chord/delta"; +import { + type AssistantMessage, + type AssistantMessageEvent, + type ToolCall, + ToolCallArgumentsStream, +} from "@earendil-works/pi-ai"; +import { describe, expect, it } from "vitest"; +import { PartialChanges } from "../src/harness/partial-changes.ts"; + +// #9265: partial commits reassigned the growing strings of a streamed answer, and Chord compared each one whole. + +type Live = { generation: Record }; + +/** + * A response streaming one text block (started empty) and then one tool call, as a provider produces it: the partial + * message it mutates and the events it emits. Partial commits go into a tracked document, as Durable's generation does. + */ +function setup() { + const partial: AssistantMessage = { + role: "assistant", + content: [{ type: "text", text: "" }], + api: "test", + provider: "test", + model: "test", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "pending", + timestamp: 0, + }; + const changes = new PartialChanges(); + const tracker = track({ generation: { attempt: 1 } }); + const args = new ToolCallArgumentsStream(); + const emit = (event: AssistantMessageEvent) => changes.observe(event); + const ops: Op[][] = []; + return { + partial, + tracker, + ops, + text(delta: string) { + (partial.content[0] as { text: string }).text += delta; + emit({ type: "text_delta", contentIndex: 0, delta, partial }); + }, + argument(delta: string) { + if (partial.content[1] === undefined) { + partial.content.push({ type: "toolCall", id: "c1", name: "write", arguments: {} }); + emit({ type: "toolcall_start", contentIndex: 1, partial }); + } + (partial.content[1] as { arguments: Record }).arguments = args.append(delta); + emit({ type: "toolcall_delta", contentIndex: 1, delta, partial }); + }, + /** The provider ends both blocks. */ + endBlocks() { + emit({ type: "text_end", contentIndex: 0, content: (partial.content[0] as { text: string }).text, partial }); + emit({ type: "toolcall_end", contentIndex: 1, toolCall: partial.content[1] as ToolCall, partial }); + }, + /** One partial commit, as the generation's throttle makes it; `fail` aborts it like a rejected commit. */ + commit(fail = false) { + const message = structuredClone(partial) as unknown as { content: JsonValue[] }; + const taken = changes.take(message); + const change = tracker.beginChange(); + changes.commit(change.state.generation, message as unknown as JsonValue, taken); + if (fail) { + change.abort(); + changes.restore(taken); + return; + } + const prepared = change.prepare(); + ops.push([...prepared.ops]); + tracker.adopt(prepared); + }, + committed() { + const message = tracker.value.generation.message as { content: { text?: string; arguments?: unknown }[] }; + return { text: message.content[0]?.text, arguments: message.content[1]?.arguments }; + }, + }; +} + +function pieces(text: string, size: number): string[] { + const result: string[] = []; + for (let index = 0; index < text.length; index += size) result.push(text.slice(index, index + size)); + return result; +} + +const ARGUMENTS = { + path: "src/a.ts", + n: 1234, + ok: true, + content: 'line "one"\n\tline \\ two é 😀\n', + edits: [ + { oldText: "a", newText: "b\nc" }, + { oldText: "", newText: "x".repeat(50) }, + ], + nested: { deep: { list: [1, [2, "three"], { four: null }] } }, +}; + +describe("PartialChanges", () => { + it("commits tool-call arguments equal to the final parse, at every piece size and commit rhythm", () => { + const text = JSON.stringify(ARGUMENTS); + for (const size of [1, 2, 3, 5, 7, 16]) { + for (const every of [1, 3, 10]) { + const stream = setup(); + pieces(text, size).forEach((piece, index) => { + stream.argument(piece); + if (index % every === every - 1) stream.commit(); + }); + stream.commit(); + expect(stream.committed().arguments, `size ${size}, every ${every}`).toEqual(ARGUMENTS); + } + } + }); + + it("handles a number ending and a string starting in one piece, and escapes split across pieces", () => { + const stream = setup(); + for (const piece of [ + '{"n":1', + '2,"s":"a', + "b\\", + 'u00e9\\"', + "q\\u", + "00", + "41", + '","t":tr', + "ue,", + '"u":"\\ud83d', + '\\ude00"}', + ]) { + stream.argument(piece); + stream.commit(); + } + expect(stream.committed().arguments).toEqual({ n: 12, s: 'abé"qA', t: true, u: "😀" }); + }); + + it("appends growing argument and text strings instead of reassigning them", () => { + const stream = setup(); + stream.text("Hello"); + stream.argument('{"path":"a.ts","content":"x'); + stream.commit(); + for (let index = 0; index < 20; index++) { + stream.text(" more"); + stream.argument("yz"); + stream.commit(); + } + for (const ops of stream.ops.slice(1)) { + expect(ops).toEqual([ + ["a", ["generation", "message", "content", 0, "text"], " more"], + ["a", ["generation", "message", "content", 1, "arguments", "content"], "yz"], + ]); + } + expect(stream.committed()).toEqual({ + text: `Hello${" more".repeat(20)}`, + arguments: { path: "a.ts", content: `x${"yz".repeat(20)}` }, + }); + }); + + it("applies the changes of a failed commit in the next one", () => { + const stream = setup(); + stream.argument('{"a":"x'); + stream.commit(); + stream.argument('y","b":[1'); + stream.commit(true); + stream.argument(',2],"c":"z"}'); + stream.commit(); + expect(stream.committed().arguments).toEqual({ a: "xy", b: [1, 2], c: "z" }); + }); + + it("re-applies a failed commit's changes even when it would have added the call", () => { + const stream = setup(); + stream.commit(); + stream.argument('{"a":"x'); + stream.commit(true); + stream.argument('y"'); + stream.commit(); + expect(stream.committed().arguments).toEqual({ a: "xy" }); + }); + + it("falls back to the snapshot when the committed message lost a call whose changes were committed", () => { + const stream = setup(); + stream.argument('{"a":"x'); + stream.commit(); + // Another writer resets the generation's partial. + const change = stream.tracker.beginChange(); + change.state.generation = { attempt: 1 }; + stream.tracker.adopt(change.prepare()); + stream.argument('y"'); + stream.commit(); + expect(stream.committed().arguments).toEqual({ a: "xy" }); + stream.argument(',"b":2}'); + stream.commit(); + expect(stream.committed().arguments).toEqual({ a: "xy", b: 2 }); + }); + + it("commits the final arguments after the call ends, and drops text appends when a block ends", () => { + const stream = setup(); + stream.text("draft"); + stream.argument('{"a":"x'); + stream.commit(); + // Providers may replace a block's text and arguments as it ends; the last commit assigns what they ended with. + stream.text(" more"); + (stream.partial.content[0] as { text: string }).text = "replaced"; + stream.partial.content[1] = { type: "toolCall", id: "c1", name: "write", arguments: { a: "final" } }; + stream.endBlocks(); + stream.commit(); + expect(stream.committed()).toEqual({ text: "replaced", arguments: { a: "final" } }); + }); +}); diff --git a/scripts/check-entry-graphs.mjs b/scripts/check-entry-graphs.mjs index f53303d99..5fbb4658b 100644 --- a/scripts/check-entry-graphs.mjs +++ b/scripts/check-entry-graphs.mjs @@ -64,8 +64,10 @@ const BUDGETS = { // memory and SQLite storages share scan order and cursor handling. Values from Chord's root (copyJson, // replicatedState) reach its host runtime through the root barrel. Chord's context reaches pi-telemetry's root // (index, noop, metrics, memory) for its NOOP telemetry default; harness/telemetry.ts is Durable's schema. - // The Harness's request identity reaches pi-ai's utils/identity.ts and its version constant. - maxFiles: 73, + // The Harness's request identity reaches pi-ai's utils/identity.ts and its version constant. Partial commits + // parse streamed tool-call arguments with pi-ai's utils/streaming-json.ts (and its json-parse.ts) to append what + // argument strings grew by (harness/partial-changes.ts). + maxFiles: 76, forbid: [ "packages/ai/src/index.ts", "packages/ai/src/utils/typebox-helpers.ts", From 1e42501bb335789b8c5971d5810a49c0a40064d0 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 24 Oct 2026 08:35:20 +0000 Subject: [PATCH 31/33] fix(durable): keep pi.live cached in the partial-commit cost test The suite's evicting run (PI_DURABLE_DOCUMENT_RETENTION_MS=0) evicts the unwatched pi.live after every commit, so each partial commit replayed the document's delta chain and the per-commit cost grew with the stream. Pin the document retention in that test so it measures appends, as the default retention does. --- packages/durable/test/harness-events.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/durable/test/harness-events.test.ts b/packages/durable/test/harness-events.test.ts index e12d7d295..fac02d049 100644 --- a/packages/durable/test/harness-events.test.ts +++ b/packages/durable/test/harness-events.test.ts @@ -690,6 +690,9 @@ describe("streamed partials", () => { const stream = async (size: number) => { const setup = chatSetup({ tokensPerSecond: 64_000, tokenSize: { min: 64, max: 64 } }); setup.settings.progress = { partialIntervalMs: 1, outputIntervalMs: 100 }; + // Keep `pi.live` cached between commits, as the default retention does. Evicted after each use (the suite's + // evicting run), every commit replays the document's delta chain: a cost of eviction, not of appending. + setup.settings.documentRetentionMs = 60_000; const text = 'streamed text with "quotes" and \\ escapes\n'.repeat(size / 40); setup.faux.setResponses([ fauxAssistantMessage( From d8d99950ac360bc9330245030f587b32988314fb Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 24 Oct 2026 09:37:12 +0000 Subject: [PATCH 32/33] feat(chord,durable): PendingOps folds pending ops for slow consumers; Watch delivers one call per busy period - chord/delta: PendingOps (push, size, take -> PendingTake): raw pending ops, r drops everything before it, a leaf index merges appends and primitive sets per path (cleared by any other op), past 10,000 ops only "replace with the current value"; one batch is kept by reference and received ops are never mutated - chord: Watch queues frames in PendingOps while its listener is busy or not started and delivers one call with the newest value, the folded ops (or [["r", newest]] after an overflow) and the newest frame's context; replaces the 100-frame collapse. NativeWatch is unchanged and takes an optional replace() - durable: watchEvents keeps every event batch in order (NativeWatch with a snapshot replace); document, view and task-graph watches fold; spec, README, example 04 and tests updated - tests: fold fuzzers (tracker, appendText, splices/reorders, root arrays, diffRevisions) with codec round trips, adversarial cases, cap, ownership, cost and GC guards --- packages/chord/CHANGELOG.md | 7 +- packages/chord/README.md | 15 +- packages/chord/src/delta/README.md | 29 + packages/chord/src/delta/index.ts | 4 + packages/chord/src/delta/pending.ts | 173 +++++ packages/chord/src/watch.ts | 198 +++-- packages/chord/test/delta-pending.test.ts | 714 ++++++++++++++++++ packages/chord/test/watch.test.ts | 318 +++++--- packages/durable/CHANGELOG.md | 3 +- packages/durable/README.md | 4 +- packages/durable/docs/spec.md | 51 +- packages/durable/src/harness/events.ts | 13 +- packages/durable/src/harness/task-graph.ts | 2 +- packages/durable/src/harness/types.ts | 6 +- packages/durable/src/harness/view.ts | 2 +- packages/durable/test/examples/04-watches.ts | 30 +- packages/durable/test/harness-events.test.ts | 19 + packages/durable/test/harness-view.test.ts | 6 +- packages/durable/test/session-watches.test.ts | 75 +- scripts/check-entry-graphs.mjs | 5 +- 20 files changed, 1434 insertions(+), 240 deletions(-) create mode 100644 packages/chord/src/delta/pending.ts create mode 100644 packages/chord/test/delta-pending.test.ts diff --git a/packages/chord/CHANGELOG.md b/packages/chord/CHANGELOG.md index bce91731e..bbd4759bc 100644 --- a/packages/chord/CHANGELOG.md +++ b/packages/chord/CHANGELOG.md @@ -9,10 +9,11 @@ ### Added - `NativeWatchable`: an in-process value that changes and can be watched (`value`, `watch(context)` returning a `NativeWatchHandle`), the in-process counterpart of `Watchable`. -- `WatchHandle` 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`, 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. +- `WatchHandle` and `WatchEnd`, the one watch API, moved from Durable: a serialized observation with `value`, `start(listener)`, `stop()`, and `closed`. `WatchEnd` reasons are `stopped`, `cancelled`, `retired`, `ended`, `listener_error`, and `failed`. +- `Watch`, 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)`. Frames that arrive while the listener runs (or before `start`) are delivered in one call with the newest value, their ops folded by `PendingOps` and the newest frame's context; past 10,000 pending ops after folding, the call gets one root replacement `[["r", newest]]` instead (replaces the collapse after 100 undelivered frames); termination drops the listener and every reference into the source. - `Watchable`: a value that changes and can be watched (`value`, `watch(context)` returning a `WatchHandle`), the common interface of documents, derives and other observable values. -- `NativeWatchHandle` and its implementation `NativeWatch`: the same lifecycle for in-process values, with listener `(value, context)` and no ops; an overflow collapses to the newest value. +- `NativeWatchHandle` and its implementation `NativeWatch`: the same lifecycle for in-process values, with listener `(value, context)` and no ops: one call per value in order, and past 100 pending values one call with the value of the optional `replace()` or the newest value. +- `PendingOps` and `PendingTake` in `@earendil-works/chord/delta`: the pending ops of one slow consumer, kept raw and folded while they wait (appends to one path join, a primitive set replaces earlier leaf ops on its path, `r` drops everything before it); `size` counts the pending ops after folding; `take()` empties the queue and hands over the folded ops, or `replace` past 10,000 pending ops. Received batches and op tuples are never mutated. - `ownedSlice(text, start, end?)` in `@earendil-works/chord/delta`: a slice of a string that does not keep the whole string alive ([#9265](https://github.com/earendil-works/pi/issues/9265)) - `appendText(draft, key, text)` in `@earendil-works/chord/delta`: append to a string in a draft at O(text) cost, recorded as an append op without comparing the old and new strings (a plain `draft[key] += text` makes `prepare()` read the whole string, O(length) per change); `StringKeys` names the keys it accepts ([#9265](https://github.com/earendil-works/pi/issues/9265)) diff --git a/packages/chord/README.md b/packages/chord/README.md index f39bea749..4239568bc 100644 --- a/packages/chord/README.md +++ b/packages/chord/README.md @@ -210,10 +210,12 @@ model; see [State](docs/guide.md#state) in the usage guide. 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`: +advances to it. Changes made while the listener runs (or before `start`) arrive in +one call: the newest value, the ops of all of them folded by Delta's `PendingOps`, +and the newest change's context. Past 10,000 pending ops after folding the call +gets one root replacement `[["r", newest]]` instead. `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; @@ -228,5 +230,6 @@ still referenced keeps only its last value alive. Sources implement handles with `Watch`, fed through `advance(value, ops, context)`, `retire(context)`, `end()` or `end({ error })`, and `observeCancellation(signal)`. `NativeWatch` implements `NativeWatchHandle`, the same lifecycle for -in-process values: the listener gets `(value, context)` without ops, and an -overflow collapses to the newest value. +in-process values: the listener gets `(value, context)` without ops, one call per +value in order; past 100 pending values they collapse to one, the value of the +optional `replace()` or the newest. diff --git a/packages/chord/src/delta/README.md b/packages/chord/src/delta/README.md index 7a8480e46..ecc6cefd6 100644 --- a/packages/chord/src/delta/README.md +++ b/packages/chord/src/delta/README.md @@ -82,6 +82,35 @@ Parsing a separately serialized message per replica is also a valid detachment. `decode()` does not copy payloads, so decoding one in-memory `WireOp[]` for two mutable replicas makes them alias each other. +## Slow consumers + +`PendingOps` holds the batches one consumer has not taken yet (a watch listener +that is still running, a link whose window is full) and folds them while they +wait, so what it hands over replays to the same value with fewer ops: + +```ts +import { PendingOps } from "@earendil-works/chord/delta"; + +const pending = new PendingOps(); +pending.push(prepared.ops); // for each adopted batch, while the consumer is busy +// When the consumer is ready again: +const batch = pending.take(); +const ops = batch.kind === "ops" ? batch.ops : [["r", tracker.value]]; +``` + +- A leaf op (an append, or a set of a primitive) folds into the last pending leaf + op on its path: `a` + `a` join, `s` replaces `s` or `a`, an `a` after a string `s` + appends into it. `["a", ["text"], "he"]`, `["s", ["n"], 1]`, `["a", ["text"], "y"]` + becomes `["a", ["text"], "hey"]`, `["s", ["n"], 1]`. +- Any other op (`p`, `m`, `d`, `t`, `s` of an object or array) ends folding into + the ops before it, as it may change what a path names; `r` drops them. +- Past 10,000 pending ops after folding, only `replace` stays: the owner sends the + current value as `[["r", value]]`. +- One batch is handed over as received; received batches and op tuples are never + mutated, so the same batch can feed other consumers. +- It folds ops of the tracker, `diffRevisions()` and `appendText()`; it does not + validate or normalize hand-written ops. + ## Do and don't - Do build a fresh root and then transfer it; validate untrusted roots at your ingestion boundary. diff --git a/packages/chord/src/delta/index.ts b/packages/chord/src/delta/index.ts index c961cf5c9..f2ec7ec53 100644 --- a/packages/chord/src/delta/index.ts +++ b/packages/chord/src/delta/index.ts @@ -125,6 +125,10 @@ export { diffRevisions } from "./diff.ts"; export type { Draft } from "./draft.ts"; export { appendText, type Change, type Prepared, type StringKeys, type Tracker, track } from "./tracker.ts"; +// ─── Pending ops of a slow consumer ────────────────────────────────────────── + +export { PendingOps, type PendingTake } from "./pending.ts"; + // ─── Path safety ───────────────────────────────────────────────────────────── /** diff --git a/packages/chord/src/delta/pending.ts b/packages/chord/src/delta/pending.ts new file mode 100644 index 000000000..4657c000a --- /dev/null +++ b/packages/chord/src/delta/pending.ts @@ -0,0 +1,173 @@ +// The pending queue: the ops a slow consumer of one state has not taken yet, folded while they wait. +import type { JsonValue } from "../json.ts"; +import type { NonEmptyPath, Op, Path } from "./index.ts"; + +/** + * What `PendingOps.take()` hands over: the pending ops, folded, or `replace` after an overflow, which the owner of the + * queue turns into a root replacement with the current value. + */ +export type PendingTake = { readonly kind: "ops"; readonly ops: readonly Op[] } | { readonly kind: "replace" }; + +/** Pending ops past which only "replace with the current value" stays pending. */ +export const MAX_PENDING_OPS = 10_000; + +/** A leaf op: an append, or a set of a primitive. The leaf index points at these only. */ +type LeafOp = readonly ["a", NonEmptyPath, string] | readonly ["s", NonEmptyPath, JsonValue]; + +type Folding = { + readonly kind: "folding"; + /** Owned by the queue until `take()` hands it over; never one of the received op arrays. */ + readonly ops: Op[]; + /** Path key to the position of the last pending leaf op on that path. */ + readonly leaves: Map; +}; + +type State = + | { readonly kind: "empty" } + /** One batch, kept by reference: a consumer that keeps up never pays for folding. */ + | { readonly kind: "batch"; readonly ops: readonly Op[] } + | Folding + /** Overflowed: nothing more accumulates until `take()` or a batch that starts with `r`. */ + | { readonly kind: "replace" }; + +const EMPTY: State = { kind: "empty" }; + +const REPLACE: State = { kind: "replace" }; + +const NO_OPS: PendingTake = { kind: "ops", ops: [] }; + +const REPLACE_TAKE: PendingTake = { kind: "replace" }; + +/** + * Pending ops of one state for one consumer, in order and raw (never encoded while they wait). `r` drops everything + * before it. A leaf op merges into the last pending leaf op on the same path (`a` + `a` joined, `s` replaces `s` or `a`, + * `a` after a string `s` appends into it); any other op clears the leaf index. Leaf ops on different paths commute, + * except after an ancestor primitive `s`, after which the descendant cannot be written until a structural op clears the + * index. Past `MAX_PENDING_OPS` only "replace with the current value" stays pending. + * + * Ops are only those of Delta's tracker, `diffRevisions()` and `appendText()`; received op arrays and tuples are never + * mutated (they feed other consumers too), a merge makes a new tuple. + */ +export class PendingOps { + #state: State = EMPTY; + + /** Queue the ops of one commit. */ + push(ops: readonly Op[]): void { + if (ops.length === 0) return; + const state = this.#state; + // A batch that starts with `r` (the tracker's and `diffRevisions()`'s only place for it) drops everything + // pending, also an overflow, and is kept as received. + if (ops[0]![0] === "r") { + this.#state = { kind: "batch", ops }; + return; + } + switch (state.kind) { + case "empty": + this.#state = { kind: "batch", ops }; + return; + case "batch": { + const folding: Folding = { kind: "folding", ops: [], leaves: new Map() }; + this.#state = folding; + if (this.#fold(state.ops, folding)) this.#fold(ops, folding); + return; + } + case "folding": + this.#fold(ops, state); + return; + case "replace": + return; + } + } + + /** + * Pending ops after folding; 1 once overflowed (the replacement the owner sends). One batch is counted as received, + * so a single commit larger than `MAX_PENDING_OPS` counts in full until a second one arrives and overflows. + */ + get size(): number { + const state = this.#state; + if (state.kind === "empty") return 0; + return state.kind === "replace" ? 1 : state.ops.length; + } + + /** Hand over everything pending and start empty. */ + take(): PendingTake { + const state = this.#state; + this.#state = EMPTY; + switch (state.kind) { + case "empty": + return NO_OPS; + case "batch": + case "folding": + return { kind: "ops", ops: state.ops }; + case "replace": + return REPLACE_TAKE; + } + } + + /** Fold `ops` into `folding`; false once the queue overflowed into `replace`. */ + #fold(ops: readonly Op[], folding: Folding): boolean { + for (const op of ops) { + switch (op[0]) { + case "r": + folding.ops.length = 0; + folding.leaves.clear(); + break; + case "a": + if (mergeLeaf(op, folding)) continue; + break; + case "s": + if (isPrimitive(op[2])) { + if (mergeLeaf(op, folding)) continue; + } else if (folding.leaves.size > 0) folding.leaves.clear(); + break; + default: + if (folding.leaves.size > 0) folding.leaves.clear(); + } + folding.ops.push(op); + if (folding.ops.length > MAX_PENDING_OPS) { + this.#state = REPLACE; + return false; + } + } + return true; + } +} + +const isPrimitive = (value: JsonValue): boolean => value === null || typeof value !== "object"; + +/** + * Merge the leaf op `op` into the last pending leaf op on its path, or index it as that op at the position it is about + * to be pushed to. True when merged. + */ +function mergeLeaf(op: LeafOp, folding: Folding): boolean { + const key = pathKey(op[1]); + const at = folding.leaves.get(key); + if (at !== undefined) { + // The index points at leaf ops only. + const merged = merge(folding.ops[at] as LeafOp, op); + if (merged !== undefined) { + folding.ops[at] = merged; + return true; + } + } + folding.leaves.set(key, folding.ops.length); + return false; +} + +/** + * The key of a path in the leaf index. Injective, as merging two distinct paths would lose a write: `["a.b"]` and + * `["a", "b"]` are different keys. A number and its string (`1`, `"1"`) may share one: within one index lifetime a + * parent is either an array or an object, as changing it is a structural op that clears the index. + */ +function pathKey(path: Path): string { + return JSON.stringify(path); +} + +/** `next` folded into `previous` (same path), or undefined when they do not fold. */ +function merge(previous: LeafOp, next: LeafOp): LeafOp | undefined { + if (next[0] === "s") return next; + if (previous[0] === "a") return ["a", previous[1], previous[2] + next[2]]; + // Only a string set takes an append; the tracker never appends to another primitive. + if (typeof previous[2] === "string") return ["s", previous[1], previous[2] + next[2]]; + return undefined; +} diff --git a/packages/chord/src/watch.ts b/packages/chord/src/watch.ts index f6f5f51a5..76373de2f 100644 --- a/packages/chord/src/watch.ts +++ b/packages/chord/src/watch.ts @@ -1,6 +1,6 @@ // 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 Op, PendingOps } from "./delta/index.ts"; import type { JsonValue } from "./json.ts"; /** @@ -18,7 +18,10 @@ export type WatchEnd = | { 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. */ +/** + * Serialized observation of an immutable value: one listener call at a time; changes made meanwhile arrive folded into + * the next call. + */ export interface WatchHandle { /** Acquisition revision before start; latest delivered immutable revision afterward. */ readonly value: T; @@ -32,12 +35,13 @@ export interface WatchHandle { /** * A value that changes and can be watched: documents, derives, members of references. Values are immutable by - * convention (JSON for state); each watch starts at the current value and then gets one frame per change. + * convention (JSON for state); each watch starts at the current value and then gets the changes, folded while its + * listener is busy. */ export interface Watchable { /** The current value. */ readonly value: T; - /** A watch holding the current value, then one frame per change; the context's cancellation ends it. */ + /** A watch holding the current value, then its changes; the context's cancellation ends it. */ watch(context: Context): Promise>; } @@ -52,7 +56,10 @@ export interface NativeWatchable { watch(context: Context): Promise>; } -/** Serialized observation of an in-process value: as `WatchHandle`, but a change is a new value without ops. */ +/** + * Serialized observation of an in-process value: as `WatchHandle`, but each new value is its own call, in order, without + * ops. + */ export interface NativeWatchHandle { /** Acquisition value before start; latest delivered value afterward. */ readonly value: T; @@ -68,45 +75,37 @@ type WatchListener = (value: T, ops: readonly Op[], context: Context) => Prom type NativeWatchListener = (value: T, context: Context) => Promise; -type WatchFrame = { +/** One listener call: the value it delivers and the context of the newest frame it covers. */ +type Delivery = { readonly value: T; - readonly ops: readonly Op[]; readonly context: Context; - /** The retirement frame: the watch ends `retired` once it is delivered. */ - readonly retires?: true; + /** The retirement: the watch ends `retired` once it is delivered. */ + readonly retires: boolean; }; -/** 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. + * The lifecycle both handles share: 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 keep what is pending and say how a listener is called. */ -abstract class WatchLifecycle { +abstract class WatchLifecycle> { #detach: (() => void) | undefined; #report: ((error: unknown) => void) | undefined; - #replace: (() => T) | undefined; readonly #closed = Promise.withResolvers(); - readonly #pending: WatchFrame[] = []; #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) { + constructor(value: T, detach: () => void, report: (error: unknown) => void) { this.#value = value; this.#detach = detach; this.#report = report; - this.#replace = replace; } get value(): T { @@ -122,7 +121,7 @@ abstract class WatchLifecycle { if (this.#end !== undefined) throw new Error("Watch is stopped"); this.#started = true; this.#listener = listener; - if (this.#pending.length > 0) this.#schedule(); + this.#schedule(); } stop(): Promise { @@ -150,34 +149,22 @@ abstract class WatchLifecycle { this.#terminate(failure === undefined ? { reason: "ended" } : { reason: "failed", error: failure.error }); } - /** Call `listener` with one frame. */ - protected abstract invoke(listener: L, frame: WatchFrame, context: Context): Promise; - - /** 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(); + /** Whether the watch still takes frames: it has not ended. */ + protected get open(): boolean { + return this.#end === undefined; } - /** - * 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 }); + /** Take the next delivery, or undefined when nothing is pending. */ + protected abstract next(): D | undefined; + + /** Call `listener` with one delivery. */ + protected abstract invoke(listener: L, delivery: D, context: Context): Promise; + + /** Drop everything pending and every reference into the source; called once, on termination. */ + protected abstract release(): void; + + /** Something became pending: deliver it once started, never inline. */ + protected pending(): void { if (this.#started) this.#schedule(); } @@ -190,18 +177,19 @@ abstract class WatchLifecycle { }); } + /** Deliver until nothing is pending; what arrives during a call is taken after it returns. */ async #drain(): Promise { 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); + if (listener === undefined) break; + const delivery = this.next(); + if (delivery === undefined) break; + this.#value = delivery.value; try { - await this.invoke(listener, frame, deliveryContext); + await this.invoke(listener, delivery, withoutAbortSignal(delivery.context)); } catch (error) { if (this.#end === undefined) { this.#report?.(error); @@ -209,19 +197,18 @@ abstract class WatchLifecycle { } break; } - if (frame.retires === true) { + if (delivery.retires) { 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 + * End the watch, once: detach, drop what is pending and every reference into the source, and settle `closed`. A * callback already running keeps running. */ #terminate(end: WatchEnd): void { @@ -230,9 +217,8 @@ abstract class WatchLifecycle { const detach = this.#detach; this.#detach = undefined; this.#report = undefined; - this.#replace = undefined; this.#listener = undefined; - this.#pending.length = 0; + this.release(); if (this.#cancellationListener !== undefined) { this.#cancellationSignal?.removeEventListener("abort", this.#cancellationListener); } @@ -252,18 +238,29 @@ function toError(error: unknown): Error { } } +type WatchDelivery = Delivery & { readonly ops: readonly Op[] }; + /** 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. + * The watch handle of a JSON source. Frames that arrive before `start()` or while the listener runs wait in one + * `PendingOps` queue; the next call gets the newest value, the folded ops of every frame since the previous call (or + * `[["r", newest]]` after the queue overflowed) and the newest frame's context. `detach` unsubscribes it from its + * source on termination; `report` gets a listener's throw, which also ends the watch. */ -export class Watch extends WatchLifecycle> implements WatchHandle { +export class Watch extends WatchLifecycle, WatchDelivery> implements WatchHandle { + readonly #ops = new PendingOps(); + /** The newest frame, while a call is pending. */ + #newest: { readonly value: T; readonly context: Context } | undefined; + #retiring = false; + /** 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); + if (!this.open || this.#retiring) return; + this.#ops.push(ops); + this.#newest = { value, context }; + this.pending(); } /** @@ -271,41 +268,76 @@ export class Watch extends WatchLifecycle> implements Wat * whose values include `null`. */ retire(this: Watch, context: Context): void { - this.enqueueRetirement(null, RETIREMENT_OPERATIONS, context); + if (!this.open || this.#retiring) return; + this.#retiring = true; + this.#ops.push(RETIREMENT_OPERATIONS); + this.#newest = { value: null, context }; + this.pending(); } - protected invoke(listener: WatchListener, frame: WatchFrame, context: Context): Promise { - return listener(frame.value, frame.ops, context); - } - - protected overflowOps(value: T): readonly Op[] { + protected next(): WatchDelivery | undefined { + const newest = this.#newest; + if (newest === undefined) return undefined; + this.#newest = undefined; + const batch = this.#ops.take(); // 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 ops: readonly Op[] = batch.kind === "ops" ? batch.ops : [["r", newest.value as JsonValue]]; + // Nothing is queued after a retirement, so a retiring watch's pending call is its last. + return { value: newest.value, ops, context: newest.context, retires: this.#retiring }; + } + + protected invoke(listener: WatchListener, delivery: WatchDelivery, context: Context): Promise { + return listener(delivery.value, delivery.ops, context); + } + + protected release(): void { + this.#ops.take(); + this.#newest = undefined; } } -const NO_OPS: readonly Op[] = []; +/** Maximum values retained behind one unavailable native watch listener. */ +const MAX_PENDING_NATIVE_VALUES = 100; /** - * The watch handle of a native source: values in order, an overflow collapses to the newest value. `detach` and - * `report` as for `Watch`. + * The watch handle of a native source: every value in order, one call each; past 100 pending values they collapse to + * one, `replace()` or by default the newest value. `detach` and `report` as for `Watch`. */ -export class NativeWatch extends WatchLifecycle> implements NativeWatchHandle { - constructor(value: T, detach: () => void, report: (error: unknown) => void) { - // No replacement: an overflow delivers the newest value. - super(value, detach, report, undefined); +export class NativeWatch + extends WatchLifecycle, Delivery> + implements NativeWatchHandle +{ + readonly #pending: Delivery[] = []; + #replace: (() => T) | undefined; + + constructor(value: T, detach: () => void, report: (error: unknown) => void, replace?: () => T) { + super(value, detach, report); + this.#replace = replace; } /** Queue `value`, the source's new value. */ advance(value: T, context: Context): void { - this.enqueue(value, NO_OPS, context); + if (!this.open) return; + if (this.#pending.length < MAX_PENDING_NATIVE_VALUES) { + this.#pending.push({ value, context, retires: false }); + } else { + this.#pending.length = 0; + const replacement = this.#replace === undefined ? value : this.#replace(); + this.#pending.push({ value: replacement, context, retires: false }); + } + this.pending(); } - protected invoke(listener: NativeWatchListener, frame: WatchFrame, context: Context): Promise { - return listener(frame.value, context); + protected next(): Delivery | undefined { + return this.#pending.shift(); } - protected overflowOps(): readonly Op[] { - return NO_OPS; + protected invoke(listener: NativeWatchListener, delivery: Delivery, context: Context): Promise { + return listener(delivery.value, context); + } + + protected release(): void { + this.#pending.length = 0; + this.#replace = undefined; } } diff --git a/packages/chord/test/delta-pending.test.ts b/packages/chord/test/delta-pending.test.ts new file mode 100644 index 000000000..b004fb3e6 --- /dev/null +++ b/packages/chord/test/delta-pending.test.ts @@ -0,0 +1,714 @@ +// Tests for Delta's pending queue: folding is invisible to a replica, the cap, ownership of received ops, cost, GC. +import { setFlagsFromString } from "node:v8"; +import { runInNewContext } from "node:vm"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + appendText, + applyImmutable, + decoder, + diffRevisions, + encoder, + type Op, + PendingOps, + type PendingTake, + type Prepared, + track, +} from "../src/delta/index.ts"; +import { MAX_PENDING_OPS } from "../src/delta/pending.ts"; +import type { JsonValue } from "../src/json.ts"; + +type JsonObject = { [key: string]: JsonValue }; +type Container = JsonValue[] | JsonObject; + +/** A seeded generator, so a failing sequence reproduces from its seed. */ +function random(seed: number) { + let state = seed >>> 0; + const next = (): number => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + return state / 4294967296; + }; + const int = (max: number): number => Math.floor(next() * max); + const pick = (items: readonly T[]): T => items[int(items.length)]!; + // Surrogate halves and separators: an append may split a code point, and paths hold any string. + const texts = [ + "a", + "bc", + " tok", + "é", + "😀", + "\u2028", + "\ud83d", + "\ude00", + "日本語", + '"q"', + "\\", + "", + "x".repeat(30), + ]; + const keys = ["a", "b", "0", "1", "01", "a.b", "x y", "text", "list"]; + const text = (): string => pick(texts); + const primitive = (): JsonValue => pick([() => int(100), text, () => next() < 0.5, () => null])(); + const value = (depth = 0): JsonValue => { + const roll = next(); + if (depth > 2 || roll < 0.4) return primitive(); + if (roll < 0.7) return Array.from({ length: int(4) }, () => value(depth + 1)); + const object: JsonObject = {}; + for (let count = int(4); count >= 0; count--) object[pick(keys)] = value(depth + 1); + return object; + }; + const root = (): JsonObject => { + const object: JsonObject = {}; + for (let count = int(4) + 1; count >= 0; count--) object[pick(keys)] = value(); + return object; + }; + return { next, int, pick, text, primitive, value, root }; +} + +type Random = ReturnType; + +/** Every (parent, key) slot under `node`, parents first. */ +function slots(node: JsonValue, out: Array<[Container, string | number]> = []): Array<[Container, string | number]> { + if (node === null || typeof node !== "object") return out; + const keys: Array = Array.isArray(node) ? node.map((_, index) => index) : Object.keys(node); + for (const key of keys) { + out.push([node, key]); + slots((node as JsonObject)[key]!, out); + } + return out; +} + +const read = (parent: Container, key: string | number): JsonValue => (parent as JsonObject)[key]!; + +const write = (parent: Container, key: string | number, value: JsonValue): void => { + (parent as JsonObject)[key] = value; +}; + +type Append = (parent: JsonObject, key: string, text: string) => void; + +/** Appends through `appendText()`, which a draft records without comparing strings. */ +const appendToDraft: Append = (parent, key, text) => appendText(parent as never, key as never, text); + +const appendToValue: Append = (parent, key, text) => { + parent[key] = `${parent[key] as string}${text}`; +}; + +/** One random edit anywhere in `draft`: string appends and trims, array splices and reorders, object edits. */ +function mutateAnywhere(draft: JsonObject, rnd: Random, append: Append): void { + const all = slots(draft); + if (all.length === 0) { + draft[rnd.pick(["a", "b"])] = rnd.value(); + return; + } + const [parent, key] = rnd.pick(all); + const current = read(parent, key); + const roll = rnd.next(); + if (typeof current === "string") { + if (roll < 0.3 && !Array.isArray(parent)) append(parent, key as string, rnd.text()); + else if (roll < 0.6) write(parent, key, current + rnd.text()); + else if (roll < 0.8) write(parent, key, current.slice(Math.min(current.length, rnd.int(3)))); + else write(parent, key, rnd.primitive()); + } else if (Array.isArray(current)) { + if (roll < 0.3) current.push(rnd.value()); + else if (roll < 0.5) + current.splice(rnd.int(current.length + 1), rnd.int(2), ...(rnd.next() < 0.5 ? [rnd.value()] : [])); + else if (roll < 0.65 && current.length > 0) current[rnd.int(current.length)] = rnd.value(); + else if (roll < 0.75 && current.length > 1) current.reverse(); + else if (roll < 0.85 && current.length > 1) + current.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); + else if (roll < 0.95) write(parent, key, rnd.value()); + else current.length = 0; + } else if (current !== null && typeof current === "object") { + const keys = Object.keys(current); + if (roll < 0.4) current[rnd.pick(["a", "b", "1", "x y"])] = rnd.value(); + else if (roll < 0.6 && keys.length > 0) delete current[rnd.pick(keys)]; + else write(parent, key, rnd.value()); + } else { + write(parent, key, rnd.next() < 0.7 ? rnd.primitive() : rnd.value()); + } +} + +/** Leaf edits of a list of elements interleaved with splices, pushes and reorders of the list. */ +function mutateList(draft: JsonObject, rnd: Random): void { + const list = draft.list; + if (!Array.isArray(list)) { + draft.list = [ + { x: "", n: 0 }, + { x: "", n: 0 }, + { x: "", n: 0 }, + ]; + return; + } + const roll = rnd.next(); + if (roll < 0.45 && list.length > 0) { + const element = list[rnd.int(list.length)]; + if (element !== null && typeof element === "object" && !Array.isArray(element)) { + if (rnd.next() < 0.3) appendText(element as never, "x" as never, rnd.text()); + else if (rnd.next() < 0.5) element.x = `${element.x as string}${rnd.text()}`; + else element.n = rnd.int(9); + } else list[rnd.int(list.length)] = { x: "", n: 0 }; + } else if (roll < 0.6) + list.splice(rnd.int(list.length + 1), rnd.int(2), ...(rnd.next() < 0.6 ? [{ x: rnd.text(), n: 0 }] : [])); + else if (roll < 0.7) list.push({ x: "", n: 0 }); + else if (roll < 0.8 && list.length > 1) list.reverse(); + else if (roll < 0.9 && list.length > 0) list[rnd.int(list.length)] = rnd.primitive(); + else if (list.length > 0) list.splice(rnd.int(list.length), 1); +} + +/** Edits of a root array: pushes, splices, reorders, element sets and shifts. */ +function mutateRootArray(draft: JsonValue[], rnd: Random): void { + switch (rnd.int(7)) { + case 0: + draft.push(rnd.value()); + break; + case 1: + draft.splice(rnd.int(draft.length + 1), rnd.int(4), rnd.value()); + break; + case 2: + draft.reverse(); + break; + case 3: + draft.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); + break; + case 4: + if (draft.length > 0) draft[rnd.int(draft.length)] = rnd.value(); + break; + case 5: + draft.shift(); + break; + default: + draft.splice(rnd.int(draft.length + 1), rnd.int(4)); + } +} + +/** One sequence of commits from a value: the commits' op batches and the value after each. */ +type Sequence = { readonly initial: JsonValue; readonly commits: Array<{ ops: readonly Op[]; value: JsonValue }> }; + +function trackedSequence( + initial: T, + commits: number, + rnd: Random, + mutate: (draft: T) => void, + replace: () => T, +): Sequence { + const tracker = track(structuredClone(initial)); + const out: Sequence["commits"] = []; + for (let index = 0; index < commits; index++) { + let prepared: Prepared; + if (rnd.next() < 0.03) prepared = tracker.prepareReplace(replace()); + else { + const change = tracker.beginChange(); + for (let edits = 1 + rnd.int(4); edits > 0; edits--) mutate(change.state as T); + prepared = change.prepare(); + } + tracker.adopt(prepared); + out.push({ ops: prepared.ops, value: tracker.value as JsonValue }); + } + return { initial: initial as JsonValue, commits: out }; +} + +/** Diffs between values, each an edit of the previous one or a new value. */ +function diffSequence(rnd: Random, commits: number): Sequence { + const initial = rnd.value(); + const out: Sequence["commits"] = []; + let value = initial; + for (let index = 0; index < commits; index++) { + const edited: JsonObject = { value: structuredClone(value) }; + for (let edits = 1 + rnd.int(3); edits > 0; edits--) mutateAnywhere(edited, rnd, appendToValue); + const next = rnd.next() < 0.2 ? rnd.value() : edited.value!; + out.push({ ops: diffRevisions(value, next), value: next }); + value = next; + } + return { initial, commits: out }; +} + +/** + * Feed the sequence through one queue, taking at random points as a slow consumer would, and replay what was taken + * on a replica, once as ops and once through Delta's encoder and decoder. Both must reach the final value; the received + * ops must be untouched. + */ +function checkSequence(sequence: Sequence, rnd: Random): { ops: number; folded: number } { + for (const commit of sequence.commits) { + // Frozen: a queue that wrote into a received batch or op tuple throws here. + for (const op of commit.ops) Object.freeze(op); + Object.freeze(commit.ops); + } + const received = JSON.stringify(sequence.commits.map((commit) => commit.ops)); + const queue = new PendingOps(); + const taken: Array<{ batch: PendingTake; value: JsonValue }> = []; + for (const [index, commit] of sequence.commits.entries()) { + queue.push(commit.ops); + if (rnd.next() < 0.1 || index === sequence.commits.length - 1) + taken.push({ batch: queue.take(), value: commit.value }); + } + const final = sequence.commits.length === 0 ? sequence.initial : sequence.commits.at(-1)!.value; + let replica = structuredClone(sequence.initial); + const encode = encoder(); + const decode = decoder(); + let decoded = applyImmutable( + null, + decode.decode(JSON.parse(JSON.stringify(encode.encode([["r", replica]])))), + ); + let folded = 0; + for (const { batch, value } of taken) { + const ops: readonly Op[] = batch.kind === "ops" ? batch.ops : [["r", value]]; + folded += ops.length; + replica = applyImmutable(replica, ops); + decoded = applyImmutable(decoded, decode.decode(JSON.parse(JSON.stringify(encode.encode(ops))))); + } + expect(replica).toEqual(final); + expect(decoded).toEqual(final); + expect(JSON.stringify(sequence.commits.map((commit) => commit.ops))).toBe(received); + return { ops: sequence.commits.reduce((sum, commit) => sum + commit.ops.length, 0), folded }; +} + +describe("PendingOps folding", () => { + it("is invisible to a replica: tracker edits anywhere, appendText, splices and reorders", () => { + const rnd = random(1); + let ops = 0; + let folded = 0; + for (let run = 0; run < 1500; run++) { + const sequence = trackedSequence( + rnd.root(), + 1 + rnd.int(30), + rnd, + (draft) => mutateAnywhere(draft, rnd, appendToDraft), + rnd.root, + ); + const result = checkSequence(sequence, rnd); + ops += result.ops; + folded += result.folded; + } + // The sequences fold: a fuzzer whose ops never merge proves nothing. + expect(folded).toBeLessThan(ops * 0.9); + }); + + it("is invisible to a replica: leaf edits of list elements between splices and reorders of the list", () => { + const rnd = random(2); + let ops = 0; + let folded = 0; + for (let run = 0; run < 1500; run++) { + const initial: JsonObject = { + list: [ + { x: "a", n: 0 }, + { x: "b", n: 1 }, + ], + }; + const sequence = trackedSequence( + initial, + 5 + rnd.int(40), + rnd, + (draft) => mutateList(draft, rnd), + () => ({ list: [] }), + ); + const result = checkSequence(sequence, rnd); + ops += result.ops; + folded += result.folded; + } + expect(folded).toBeLessThan(ops * 0.9); + }); + + it("is invisible to a replica: a root array", () => { + const rnd = random(3); + for (let run = 0; run < 500; run++) { + const initial = Array.from({ length: rnd.int(6) }, () => rnd.value()); + const replace = () => Array.from({ length: rnd.int(6) }, () => rnd.value()); + checkSequence( + trackedSequence(initial, 40, rnd, (draft) => mutateRootArray(draft, rnd), replace), + rnd, + ); + } + }); + + it("is invisible to a replica: diffRevisions between arbitrary values", () => { + const rnd = random(4); + for (let run = 0; run < 800; run++) checkSequence(diffSequence(rnd, 30), rnd); + }); +}); + +/** Push each op as its own batch, take, and check the replay; returns the folded ops. */ +function foldOneByOne(initial: JsonValue, ops: readonly Op[]): readonly Op[] { + const queue = new PendingOps(); + for (const op of ops) queue.push([op]); + const batch = queue.take(); + if (batch.kind !== "ops") throw new Error("unexpected overflow"); + const expected = applyImmutable(structuredClone(initial), ops); + expect(applyImmutable(structuredClone(initial), batch.ops)).toEqual(expected); + const encode = encoder(); + const decode = decoder(); + const base = applyImmutable(null, decode.decode(JSON.parse(JSON.stringify(encode.encode([["r", initial]]))))); + expect(applyImmutable(base, decode.decode(JSON.parse(JSON.stringify(encode.encode(batch.ops)))))).toEqual(expected); + return batch.ops; +} + +describe("PendingOps adversarial sequences", () => { + it("a leaf set in an array element, a splice before it, the same path set again", () => { + const ops: Op[] = [ + ["s", ["arr", 2, "x"], 1], + ["p", ["arr"], 0, 0, [{ x: 9 }]], + ["s", ["arr", 2, "x"], 2], + ]; + expect(foldOneByOne({ arr: [{ x: 0 }, { x: 0 }, { x: 0 }] }, ops)).toEqual(ops); + const appends: Op[] = [ + ["a", ["arr", 0, "x"], "1"], + ["p", ["arr"], 0, 0, [{ x: "new" }]], + ["a", ["arr", 0, "x"], "2"], + ]; + expect(foldOneByOne({ arr: [{ x: "a" }, { x: "b" }] }, appends)).toEqual(appends); + }); + + it("`t` then `a`, and appends around a trim", () => { + expect( + foldOneByOne({ s: "hello" }, [ + ["t", ["s"], 2], + ["a", ["s"], "!"], + ]), + ).toHaveLength(2); + expect( + foldOneByOne({ s: "hello" }, [ + ["a", ["s"], "AB"], + ["t", ["s"], 3], + ["a", ["s"], "C"], + ]), + ).toHaveLength(3); + }); + + it("an object `s` at a parent, then leaf sets below it", () => { + expect( + foldOneByOne({ o: { a: 1 } }, [ + ["s", ["o", "a"], 2], + ["s", ["o"], { a: 5, b: 6 }], + ["s", ["o", "a"], 7], + ]), + ).toHaveLength(3); + expect( + foldOneByOne({ x: { s: "a" } }, [ + ["s", ["x"], { s: "b" }], + ["a", ["x", "s"], "c"], + ["s", ["x", "s"], "d"], + ]), + ).toEqual([ + ["s", ["x"], { s: "b" }], + ["s", ["x", "s"], "d"], + ]); + }); + + it("an ancestor primitive `s` between leaf ops below it", () => { + expect( + foldOneByOne({ o: { a: "x", b: "y" } }, [ + ["a", ["o", "a"], "1"], + ["s", ["o", "b"], "q"], + ["s", ["o"], "flat"], + ["a", ["o"], "!"], + ]), + ).toEqual([ + ["a", ["o", "a"], "1"], + ["s", ["o", "b"], "q"], + ["s", ["o"], "flat!"], + ]); + expect( + foldOneByOne({ a: { b: 0 } }, [ + ["s", ["a", "b"], 1], + ["s", ["a"], null], + ["s", ["a"], "x"], + ]), + ).toEqual([ + ["s", ["a", "b"], 1], + ["s", ["a"], "x"], + ]); + }); + + it("deletes, moves and splices shift what a path names", () => { + expect( + foldOneByOne({ arr: ["a", "b", "c"] }, [ + ["s", ["arr", 2], "C"], + ["d", ["arr", 0]], + ["s", ["arr", 1], "Z"], + ]), + ).toHaveLength(3); + expect( + foldOneByOne({ arr: [1, 2, 3] }, [ + ["s", ["arr", 0], 10], + ["m", ["arr"], [2, 1, 0]], + ["s", ["arr", 0], 20], + ]), + ).toHaveLength(3); + expect( + foldOneByOne({ arr: [{ x: 0 }] }, [ + ["s", ["arr", 0, "x"], 1], + ["p", ["arr"], 1, 0, [{ x: 0 }]], + ["s", ["arr", 0, "x"], 2], + ["s", ["arr", 1, "x"], 3], + ]), + ).toHaveLength(4); + }); + + it("keys of one parent never collide: `a.b` and `a`/`b`, `1` on an object and on an array", () => { + foldOneByOne({ a: { b: "x" }, "a.b": "z" }, [ + ["a", ["a", "b"], "1"], + ["s", ["a.b"], "y"], + ["a", ["a", "b"], "2"], + ]); + foldOneByOne({ o: { "1": "a" }, arr: ["x", "y"] }, [ + ["s", ["o", "1"], "b"], + ["s", ["arr", 1], "z"], + ["s", ["o", "1"], "c"], + ["s", ["arr", 1], "w"], + ]); + }); + + it("an array set at the end appends, and a later set of that index folds into it", () => { + expect( + foldOneByOne({ arr: [1] }, [ + ["s", ["arr", 1], 2], + ["s", ["arr", 0], 9], + ["s", ["arr", 1], 3], + ]), + ).toEqual([ + ["s", ["arr", 1], 3], + ["s", ["arr", 0], 9], + ]); + }); + + it("`r` drops everything before it", () => { + expect( + foldOneByOne({ a: 1 }, [ + ["s", ["a"], 2], + ["r", { b: "x" }], + ["a", ["b"], "y"], + ]), + ).toEqual([ + ["r", { b: "x" }], + ["a", ["b"], "y"], + ]); + }); + + it("joins appends, folds appends into a string set, and keeps split surrogates", () => { + expect( + foldOneByOne({ s: "" }, [ + ["s", ["s"], "ab"], + ["a", ["s"], "c"], + ["s", ["t"], 1], + ["a", ["s"], "d"], + ]), + ).toEqual([ + ["s", ["s"], "abcd"], + ["s", ["t"], 1], + ]); + expect( + foldOneByOne({ s: "" }, [ + ["a", ["s"], "\ud83d"], + ["s", ["n"], 1], + ["a", ["s"], "\ude00"], + ["a", ["s"], "é"], + ]), + ).toEqual([ + ["a", ["s"], "😀é"], + ["s", ["n"], 1], + ]); + }); + + it("interleaved leaves, a structural op, then the same leaves again", () => { + expect( + foldOneByOne({ a: "", b: "", list: [0] }, [ + ["a", ["a"], "1"], + ["a", ["b"], "1"], + ["a", ["a"], "2"], + ["p", ["list"], 1, 0, [1]], + ["a", ["b"], "2"], + ["a", ["a"], "3"], + ]), + ).toEqual([ + ["a", ["a"], "12"], + ["a", ["b"], "1"], + ["p", ["list"], 1, 0, [1]], + ["a", ["b"], "2"], + ["a", ["a"], "3"], + ]); + }); +}); + +/** `count` set ops on distinct paths. */ +const distinctSets = (count: number, from = 0): Op[] => + Array.from({ length: count }, (_, index): Op => ["s", ["k", from + index], index]); + +describe("PendingOps cap", () => { + it(`keeps ${MAX_PENDING_OPS} pending ops and replaces past them`, () => { + const queue = new PendingOps(); + queue.push([]); + queue.push(distinctSets(MAX_PENDING_OPS)); + const exact = queue.take(); + expect(exact.kind === "ops" && exact.ops.length).toBe(MAX_PENDING_OPS); + + queue.push(distinctSets(MAX_PENDING_OPS / 2)); + queue.push(distinctSets(MAX_PENDING_OPS / 2, MAX_PENDING_OPS / 2)); + const full = queue.take(); + expect(full.kind === "ops" && full.ops.length).toBe(MAX_PENDING_OPS); + + queue.push(distinctSets(MAX_PENDING_OPS / 2)); + queue.push(distinctSets(MAX_PENDING_OPS / 2 + 1, MAX_PENDING_OPS / 2)); + expect(queue.take()).toEqual({ kind: "replace" }); + }); + + it("counts ops after folding", () => { + const queue = new PendingOps(); + for (let index = 0; index < 3 * MAX_PENDING_OPS; index++) queue.push([["a", ["text"], "x"]]); + expect(queue.take()).toEqual({ kind: "ops", ops: [["a", ["text"], "x".repeat(3 * MAX_PENDING_OPS)]] }); + }); + + it("accumulates nothing once replaced, until taken or a batch starting with `r`", () => { + const queue = new PendingOps(); + queue.push(distinctSets(1)); + queue.push(distinctSets(MAX_PENDING_OPS + 1)); + queue.push(distinctSets(3)); + expect(queue.take()).toEqual({ kind: "replace" }); + expect(queue.take()).toEqual({ kind: "ops", ops: [] }); + + queue.push(distinctSets(1)); + queue.push(distinctSets(MAX_PENDING_OPS + 1)); + const fresh: readonly Op[] = [["r", { k: [0] }]]; + queue.push(fresh); + const taken = queue.take(); + expect(taken.kind === "ops" && taken.ops).toBe(fresh); + queue.push(distinctSets(1)); + queue.push(fresh); + queue.push([["s", ["n"], 1]]); + expect(queue.take()).toEqual({ kind: "ops", ops: [...fresh, ["s", ["n"], 1]] }); + }); + + it("hands one batch over as received, whatever its size", () => { + const queue = new PendingOps(); + const batch = distinctSets(MAX_PENDING_OPS + 1); + queue.push(batch); + const taken = queue.take(); + expect(taken.kind === "ops" && taken.ops).toBe(batch); + queue.push(batch); + queue.push([["s", ["n"], 1]]); + expect(queue.take()).toEqual({ kind: "replace" }); + }); +}); + +describe("PendingOps ownership", () => { + it("hands over a received batch by identity and never mutates batches or op tuples", () => { + const queue = new PendingOps(); + const first: readonly Op[] = Object.freeze([Object.freeze(["a", ["s"], "1"] as const)]); + queue.push(first); + const alone = queue.take(); + expect(alone.kind === "ops" && alone.ops).toBe(first); + + const second: readonly Op[] = Object.freeze([Object.freeze(["a", ["s"], "2"] as const)]); + queue.push(first); + queue.push(second); + expect(queue.take()).toEqual({ kind: "ops", ops: [["a", ["s"], "12"]] }); + expect(first).toEqual([["a", ["s"], "1"]]); + expect(second).toEqual([["a", ["s"], "2"]]); + }); +}); + +describe("PendingOps size", () => { + it("counts pending ops after folding, the replacement once overflowed, and nothing once taken", () => { + const queue = new PendingOps(); + expect(queue.size).toBe(0); + queue.push([]); + expect(queue.size).toBe(0); + queue.push([ + ["a", ["text"], "a"], + ["s", ["n"], 1], + ]); + expect(queue.size).toBe(2); + queue.push([ + ["a", ["text"], "b"], + ["s", ["n"], 2], + ["p", ["list"], 0, 0, [1]], + ]); + expect(queue.size).toBe(3); + queue.take(); + expect(queue.size).toBe(0); + + queue.push(distinctSets(MAX_PENDING_OPS)); + expect(queue.size).toBe(MAX_PENDING_OPS); + queue.push([["s", ["n"], 1]]); + expect(queue.size).toBe(1); + expect(queue.take()).toEqual({ kind: "replace" }); + expect(queue.size).toBe(0); + }); +}); + +describe("PendingOps cost", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("computes one path key per leaf op once folding, none for one batch or structural ops", () => { + const stringify = vi.spyOn(JSON, "stringify"); + const queue = new PendingOps(); + const leaves: Op[] = [ + ["a", ["entries", 0, "text"], "a"], + ["s", ["meta", "tokens"], 1], + ]; + queue.push(leaves); + queue.take(); + expect(stringify).toHaveBeenCalledTimes(0); + + queue.push(leaves); + expect(stringify).toHaveBeenCalledTimes(0); + queue.push([...leaves, ["p", ["entries"], 1, 0, [{ text: "" }]], ["s", ["meta"], { tokens: 0 }], ["d", ["x"]]]); + expect(stringify).toHaveBeenCalledTimes(4); + }); + + it("keeps one op per leaf path however many commits write it", () => { + const queue = new PendingOps(); + for (let index = 0; index < 50_000; index++) { + queue.push([ + ["a", ["entries", 0, "text"], "tok "], + ["s", ["meta", "tokens"], index], + ]); + } + const taken = queue.take(); + expect(taken.kind === "ops" && taken.ops).toEqual([ + ["a", ["entries", 0, "text"], "tok ".repeat(50_000)], + ["s", ["meta", "tokens"], 49_999], + ]); + }); +}); + +const collect = (() => { + setFlagsFromString("--expose-gc"); + const gc = runInNewContext("gc") as () => void; + return async (): Promise => { + for (let round = 0; round < 5; round++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + gc(); + } + }; +})(); + +describe("PendingOps references", () => { + it("releases a folded-over op, and the index and everything pending once taken", async () => { + const queue = new PendingOps(); + const pending = (): { payload: WeakRef; replaced: WeakRef } => { + const payload = { big: "x".repeat(1000) }; + const replaced: Op = ["s", ["n"], 1]; + queue.push([replaced]); + queue.push([ + ["s", ["n"], 2], + ["s", ["o"], payload], + ]); + return { payload: new WeakRef(payload), replaced: new WeakRef(replaced) }; + }; + const first = pending(); + await collect(); + // The set of `n` replaced the first one: only its newest op is held. + expect(first.replaced.deref()).toBeUndefined(); + expect(first.payload.deref()).toBeDefined(); + queue.take(); + await collect(); + expect(first.payload.deref()).toBeUndefined(); + + // Taken while the folding state holds the index: nothing stays behind in the queue. + const second = pending(); + queue.push([["p", ["list"], 0, 0, [1]]]); + queue.take(); + await collect(); + expect(second.payload.deref()).toBeUndefined(); + }); +}); diff --git a/packages/chord/test/watch.test.ts b/packages/chord/test/watch.test.ts index 3c8282430..76b54401b 100644 --- a/packages/chord/test/watch.test.ts +++ b/packages/chord/test/watch.test.ts @@ -1,7 +1,7 @@ // 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 { afterEach, describe, expect, expectTypeOf, it, vi } from "vitest"; import { BACKGROUND_CONTEXT, type Context, @@ -9,7 +9,8 @@ import { withAbortSignal, withContextValue, } from "../src/context/index.ts"; -import type { Op } from "../src/delta/index.ts"; +import { applyImmutable, type Op, track } from "../src/delta/index.ts"; +import { MAX_PENDING_OPS } from "../src/delta/pending.ts"; import type { JsonValue } from "../src/json.ts"; import { NativeWatch, Watch, type WatchEnd } from "../src/watch.ts"; @@ -25,20 +26,19 @@ function deferred(): { readonly promise: Promise; readonly resolve: () => } /** A watch whose detach and reports are recorded. */ -function recordedWatch(value: T, replace?: () => T) { +function recordedWatch(value: T) { const recorded = { detached: 0, reported: [] as unknown[] }; const watch = new Watch( value, () => recorded.detached++, (error) => recorded.reported.push(error), - replace, ); return { watch, recorded }; } -/** A started watch collecting its frames. */ -function collectingWatch(value: T, replace?: () => T) { - const { watch, recorded } = recordedWatch(value, replace); +/** A started watch collecting its calls. */ +function collectingWatch(value: T) { + const { watch, recorded } = recordedWatch(value); const frames: Array<{ value: T; ops: readonly Op[] }> = []; watch.start(async (delivered, ops) => { frames.push({ value: delivered, ops }); @@ -67,12 +67,20 @@ const collect = (() => { })(); describe("Watch delivery", () => { - it("keeps the acquisition value until start and delivers exact frames in order", async () => { - const { watch } = recordedWatch({ n: 0 }); - watch.advance({ n: 1 }, [["s", ["n"], 1]], context); - watch.advance({ n: 2 }, [["s", ["n"], 2]], context); + it("keeps the acquisition value until start, then delivers the frames since in one call", async () => { + const { watch } = recordedWatch({ n: 0, text: "" }); + watch.advance({ n: 1, text: "" }, [["s", ["n"], 1]], context); + watch.advance( + { n: 2, text: "a" }, + [ + ["s", ["n"], 2], + ["a", ["text"], "a"], + ], + context, + ); + watch.advance({ n: 2, text: "ab" }, [["a", ["text"], "b"]], context); await flush(); - expect(watch.value).toEqual({ n: 0 }); + expect(watch.value).toEqual({ n: 0, text: "" }); const frames: Array<{ value: JsonValue; ops: readonly Op[] }> = []; watch.start(async (value, ops) => { @@ -81,10 +89,32 @@ describe("Watch delivery", () => { expect(frames).toEqual([]); await flush(); expect(frames).toEqual([ - { value: { n: 1 }, ops: [["s", ["n"], 1]] }, - { value: { n: 2 }, ops: [["s", ["n"], 2]] }, + { + value: { n: 2, text: "ab" }, + ops: [ + ["s", ["n"], 2], + ["a", ["text"], "ab"], + ], + }, ]); - expect(watch.value).toEqual({ n: 2 }); + expect(watch.value).toEqual({ n: 2, text: "ab" }); + }); + + it("passes a frame's ops on by identity to a listener that keeps up", async () => { + const { watch } = recordedWatch(0); + const delivered: Array = []; + watch.start(async (_value, ops) => { + delivered.push(ops); + }); + const first: readonly Op[] = [["r", 1]]; + const second: readonly Op[] = [["r", 2]]; + watch.advance(1, first, context); + await flush(); + watch.advance(2, second, context); + await flush(); + expect(delivered).toHaveLength(2); + expect(delivered[0]).toBe(first); + expect(delivered[1]).toBe(second); }); it("never invokes the listener inline", async () => { @@ -95,98 +125,159 @@ describe("Watch delivery", () => { 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(0); + it("awaits each callback and folds frames advanced while one is in flight into the next call", async () => { + const { watch } = recordedWatch({ text: "" }); const gate = deferred(); const calls: string[] = []; - watch.start(async (value) => { - calls.push(`start ${value}`); - if (value === 1) await gate.promise; - calls.push(`end ${value}`); + const frames: Array<{ value: JsonValue; ops: readonly Op[] }> = []; + watch.start(async (value, ops) => { + calls.push(`start ${JSON.stringify(value)}`); + frames.push({ value, ops }); + if (frames.length === 1) await gate.promise; + calls.push(`end ${JSON.stringify(value)}`); }); - watch.advance(1, [["r", 1]], context); + watch.advance({ text: "a" }, [["a", ["text"], "a"]], context); await flush(); - watch.advance(2, [["r", 2]], context); - watch.advance(3, [["r", 3]], context); + watch.advance({ text: "ab" }, [["a", ["text"], "b"]], context); + watch.advance({ text: "abc" }, [["a", ["text"], "c"]], context); await flush(); - expect(calls).toEqual(["start 1"]); + expect(calls).toEqual(['start {"text":"a"}']); gate.resolve(); await flush(); - expect(calls).toEqual(["start 1", "end 1", "start 2", "end 2", "start 3", "end 3"]); + expect(calls).toEqual(['start {"text":"a"}', 'end {"text":"a"}', 'start {"text":"abc"}', 'end {"text":"abc"}']); + expect(frames[1]).toEqual({ value: { text: "abc" }, ops: [["a", ["text"], "bc"]] }); }); - it("delivers a context with the frame's values and without its abort signal", async () => { + it("delivers the newest frame's context, without its abort signal", async () => { const { watch } = recordedWatch(0); const key = createContextKey("frame"); const controller = new AbortController(); - const frameContext = withContextValue(key, "first", withAbortSignal(controller.signal, context)); - let delivered: Context | undefined; + const delivered: Context[] = []; watch.start(async (_value, _ops, deliveryContext) => { - delivered = deliveryContext; + delivered.push(deliveryContext); }); - watch.advance(1, [["r", 1]], frameContext); + watch.advance(1, [["r", 1]], withContextValue(key, "first", context)); + watch.advance(2, [["r", 2]], withContextValue(key, "second", withAbortSignal(controller.signal, context))); await flush(); - expect(delivered?.value(key)).toBe("first"); - expect(delivered?.abortSignal).toBeUndefined(); + expect(delivered).toHaveLength(1); + expect(delivered[0]?.value(key)).toBe("second"); + expect(delivered[0]?.abortSignal).toBeUndefined(); }); }); +describe("Watch with a slow listener", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("gets one call per batch whose ops turn the previous delivered value into the newest", async () => { + vi.useFakeTimers(); + type Doc = { entries: Array<{ text: string; status: string }>; tokens: number }; + const tracker = track({ entries: [], tokens: 0 }); + const { watch } = recordedWatch(tracker.value); + const commit = (edit: (draft: Doc) => void): void => { + const change = tracker.beginChange(); + edit(change.state as Doc); + const prepared = change.prepare(); + tracker.adopt(prepared); + watch.advance(tracker.value, prepared.ops, context); + }; + let replica: Doc = tracker.value; + const calls: Array<{ commits: number; ops: number }> = []; + let commits = 0; + watch.start(async (value, ops) => { + replica = applyImmutable(replica, ops); + expect(replica).toEqual(value); + calls.push({ commits, ops: ops.length }); + // A listener taking a second per call, as a slow client would. + await new Promise((resolve) => setTimeout(resolve, 1000)); + }); + // Ten seconds of a streamed reply at 60 tokens per second, a new entry every second. + for (let tick = 0; tick < 600; tick++) { + if (tick % 60 === 0) commit((draft) => void draft.entries.push({ text: "", status: "streaming" })); + commit((draft) => { + draft.entries.at(-1)!.text += "tok "; + draft.tokens++; + }); + commits++; + await vi.advanceTimersByTimeAsync(1000 / 60); + } + await vi.advanceTimersByTimeAsync(2000); + expect(replica).toEqual(tracker.value); + // One call per second, each carrying at most one op per path written since the previous one. + expect(calls.length).toBeGreaterThanOrEqual(10); + expect(calls.length).toBeLessThanOrEqual(12); + for (const call of calls) expect(call.ops).toBeLessThanOrEqual(5); + await watch.stop(); + }); +}); + +/** Advance `watch` by one frame per element of `{ k: [0, ..., count - 1] }`, each setting its own path. */ +function advanceDistinct(watch: Watch, count: number): JsonValue { + const values: number[] = []; + for (let index = 0; index < count; index++) { + values.push(index); + watch.advance({ k: [...values] }, [["s", ["k", index], index]], context); + } + return { k: values }; +} + describe("Watch overflow", () => { - it("collapses 101 pending frames to one root replacement of the newest value", async () => { - const { watch } = recordedWatch(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 }); - }); + it(`keeps ${MAX_PENDING_OPS} pending ops after folding and replaces the root past them`, async () => { + const exact = collectingWatch({ k: [] }); + advanceDistinct(exact.watch, MAX_PENDING_OPS); await flush(); - expect(frames).toEqual([{ value: 101, ops: [["r", 101]] }]); + expect(exact.frames).toHaveLength(1); + expect(exact.frames[0]?.ops).toHaveLength(MAX_PENDING_OPS); + + const over = collectingWatch({ k: [] }); + const newest = advanceDistinct(over.watch, MAX_PENDING_OPS + 1); + await flush(); + expect(over.frames).toEqual([{ value: newest, ops: [["r", newest]] }]); }); - it("keeps 100 pending frames exact", async () => { - const { watch } = recordedWatch(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(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(0); + it("never folds the in-flight call into an overflow", async () => { + const { watch } = recordedWatch({ k: [] }); const gate = deferred(); - const values: JsonValue[] = []; - watch.start(async (value) => { - values.push(value); - if (value === 1) await gate.promise; + const frames: Array<{ value: JsonValue; ops: readonly Op[] }> = []; + watch.start(async (value, ops) => { + frames.push({ value, ops }); + if (frames.length === 1) await gate.promise; }); - watch.advance(1, [["r", 1]], context); + watch.advance({ k: [], first: true }, [["s", ["first"], true]], context); await flush(); - for (let n = 2; n <= 102; n++) watch.advance(n, [["r", n]], context); + const newest = advanceDistinct(watch, MAX_PENDING_OPS + 1); gate.resolve(); await flush(); - expect(values).toEqual([1, 102]); + expect(frames).toEqual([ + { value: { k: [], first: true }, ops: [["s", ["first"], true]] }, + { value: newest, ops: [["r", newest]] }, + ]); + }); + + it("starts folding again after an overflow was delivered", async () => { + const { watch, frames } = collectingWatch({ k: [] }); + advanceDistinct(watch, MAX_PENDING_OPS + 1); + await flush(); + watch.advance({ n: 1 }, [["r", { n: 1 }]], context); + watch.advance({ n: 2 }, [["s", ["n"], 2]], context); + await flush(); + expect(frames.at(-1)).toEqual({ + value: { n: 2 }, + ops: [ + ["r", { n: 1 }], + ["s", ["n"], 2], + ], + }); }); }); describe("Watch retirement", () => { - it('delivers `[["r", null]]` as the last frame and then ends retired', async () => { + it('delivers `[["r", null]]` as the last call and then ends retired', async () => { const { watch, frames, recorded } = collectingWatch(1); const settled = endOf(watch); watch.advance(2, [["r", 2]], context); + await flush(); watch.retire(context); watch.advance(3, [["r", 3]], context); await flush(); @@ -199,22 +290,25 @@ describe("Watch retirement", () => { expect(recorded.detached).toBe(1); }); - it("drops an overflowing backlog for the retirement frame", async () => { - const { watch } = recordedWatch(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("drops pending frames, also an overflow, for the retirement", async () => { + for (const pending of [3, MAX_PENDING_OPS + 1]) { + const { watch } = recordedWatch({ k: [] }); + advanceDistinct(watch, pending); + 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(1); const settled = endOf(watch); watch.advance(null, [["r", null]], context); + await flush(); watch.advance(2, [["r", 2]], context); await flush(); expect(frames).toEqual([ @@ -311,13 +405,17 @@ describe("Watch ends", () => { it("ends `listener_error` on a throw, reports it, and delivers nothing more", async () => { const { watch, recorded } = recordedWatch(0); const failure = new Error("listener failed"); + const gate = deferred(); const values: JsonValue[] = []; watch.start(async (value) => { values.push(value); + await gate.promise; throw failure; }); watch.advance(1, [["r", 1]], context); + await flush(); watch.advance(2, [["r", 2]], context); + gate.resolve(); expect(await watch.closed).toEqual({ reason: "listener_error", error: failure }); expect(values).toEqual([1]); expect(recorded.reported).toEqual([failure]); @@ -344,7 +442,6 @@ describe("Watch references", () => { 0, () => void source, () => void source, - () => source.value, ); const listener = async (): Promise => {}; watch.observeCancellation(controller.signal); @@ -382,6 +479,43 @@ describe("Watch references", () => { expect(ended.signal.deref()).toBeUndefined(); } }); + + it("releases its pending queue and leaf index once delivered or ended", async () => { + /** Two pending frames, folded, whose ops alone reference `payload`; `settle` delivers or ends them. */ + const pending = async (settle: (watch: Watch, gate: () => void) => Promise) => { + const payload = { big: "x".repeat(1000) }; + const watch = new Watch( + 0, + () => {}, + () => {}, + ); + const gate = deferred(); + let calls = 0; + watch.start(async () => { + if (++calls === 1) await gate.promise; + }); + watch.advance(1, [["s", ["n"], 1]], context); + await flush(); + watch.advance(2, [["s", ["o"], payload]], context); + watch.advance(3, [["s", ["n"], 3]], context); + await settle(watch, gate.resolve); + return { watch, payload: new WeakRef(payload) }; + }; + + const delivered = await pending(async (_watch, gate) => { + gate(); + await flush(); + }); + await collect(); + expect(delivered.payload.deref()).toBeUndefined(); + expect(delivered.watch.value).toBe(3); + + const stopped = await pending(async (watch) => { + await watch.stop(); + }); + await collect(); + expect(stopped.payload.deref()).toBeUndefined(); + }); }); describe("NativeWatch", () => { @@ -424,6 +558,22 @@ describe("NativeWatch", () => { expect(values).toEqual([101]); }); + it("collapses 101 pending values to the value of `replace`", async () => { + const watch = new NativeWatch( + "", + () => {}, + () => {}, + () => "replacement", + ); + for (let n = 1; n <= 101; n++) watch.advance(`value ${n}`, context); + const values: string[] = []; + watch.start(async (value) => { + values.push(value); + }); + await flush(); + expect(values).toEqual(["replacement"]); + }); + it("ends with every reason of its lifecycle", async () => { const create = () => new NativeWatch( diff --git a/packages/durable/CHANGELOG.md b/packages/durable/CHANGELOG.md index ea1481081..41abda519 100644 --- a/packages/durable/CHANGELOG.md +++ b/packages/durable/CHANGELOG.md @@ -7,6 +7,7 @@ - `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. +- Document, conversation view, and task graph watches deliver the commits made while their listener runs (or before `start()`) in one callback: the newest value, the commits' operations folded (appends to one string join, sets of one field keep the last) and the newest commit's Context. Past 10,000 pending operations the callback gets one root replacement `[["r", newest]]`. Before, each commit was its own callback and more than 100 pending commits became a root replacement. `watchEvents()` still delivers every event batch in order, with one `snapshot` past 100 pending batches. - 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 @@ -15,7 +16,7 @@ ### Changed -- Document, conversation view, task graph, and event watches use Chord's `Watch` implementation; behaviour is unchanged. +- Document, conversation view, task graph, and event watches use Chord's watch handles (`Watch`, and `NativeWatch` for event batches). ### Fixed diff --git a/packages/durable/README.md b/packages/durable/README.md index 8882f9a3a..257faa8d1 100644 --- a/packages/durable/README.md +++ b/packages/durable/README.md @@ -473,7 +473,7 @@ 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. `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: +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 Chord operations of that commit, one callback at a time: ```typescript const watch = await root.watch(context); @@ -490,7 +490,7 @@ watch.start(async (value, ops) => { 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. +Commits that land while a callback runs are delivered together in the next callback: the newest view and the operations of all of them, folded (appends to one string join, sets of one field keep the last). Past 10,000 pending operations the callback gets one root replacement `[["r", view]]` holding the whole newest view instead. 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 }`. diff --git a/packages/durable/docs/spec.md b/packages/durable/docs/spec.md index ae40f01b1..d63ba63e5 100644 --- a/packages/durable/docs/spec.md +++ b/packages/durable/docs/spec.md @@ -4674,7 +4674,7 @@ of the loaded tracker cache. Reopen creates a new watch lifetime. 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 +acquisition, and every later commit is buffered until `start()`. This prevents a value from being paired with operations based on a newer unseen revision. @@ -4686,7 +4686,7 @@ Session code. On the Session line, `watchDoc()` resolves one existing concrete incarnation, captures its current immutable tracker revision in O(1), and registers for every -later exact committed revision and operation batch. It returns `undefined` when +later committed revision and its operation batch. It returns `undefined` when absent. Before `start()`, `watch.value` remains the acquisition revision while later frames buffer. @@ -4705,29 +4705,34 @@ try { ``` `start()` installs the sole listener and schedules delivery; it never invokes -user code inline. Each callback receives the exact immutable committed value and -the exact operation batch that produced it. Immediately before invocation, +user code inline. Each callback receives the newest immutable committed value and +operations that turn the previously delivered value into it: the exact operation +batch of its commit when the listener kept up, or the folded batches of every +commit since the previous callback. Immediately before invocation, `watch.value` advances to that value. The watch awaits the listener before starting its next callback, but Session commits never wait for callback settlement. -A watch retains at most 100 pending committed frames, excluding the frame already -being delivered. Adding frame 101 replaces the complete undelivered suffix with -one self-contained root replacement `[["r", newestValue]]`, using the newest -exact immutable revision and that commit's Context. Later exact frames follow -that replacement normally. Another overflow replaces the pending suffix again. -No serialized-byte measurement, value copy, operation replay, or re-diff occurs. -This is convergent observation, not an audit stream; consumers requiring every -transition must persist those facts separately. +Commits that land while a callback runs, or before `start()`, wait in Chord's +pending queue (`PendingOps`) as raw operations and are delivered together in one +callback with the newest value and the newest commit's Context. While waiting, +an operation on a leaf (an append, or a set of a primitive) folds into the last +pending one on the same path: appends join, and a set replaces an earlier set or +append. Any other operation keeps the order of everything before it, and a root +replacement drops everything before it. Past 10,000 pending operations after +folding, the callback gets one self-contained root replacement +`[["r", newestValue]]` instead. No serialized-byte measurement, value copy, +operation replay, or re-diff occurs. This is convergent observation, not an audit +stream; consumers requiring every transition must persist those facts separately. `watch.value` and every previously returned revision remain stable forever under the trusted immutability contract. Consumers must not mutate them or retained descendants. The selected commit Context's values are preserved without inheriting the producer's cancellation. A callback owns its own asynchronous work. -A watch remains bound to its original incarnation. Retirement queues the exact -terminal `[["r", null]]` frame, or folds it into an overflow replacement with -value `null`. After that callback settles, the watch closes as `retired` and never +A watch remains bound to its original incarnation. Retirement drops what is +pending and queues the terminal `[["r", null]]` with value `null`. After that +callback settles, the watch closes as `retired` and never follows a replacement incarnation. `stop()` is idempotent. It synchronously unregisters, discards pending frames, @@ -4785,9 +4790,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.watch()` exposes the mount through the serialized exact-frame -watch of section 9.2, with bounded pending frames and full-value -overflow replacements. +`Conversation.watch()` exposes the mount through the serialized watch of +section 9.2, with folded pending commits and a full-value overflow replacement. ### 9.4 Agent events (experimental) @@ -5034,8 +5038,8 @@ 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. -`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 +`watchTaskGraph()` returns the mount through the serialized 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 @@ -5524,9 +5528,10 @@ These are contracts, not invitations to add defensive machinery: acquisition revision. Initialize the consumer from it first. After start, the property advances to each delivered immutable revision before its callback; every earlier reference remains stable. -- **Buffered watches:** slow or unstarted watches retain up to 100 exact committed - frames. Overflow replaces the undelivered suffix with one full-value root - replacement, so intermediate committed states may be omitted. A consumer that +- **Buffered watches:** slow or unstarted watches deliver the commits made + meanwhile in one callback with folded operations, and past 10,000 pending + operations one full-value root replacement, so intermediate committed states + are omitted. A consumer that must audit every transition must persist each fact in an immutable entry or journal and scan that history explicitly. - **Watch stop:** a listener may call and await `stop()`; it prevents future diff --git a/packages/durable/src/harness/events.ts b/packages/durable/src/harness/events.ts index 9251f21a5..5d5d638ce 100644 --- a/packages/durable/src/harness/events.ts +++ b/packages/durable/src/harness/events.ts @@ -1,4 +1,4 @@ -import { type Context, type JsonValue, Watch, type WatchEnd } from "@earendil-works/chord"; +import { type Context, type JsonValue, NativeWatch, type WatchEnd } from "@earendil-works/chord"; import type { Op, Path } from "@earendil-works/chord/delta"; import type { AssistantMessage, Message, Usage } from "@earendil-works/pi-ai"; import type { @@ -167,7 +167,7 @@ export async function watchEvents( context: Context, ): Promise { const views = conversationViews(harness); - let watch!: Watch; + let watch!: NativeWatch; let snapshot!: SnapshotEvent; await views.attach( conversationId, @@ -178,8 +178,9 @@ export async function watchEvents( const held = new Set(completing.map((record) => record.id)); let current = initial; snapshot = snapshotOf(initial); - // Batches are the watch's values; an overflow delivers a snapshot of the newest view instead. - watch = new Watch( + // Batches are the watch's values, each delivered (folding would drop events); an overflow delivers a snapshot of + // the newest view instead. + watch = new NativeWatch( [], release, (error) => views.report(error), @@ -189,7 +190,7 @@ export async function watchEvents( publication: (before, after, ops, publication, commitContext) => { current = after; const events = translate(conversationId, before, after, ops, publication, held); - if (events.length > 0) watch.advance(events, [], commitContext); + if (events.length > 0) watch.advance(events, commitContext); }, end: (failure) => watch.end(failure), }; @@ -205,7 +206,7 @@ export async function watchEvents( if (signal !== undefined) watch.observeCancellation(signal); return { snapshot, - start: (listener) => watch.start((events, _ops, deliveryContext) => listener(events, deliveryContext)), + start: (listener) => watch.start(listener), stop: () => watch.stop(), closed: watch.closed, }; diff --git a/packages/durable/src/harness/task-graph.ts b/packages/durable/src/harness/task-graph.ts index b44911173..ec4e55a23 100644 --- a/packages/durable/src/harness/task-graph.ts +++ b/packages/durable/src/harness/task-graph.ts @@ -104,7 +104,7 @@ export class TaskGraphView { }); } - /** A serialized exact-frame watch of the graph; cancelling `context` stops it. */ + /** A serialized watch of the graph; cancelling `context` stops it. */ async watch(context: Context): Promise { const { observer } = await this.#attach( (value, release) => new Watch(value, release, (error) => this.#session.report(error)), diff --git a/packages/durable/src/harness/types.ts b/packages/durable/src/harness/types.ts index 545f12e6b..66d8cb75f 100644 --- a/packages/durable/src/harness/types.ts +++ b/packages/durable/src/harness/types.ts @@ -778,7 +778,7 @@ export interface Conversation { * owned, transitively, by its non-background tasks. */ waitForIdle(context: Context): Promise; - /** The structural view (spec §9.3) as a serialized exact-frame watch with bounded pending frames. */ + /** The structural view (spec §9.3) as a serialized watch; commits made while its listener runs arrive folded. */ watch(context: Context): Promise; } @@ -837,8 +837,8 @@ export interface Harness extends Session { /** Session total: every conversation's `pi.usage` summed. */ usage(context: Context): Promise; /** - * Every live task with its owner edge, status, and owned conversations (spec §9.5), as a serialized exact-frame - * watch with bounded pending frames. + * Every live task with its owner edge, status, and owned conversations (spec §9.5), as a serialized watch; commits + * made while its listener runs arrive folded. */ watchTaskGraph(context: Context): Promise; } diff --git a/packages/durable/src/harness/view.ts b/packages/durable/src/harness/view.ts index cd4a14f98..d47d2e913 100644 --- a/packages/durable/src/harness/view.ts +++ b/packages/durable/src/harness/view.ts @@ -106,7 +106,7 @@ export class ConversationViews { this.#session.report(error); } - /** A serialized exact-frame watch of the view; cancelling `context` stops it. */ + /** A serialized watch of the view; cancelling `context` stops it. */ async watch(id: ConversationId, context: Context): Promise> { const { observer } = await this.attach( id, diff --git a/packages/durable/test/examples/04-watches.ts b/packages/durable/test/examples/04-watches.ts index 4ed33f1af..bc76fc6ec 100644 --- a/packages/durable/test/examples/04-watches.ts +++ b/packages/durable/test/examples/04-watches.ts @@ -22,21 +22,33 @@ const chat = await session.commit(async (tx) => { return conversation; }, context); -// A watch starts from one stable acquisition revision. Slow callbacks never -// overlap; exact committed frames buffer, with a full-value reset after 100. +// A watch starts from one stable acquisition revision. Callbacks never overlap: +// commits made while one runs arrive folded into the next call, which gets the +// newest value and ops that turn the previously delivered value into it (one +// full-value replacement `[["r", value]]` once more than 10,000 ops pile up). const notesWatch = await session.watchDoc(Notes, chat.id, context); if (notesWatch === undefined) throw new Error("notes are absent"); console.log("watch baseline:", notesWatch.value); -const delivered = new Promise((resolve) => { - notesWatch.start(async (value, _ops, _deliveryContext) => { - console.log("watch update:", value); - resolve(); - }); +const busy = Promise.withResolvers(); +const done = Promise.withResolvers(); +let calls = 0; +notesWatch.start(async (value, ops, _deliveryContext) => { + console.log("watch update:", value, JSON.stringify(ops)); + // The first call stays busy, as a slow consumer would, while three more commits land. + if (++calls === 1) await busy.promise; + else done.resolve(); }); await session.commit(async (tx) => { - (await tx.doc(Notes, chat.id)).text = "observed asynchronously"; + (await tx.doc(Notes, chat.id)).text = "observed"; }, context); -await delivered; +for (const more of [" asynchronously", ", folded", "!"]) { + await session.commit(async (tx) => { + (await tx.doc(Notes, chat.id)).text += more; + }, context); +} +busy.resolve(); +await done.promise; +console.log("listener calls:", calls); await notesWatch.stop(); await session.close(context); diff --git a/packages/durable/test/harness-events.test.ts b/packages/durable/test/harness-events.test.ts index fac02d049..8f561a205 100644 --- a/packages/durable/test/harness-events.test.ts +++ b/packages/durable/test/harness-events.test.ts @@ -252,6 +252,25 @@ describe("agent events", () => { await harness.close(context); }); + it("delivers each of 100 pending batches in its own call, in order, unlike state watches", async () => { + const { harness, root } = await openChat(new MemoryStorage(), chatSetup()); + const stream = await watchEvents(harness, root.id, context); + for (let index = 0; index < 100; index++) { + await root.commit((tx) => tx.appendEntry(root.id, { kind: "note", data: { index } }), context); + } + const batches: AgentEvent[][] = []; + stream.start(async (events) => { + batches.push([...events]); + }); + await drained(); + expect(batches).toHaveLength(100); + expect( + batches.map((events) => events.map((event) => (event.type === "entry_appended" ? event.entry.data : event))), + ).toEqual(Array.from({ length: 100 }, (_, index) => [{ index }])); + await stream.stop(); + await harness.close(context); + }); + it("replaces undelivered batches with one snapshot after 100 pending batches", async () => { const { harness, root } = await openChat(new MemoryStorage(), chatSetup()); const stream = await watchEvents(harness, root.id, context); diff --git a/packages/durable/test/harness-view.test.ts b/packages/durable/test/harness-view.test.ts index 3370dde62..a2214c762 100644 --- a/packages/durable/test/harness-view.test.ts +++ b/packages/durable/test/harness-view.test.ts @@ -228,9 +228,10 @@ describe("conversation view", () => { await harness.close(context); }); - it("replaces undelivered frames with the newest view after 100 pending frames", async () => { + it("folds undelivered commits into one call whose ops reach the newest view", async () => { const { harness, root } = await openChat(new MemoryStorage(), chatSetup()); const watch = await root.watch(context); + const initial = watch.value; for (let index = 0; index < 101; index++) { await root.commit((tx) => tx.appendEntry(root.id, { kind: "note" }), context); } @@ -240,7 +241,8 @@ describe("conversation view", () => { }); await drained(); expect(frames).toHaveLength(1); - expect(frames[0]!.ops).toEqual([["r", frames[0]!.value]]); + expect(frames[0]!.ops[0]?.[0]).not.toBe("r"); + expect(applyImmutable(initial, frames[0]!.ops)).toEqual(frames[0]!.value); expect(frames[0]!.value.entries).toHaveLength(101); await watch.stop(); await harness.close(context); diff --git a/packages/durable/test/session-watches.test.ts b/packages/durable/test/session-watches.test.ts index 7bd9e78d3..79a7f5b39 100644 --- a/packages/durable/test/session-watches.test.ts +++ b/packages/durable/test/session-watches.test.ts @@ -24,6 +24,16 @@ const StateDoc = defineDoc({ initial: () => ({ value: 0, items: ["a", "b"], retained: { label: "stable" } }), }); +type Keys = { keys: Record }; + +const KeysDoc = defineDoc({ + checkpointWhen: () => false, + kind: "watch.keys", + version: 1, + scope: "session", + initial: () => ({ keys: {} }), +}); + type Deferred = { readonly promise: Promise; readonly resolve: () => void }; function deferred(): Deferred { @@ -49,7 +59,7 @@ describe("Session document watches", () => { expect(storage.mintCount).toBe(0); }); - it("keeps the acquisition revision until start and delivers exact committed frames", async () => { + it("keeps the acquisition revision until start, then delivers the commits since in one call", async () => { const { session, publications } = await createState(); const watch = (await session.watchDoc(StateDoc, context))!; const initial = watch.value; @@ -75,27 +85,38 @@ describe("Session document watches", () => { inline = false; expect(deliveries).toHaveLength(0); await flush(); - expect(deliveries.map(({ value }) => value?.value)).toEqual([1, 2]); - expect(deliveries[0]!.value).toBe(firstPublished.value); - expect(deliveries[0]!.ops).toBe(firstPublished.ops); - expect(deliveries[1]!.value).toBe(secondPublished.value); - expect(deliveries[1]!.ops).toBe(secondPublished.ops); + // Both commits set `value`: folded, the second set replaces the first. + expect(firstPublished.ops).toEqual([["s", ["value"], 1]]); + expect(deliveries).toEqual([{ value: secondPublished.value, ops: secondPublished.ops }]); + expect(deliveries[0]!.value).toBe(secondPublished.value); expect(initial?.value).toBe(0); + + // A listener that keeps up gets each commit's ops as published. + await session.commit(async (tx) => { + (await tx.doc(StateDoc)).value = 3; + }, context); + await flush(); + const thirdPublished = documentChanges(publications.at(-1)!)[0]!; + expect(deliveries).toHaveLength(2); + expect(deliveries[1]!.value).toBe(thirdPublished.value); + expect(deliveries[1]!.ops).toBe(thirdPublished.ops); await watch.stop(); }); - it("serializes callbacks and buffers exact frames committed while one is in flight", async () => { + it("serializes callbacks and folds the commits made while one is in flight into the next", async () => { const { session } = await createState(); const watch = (await session.watchDoc(StateDoc, context))!; const entered = deferred(); const release = deferred(); const values: number[] = []; + const batches: unknown[] = []; let active = 0; let maxActive = 0; - watch.start(async (value) => { + watch.start(async (value, ops) => { active++; maxActive = Math.max(maxActive, active); values.push(value?.value ?? -1); + batches.push(ops); if (values.length === 1) { entered.resolve(); await release.promise; @@ -115,7 +136,8 @@ describe("Session document watches", () => { release.resolve(); await flush(); expect(maxActive).toBe(1); - expect(values).toEqual(Array.from({ length: 20 }, (_, index) => index + 1)); + expect(values).toEqual([1, 20]); + expect(batches[1]).toEqual([["s", ["value"], 20]]); await watch.stop(); }); @@ -143,7 +165,7 @@ describe("Session document watches", () => { await watch.stop(); }); - it("collapses 101 pending commits to one root replacement", async () => { + it("folds 101 pending commits of one field into one set", async () => { const { session } = await createState(); const watch = (await session.watchDoc(StateDoc, context))!; for (let value = 1; value <= 101; value++) { @@ -156,11 +178,36 @@ describe("Session document watches", () => { deliveries.push({ value: value?.value ?? -1, ops }); }); await flush(); - expect(deliveries).toEqual([{ value: 101, ops: [["r", watch.value]] }]); + expect(deliveries).toEqual([{ value: 101, ops: [["s", ["value"], 101]] }]); await watch.stop(); }); - it("never folds the in-flight frame into an overflow reset", async () => { + it("replaces the root once more than 10,000 ops are pending after folding", async () => { + const { session } = openTestSession(); + await session.commit((tx) => tx.doc(KeysDoc).then(() => undefined), context); + const watch = (await session.watchDoc(KeysDoc, context))!; + // Sets of distinct keys, in commits small enough to stay ops: 4,000 + 4,000 + 2,001 pending ops. + for (const [from, to] of [ + [0, 4_000], + [4_000, 8_000], + [8_000, 10_001], + ] as const) { + await session.commit(async (tx) => { + const state = await tx.doc(KeysDoc); + for (let index = from; index < to; index++) state.keys[`k${index}`] = index; + }, context); + } + const deliveries: Array<{ value: Readonly | null; ops: unknown }> = []; + watch.start(async (value, ops) => { + deliveries.push({ value, ops }); + }); + await flush(); + expect(deliveries).toEqual([{ value: watch.value, ops: [["r", watch.value]] }]); + expect(Object.keys(watch.value?.keys ?? {})).toHaveLength(10_001); + await watch.stop(); + }); + + it("never folds the in-flight frame into the next call", async () => { const { session } = await createState(); const watch = (await session.watchDoc(StateDoc, context))!; const entered = deferred(); @@ -185,12 +232,12 @@ describe("Session document watches", () => { release.resolve(); await flush(); expect(deliveries[0]!.value).toBe(1); - expect(deliveries[1]).toEqual({ value: 102, ops: [["r", watch.value]] }); + expect(deliveries[1]).toEqual({ value: 102, ops: [["s", ["value"], 102]] }); expect(deliveries).toHaveLength(2); await watch.stop(); }); - it("folds retirement into an overflow reset and then closes", async () => { + it("folds retirement over pending commits and then closes", async () => { const { session } = await createState(); const watch = (await session.watchDoc(StateDoc, context))!; for (let value = 1; value <= 100; value++) { diff --git a/scripts/check-entry-graphs.mjs b/scripts/check-entry-graphs.mjs index 5fbb4658b..a26291094 100644 --- a/scripts/check-entry-graphs.mjs +++ b/scripts/check-entry-graphs.mjs @@ -66,8 +66,9 @@ const BUDGETS = { // (index, noop, metrics, memory) for its NOOP telemetry default; harness/telemetry.ts is Durable's schema. // The Harness's request identity reaches pi-ai's utils/identity.ts and its version constant. Partial commits // parse streamed tool-call arguments with pi-ai's utils/streaming-json.ts (and its json-parse.ts) to append what - // argument strings grew by (harness/partial-changes.ts). - maxFiles: 76, + // argument strings grew by (harness/partial-changes.ts). Chord's watches fold pending ops with Delta's + // pending queue (delta/pending.ts). + maxFiles: 77, forbid: [ "packages/ai/src/index.ts", "packages/ai/src/utils/typebox-helpers.ts", From bdb151774ed6bef2b5676bbff4669f20a0c233fe Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 24 Oct 2026 09:48:13 +0000 Subject: [PATCH 33/33] fix(chord): give the PendingOps fuzz tests room on loaded CI runners The tracker fuzz test hit vitest's 5 s default timeout on CI (6.2 s); fewer sequences and a 30 s timeout per fuzz test. --- packages/chord/test/delta-pending.test.ts | 135 ++++++++++++---------- 1 file changed, 77 insertions(+), 58 deletions(-) diff --git a/packages/chord/test/delta-pending.test.ts b/packages/chord/test/delta-pending.test.ts index b004fb3e6..dcf3c067d 100644 --- a/packages/chord/test/delta-pending.test.ts +++ b/packages/chord/test/delta-pending.test.ts @@ -261,68 +261,87 @@ function checkSequence(sequence: Sequence, rnd: Random): { ops: number; folded: return { ops: sequence.commits.reduce((sum, commit) => sum + commit.ops.length, 0), folded }; } +/** Room for a loaded CI runner: each fuzz test takes about a second locally. */ +const FUZZ_TIMEOUT = 30_000; + describe("PendingOps folding", () => { - it("is invisible to a replica: tracker edits anywhere, appendText, splices and reorders", () => { - const rnd = random(1); - let ops = 0; - let folded = 0; - for (let run = 0; run < 1500; run++) { - const sequence = trackedSequence( - rnd.root(), - 1 + rnd.int(30), - rnd, - (draft) => mutateAnywhere(draft, rnd, appendToDraft), - rnd.root, - ); - const result = checkSequence(sequence, rnd); - ops += result.ops; - folded += result.folded; - } - // The sequences fold: a fuzzer whose ops never merge proves nothing. - expect(folded).toBeLessThan(ops * 0.9); - }); + it( + "is invisible to a replica: tracker edits anywhere, appendText, splices and reorders", + () => { + const rnd = random(1); + let ops = 0; + let folded = 0; + for (let run = 0; run < 800; run++) { + const sequence = trackedSequence( + rnd.root(), + 1 + rnd.int(30), + rnd, + (draft) => mutateAnywhere(draft, rnd, appendToDraft), + rnd.root, + ); + const result = checkSequence(sequence, rnd); + ops += result.ops; + folded += result.folded; + } + // The sequences fold: a fuzzer whose ops never merge proves nothing. + expect(folded).toBeLessThan(ops * 0.9); + }, + FUZZ_TIMEOUT, + ); - it("is invisible to a replica: leaf edits of list elements between splices and reorders of the list", () => { - const rnd = random(2); - let ops = 0; - let folded = 0; - for (let run = 0; run < 1500; run++) { - const initial: JsonObject = { - list: [ - { x: "a", n: 0 }, - { x: "b", n: 1 }, - ], - }; - const sequence = trackedSequence( - initial, - 5 + rnd.int(40), - rnd, - (draft) => mutateList(draft, rnd), - () => ({ list: [] }), - ); - const result = checkSequence(sequence, rnd); - ops += result.ops; - folded += result.folded; - } - expect(folded).toBeLessThan(ops * 0.9); - }); + it( + "is invisible to a replica: leaf edits of list elements between splices and reorders of the list", + () => { + const rnd = random(2); + let ops = 0; + let folded = 0; + for (let run = 0; run < 800; run++) { + const initial: JsonObject = { + list: [ + { x: "a", n: 0 }, + { x: "b", n: 1 }, + ], + }; + const sequence = trackedSequence( + initial, + 5 + rnd.int(40), + rnd, + (draft) => mutateList(draft, rnd), + () => ({ list: [] }), + ); + const result = checkSequence(sequence, rnd); + ops += result.ops; + folded += result.folded; + } + expect(folded).toBeLessThan(ops * 0.9); + }, + FUZZ_TIMEOUT, + ); - it("is invisible to a replica: a root array", () => { - const rnd = random(3); - for (let run = 0; run < 500; run++) { - const initial = Array.from({ length: rnd.int(6) }, () => rnd.value()); - const replace = () => Array.from({ length: rnd.int(6) }, () => rnd.value()); - checkSequence( - trackedSequence(initial, 40, rnd, (draft) => mutateRootArray(draft, rnd), replace), - rnd, - ); - } - }); + it( + "is invisible to a replica: a root array", + () => { + const rnd = random(3); + for (let run = 0; run < 300; run++) { + const initial = Array.from({ length: rnd.int(6) }, () => rnd.value()); + const replace = () => Array.from({ length: rnd.int(6) }, () => rnd.value()); + checkSequence( + trackedSequence(initial, 40, rnd, (draft) => mutateRootArray(draft, rnd), replace), + rnd, + ); + } + }, + FUZZ_TIMEOUT, + ); - it("is invisible to a replica: diffRevisions between arbitrary values", () => { - const rnd = random(4); - for (let run = 0; run < 800; run++) checkSequence(diffSequence(rnd, 30), rnd); - }); + it( + "is invisible to a replica: diffRevisions between arbitrary values", + () => { + const rnd = random(4); + for (let run = 0; run < 500; run++) checkSequence(diffSequence(rnd, 30), rnd); + }, + FUZZ_TIMEOUT, + ); }); /** Push each op as its own batch, take, and check the replay; returns the folded ops. */