mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
Merge remote-tracking branch 'origin/main' into replication
# Conflicts: # scripts/check-entry-graphs.mjs
This commit is contained in:
commit
f6f3d55076
120 changed files with 6287 additions and 855 deletions
2
.github/workflows/build-binaries.yml
vendored
2
.github/workflows/build-binaries.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<Provider>()` returning `AssistantMessageEventStream`.
|
||||
- `streamSimple<Provider>()` 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/<api-id>.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/<api-id>.lazy.ts` (`<name>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/<provider>.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/<id>.models.ts` (exporting `<ID>_MODELS`) that the provider factory imports, and updates `src/models.generated.ts`.
|
||||
|
||||
## 4. Provider Factory and Auth (`packages/ai/src/providers/`)
|
||||
|
||||
- Create `src/providers/<id>.ts` exporting `<id>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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<number, ToolCallArgumentsStream>();
|
||||
|
||||
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<number, ToolCallArgumentsStream>,
|
||||
): 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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,9 +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
|
||||
|
||||
|
|
|
|||
|
|
@ -1940,7 +1940,7 @@ Add a lazy wrapper `src/api/<api-id>.lazy.ts` (`<name>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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<number, { contentIndex: number; arguments?: ToolCallArgumentsStream }>();
|
||||
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<string, any>) ?? {},
|
||||
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 });
|
||||
|
|
@ -1300,7 +1282,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").
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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<number, OpenBlock>;
|
||||
|
||||
const EMPTY_TEXT_PLACEHOLDER = "<empty>";
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -371,11 +371,68 @@ 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",
|
||||
"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.
|
||||
* 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;
|
||||
|
||||
// fromEntries preserves names such as __proto__ as own properties.
|
||||
const result: Record<string, unknown> = 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") {
|
||||
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 +452,7 @@ export function convertTools(
|
|||
description: tool.description,
|
||||
...(useParameters
|
||||
? { parameters: sanitizeForOpenApi(parameters as unknown) }
|
||||
: { parametersJsonSchema: parameters }),
|
||||
: { parametersJsonSchema: rewriteConstAsEnum(parameters) }),
|
||||
};
|
||||
}),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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<string | number, number>();
|
||||
// Stream state per Mistral tool call, kept off the content blocks so it never reaches partials.
|
||||
const toolCallsByKey = new Map<string | number, { contentIndex: number; arguments: ToolCallArgumentsStream }>();
|
||||
|
||||
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<ToolCall["arguments"]>(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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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<number, StreamingToolCallBlock>();
|
||||
const toolCallBlocksById = new Map<string, StreamingToolCallBlock>();
|
||||
const toolCallBlocksByIndex = new Map<number, ToolCall>();
|
||||
const toolCallBlocksById = new Map<string, ToolCall>();
|
||||
const toolCallStates = new Map<ToolCall, ToolCallState>();
|
||||
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));
|
||||
|
|
|
|||
|
|
@ -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<ResponsesOutputSlot, { type: "toolCall" }>;
|
||||
|
||||
|
|
@ -517,6 +509,8 @@ export async function processResponsesStream<TApi extends Api>(
|
|||
): Promise<void> {
|
||||
let sawTerminalResponseEvent = false;
|
||||
const outputSlots = new Map<number, ResponsesOutputSlot>();
|
||||
// Tool calls whose output_item.done has not arrived yet.
|
||||
const unfinishedToolCalls = new Set<ToolCall>();
|
||||
const reasoningBlocksById = new Map<string, ThinkingContent>();
|
||||
const applyMessagePhaseStopReason = (item: ResponseOutputItem): void => {
|
||||
if (item.type === "message" && item.phase === "final_answer") {
|
||||
|
|
@ -562,19 +556,21 @@ export async function processResponsesStream<TApi extends Api>(
|
|||
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<TApi extends Api>(
|
|||
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<TApi extends Api>(
|
|||
});
|
||||
} 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<TApi extends Api>(
|
|||
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<TApi extends Api>(
|
|||
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<TApi extends Api>(
|
|||
}
|
||||
// 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<ToolCallInput, { kind: "custom" }>;
|
||||
|
||||
/** 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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -74,6 +75,7 @@ export type PiMessagesEvent =
|
|||
reason: Extract<PiMessagesStopReason, "stop" | "length" | "toolUse">;
|
||||
usage: PiMessagesUsage;
|
||||
responseId?: string;
|
||||
responseModel?: string;
|
||||
providerThinkingLevel?: string;
|
||||
rewrite?: PiMessagesRewriteImpact;
|
||||
}
|
||||
|
|
@ -83,6 +85,7 @@ export type PiMessagesEvent =
|
|||
usage: PiMessagesUsage;
|
||||
errorMessage?: string;
|
||||
responseId?: string;
|
||||
responseModel?: string;
|
||||
providerThinkingLevel?: string;
|
||||
rewrite?: PiMessagesRewriteImpact;
|
||||
};
|
||||
|
|
@ -120,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(
|
||||
|
|
@ -189,7 +192,7 @@ function createEventConverter(model: Model<"pi-messages">) {
|
|||
stopReason: "pending",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const toolJson = new Map<number, string>();
|
||||
const toolArguments = new Map<number, ToolCallArgumentsStream>();
|
||||
|
||||
return (event: PiMessagesEvent): AssistantMessageEvent => {
|
||||
switch (event.type) {
|
||||
|
|
@ -199,6 +202,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 +217,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;
|
||||
}
|
||||
|
|
@ -250,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<ToolCall["arguments"]>(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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -263,11 +264,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);
|
||||
}
|
||||
|
||||
|
|
@ -424,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) {
|
||||
|
|
@ -432,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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
559
packages/ai/src/utils/streaming-json.ts
Normal file
559
packages/ai/src/utils/streaming-json.ts
Normal file
|
|
@ -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<ToolCall, "arguments" | "argumentsError"> {
|
||||
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<Record<string, string>> = {
|
||||
'"': '"',
|
||||
"\\": "\\",
|
||||
"/": "/",
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
158
packages/ai/test/bedrock-stream-state.test.ts
Normal file
158
packages/ai/test/bedrock-stream-state.test.ts
Normal file
|
|
@ -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<unknown>) | undefined }));
|
||||
|
||||
vi.mock("@aws-sdk/client-bedrock-runtime", () => {
|
||||
class BedrockRuntimeServiceException extends Error {}
|
||||
|
||||
class BedrockRuntimeClient {
|
||||
send(): Promise<unknown> {
|
||||
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<unknown> {
|
||||
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<string, readonly string[]> = {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
322
packages/ai/test/data/json-test-suite.json
Normal file
322
packages/ai/test/data/json-test-suite.json
Normal file
|
|
@ -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=="
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = "<html>".repeat(1_000_000);
|
||||
return truncateErrorText(body, MAX_PROVIDER_ERROR_BODY_CHARS);
|
||||
})();
|
||||
await collectGarbage();
|
||||
expect(kept.startsWith("<html><html>")).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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -201,3 +201,133 @@ describe("google-shared convertTools", () => {
|
|||
expect(convertTools([], true)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// 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",
|
||||
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" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
39
packages/ai/test/memory-helpers.ts
Normal file
39
packages/ai/test/memory-helpers.ts
Normal file
|
|
@ -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<void> {
|
||||
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<number> {
|
||||
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<object>): Promise<boolean> {
|
||||
await collectGarbage();
|
||||
return reference.deref() === undefined;
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
848
packages/ai/test/streaming-json.test.ts
Normal file
848
packages/ai/test/streaming-json.test.ts
Normal file
|
|
@ -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 = <T>(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<string, string> = { '"': '"', "\\": "\\", "/": "/", 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<string, string>;
|
||||
};
|
||||
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<string | number, JsonValue>;
|
||||
for (const segment of change.path.slice(0, -1)) parent = parent[segment] as Record<string | number, JsonValue>;
|
||||
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<number> {
|
||||
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<object>[] = [];
|
||||
(() => {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
420
packages/ai/test/streaming-tool-arguments.test.ts
Normal file
420
packages/ai/test/streaming-tool-arguments.test.ts
Normal file
|
|
@ -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<string>): typeof fetch {
|
||||
return async () => {
|
||||
const iterator = events()[Symbol.iterator]();
|
||||
const encoder = new TextEncoder();
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<ResponseStreamEvent> {
|
||||
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<string, readonly string[]> = {
|
||||
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<object>[] = [];
|
||||
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" } });
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -8,9 +8,18 @@
|
|||
|
||||
### Added
|
||||
|
||||
- `WatchHandle<T>` and `WatchEnd`, the one watch API, moved from Durable: a serialized exact-frame observation with `value`, `start(listener)`, `stop()`, and `closed`. `WatchEnd` reasons are `stopped`, `cancelled`, `retired`, `ended`, `listener_error`, and `failed`.
|
||||
- `Watch<T>`, the one watch handle implementation, moved from Durable: a source creates one per watch and feeds it with `advance(value, ops, context)`, `retire(context)` (last frame `[["r", null]]`, then `retired`), `end()` or `end({ error })`, and `observeCancellation(signal)`. More than 100 undelivered frames collapse to one root replacement `[["r", newest]]`; termination drops the listener and every reference into the source.
|
||||
- `NativeWatchHandle<T>` and its implementation `NativeWatch<T>`: the same lifecycle for in-process values, with listener `(value, context)` and no ops; an overflow collapses to the newest value.
|
||||
- `NativeWatchable<T>`: an in-process value that changes and can be watched (`value`, `watch(context)` returning a `NativeWatchHandle<T>`), the in-process counterpart of `Watchable<T>`.
|
||||
- `WatchHandle<T>` 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<T>`, the one watch handle implementation, moved from Durable: a source creates one per watch and feeds it with `advance(value, ops, context)`, `retire(context)` (last frame `[["r", null]]`, then `retired`), `end()` or `end({ error })`, and `observeCancellation(signal)`. 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<T>`: a value that changes and can be watched (`value`, `watch(context)` returning a `WatchHandle<T>`), the common interface of documents, derives and other observable values.
|
||||
- `NativeWatchHandle<T>` and its implementation `NativeWatch<T>`: 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<T>` names the keys it accepts ([#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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<T>`, fed through `advance(value, ops, context)`,
|
||||
`retire(context)`, `end()` or `end({ error })`, and `observeCancellation(signal)`.
|
||||
`NativeWatch<T>` implements `NativeWatchHandle<T>`, the same lifecycle for
|
||||
in-process values: the listener gets `(value, context)` without ops, and an
|
||||
overflow collapses to the newest value.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,12 @@ 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. 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) {
|
||||
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 +74,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];
|
||||
|
|
|
|||
|
|
@ -109,11 +109,25 @@ 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";
|
||||
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";
|
||||
|
||||
// ─── Pending ops of a slow consumer ──────────────────────────────────────────
|
||||
|
||||
export { PendingOps, type PendingTake } from "./pending.ts";
|
||||
|
||||
// ─── Path safety ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
173
packages/chord/src/delta/pending.ts
Normal file
173
packages/chord/src/delta/pending.ts
Normal file
|
|
@ -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<string, number>;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -2,10 +2,23 @@ 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 };
|
||||
|
||||
/** Keys of `T` whose value type is exactly `string`: required, and not a string literal type. */
|
||||
export type StringKeys<T> = 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<string, JsonValue>;
|
||||
type Stored = JsonValue;
|
||||
|
|
@ -69,6 +82,11 @@ type OverlayNode = {
|
|||
deleteKey?: string;
|
||||
deletes?: Set<string>;
|
||||
readded?: Set<string>;
|
||||
/**
|
||||
* 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<string, string>;
|
||||
array?: ArrayOverlay;
|
||||
dirty?: boolean;
|
||||
subtreeDirty?: boolean;
|
||||
|
|
@ -322,6 +340,33 @@ export function track<T extends object>(initial: T): Tracker<T> {
|
|||
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<T extends object>(draft: Draft<T>, key: StringKeys<T>, text: string): void {
|
||||
// Trust boundary: JavaScript callers can pass any object and key; the types cover TypeScript callers.
|
||||
const node = (draft as Record<PropertyKey, unknown>)[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<T extends object>(context: OverlayContext): PreparedImpl<T> {
|
||||
const operations = ensureOperations(context);
|
||||
const base = context.baseValue as T;
|
||||
|
|
@ -580,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();
|
||||
|
|
@ -596,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);
|
||||
|
|
@ -1838,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
|
||||
|
|
@ -2045,16 +2097,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. 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, 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,14 @@ 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 NativeWatchable,
|
||||
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";
|
||||
|
|
|
|||
|
|
@ -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<T> {
|
||||
/** Acquisition revision before start; latest delivered immutable revision afterward. */
|
||||
readonly value: T;
|
||||
|
|
@ -30,7 +33,33 @@ export interface WatchHandle<T> {
|
|||
readonly closed: Promise<WatchEnd>;
|
||||
}
|
||||
|
||||
/** Serialized observation of an in-process value: as `WatchHandle`, but a change is a new value without ops. */
|
||||
/**
|
||||
* 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 the changes, folded while its
|
||||
* listener is busy.
|
||||
*/
|
||||
export interface Watchable<T> {
|
||||
/** The current value. */
|
||||
readonly value: T;
|
||||
/** A watch holding the current value, then its changes; the context's cancellation ends it. */
|
||||
watch(context: Context): Promise<WatchHandle<T>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<T> {
|
||||
/** 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<NativeWatchHandle<T>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialized observation of an in-process value: as `WatchHandle`, but each new value is its own call, in order, without
|
||||
* ops.
|
||||
*/
|
||||
export interface NativeWatchHandle<T> {
|
||||
/** Acquisition value before start; latest delivered value afterward. */
|
||||
readonly value: T;
|
||||
|
|
@ -46,45 +75,37 @@ type WatchListener<T> = (value: T, ops: readonly Op[], context: Context) => Prom
|
|||
|
||||
type NativeWatchListener<T> = (value: T, context: Context) => Promise<void>;
|
||||
|
||||
type WatchFrame<T> = {
|
||||
/** One listener call: the value it delivers and the context of the newest frame it covers. */
|
||||
type Delivery<T> = {
|
||||
readonly value: T;
|
||||
readonly ops: readonly Op[];
|
||||
readonly context: Context;
|
||||
/** The retirement frame: the watch ends `retired` once it is delivered. */
|
||||
readonly retires?: true;
|
||||
/** 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<T, L> {
|
||||
abstract class WatchLifecycle<T, L, D extends Delivery<T>> {
|
||||
#detach: (() => void) | undefined;
|
||||
#report: ((error: unknown) => void) | undefined;
|
||||
#replace: (() => T) | undefined;
|
||||
readonly #closed = Promise.withResolvers<WatchEnd>();
|
||||
readonly #pending: WatchFrame<T>[] = [];
|
||||
#value: T;
|
||||
#listener: L | undefined;
|
||||
#started = false;
|
||||
#scheduled = false;
|
||||
#running = false;
|
||||
#retired = false;
|
||||
/** Set once, by `#terminate()`. */
|
||||
#end: WatchEnd | undefined;
|
||||
#cancellationSignal: AbortSignal | undefined;
|
||||
#cancellationListener: (() => void) | undefined;
|
||||
|
||||
constructor(value: T, detach: () => void, report: (error: unknown) => void, replace?: () => T) {
|
||||
constructor(value: T, detach: () => void, report: (error: unknown) => void) {
|
||||
this.#value = value;
|
||||
this.#detach = detach;
|
||||
this.#report = report;
|
||||
this.#replace = replace;
|
||||
}
|
||||
|
||||
get value(): T {
|
||||
|
|
@ -100,7 +121,7 @@ abstract class WatchLifecycle<T, L> {
|
|||
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<WatchEnd> {
|
||||
|
|
@ -128,34 +149,22 @@ abstract class WatchLifecycle<T, L> {
|
|||
this.#terminate(failure === undefined ? { reason: "ended" } : { reason: "failed", error: failure.error });
|
||||
}
|
||||
|
||||
/** Call `listener` with one frame. */
|
||||
protected abstract invoke(listener: L, frame: WatchFrame<T>, context: Context): Promise<void>;
|
||||
|
||||
/** The ops of the frame an overflow delivers instead of the backlog, whose value is `value`. */
|
||||
protected abstract overflowOps(value: T): readonly Op[];
|
||||
|
||||
/** Queue one frame; past the bound the backlog collapses to one frame of `replace()` or the newest value. */
|
||||
protected enqueue(value: T, ops: readonly Op[], context: Context): void {
|
||||
if (this.#end !== undefined || this.#retired) return;
|
||||
if (this.#pending.length >= MAX_PENDING_WATCH_FRAMES) {
|
||||
this.#pending.length = 0;
|
||||
const replacement = this.#replace?.() ?? value;
|
||||
this.#pending.push({ value: replacement, ops: this.overflowOps(replacement), context });
|
||||
} else {
|
||||
this.#pending.push({ value, ops, context });
|
||||
}
|
||||
if (this.#started) this.#schedule();
|
||||
/** 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<void>;
|
||||
|
||||
/** 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();
|
||||
}
|
||||
|
||||
|
|
@ -168,18 +177,19 @@ abstract class WatchLifecycle<T, L> {
|
|||
});
|
||||
}
|
||||
|
||||
/** Deliver until nothing is pending; what arrives during a call is taken after it returns. */
|
||||
async #drain(): Promise<void> {
|
||||
if (this.#running || this.#end !== undefined || !this.#started) return;
|
||||
this.#running = true;
|
||||
try {
|
||||
while (this.#end === undefined) {
|
||||
const frame = this.#pending.shift();
|
||||
const listener = this.#listener;
|
||||
if (frame === undefined || listener === undefined) break;
|
||||
this.#value = frame.value;
|
||||
const deliveryContext = withoutAbortSignal(frame.context);
|
||||
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);
|
||||
|
|
@ -187,19 +197,18 @@ abstract class WatchLifecycle<T, L> {
|
|||
}
|
||||
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 {
|
||||
|
|
@ -208,9 +217,8 @@ abstract class WatchLifecycle<T, L> {
|
|||
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);
|
||||
}
|
||||
|
|
@ -230,18 +238,29 @@ function toError(error: unknown): Error {
|
|||
}
|
||||
}
|
||||
|
||||
type WatchDelivery<T> = Delivery<T> & { 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<T> extends WatchLifecycle<T, WatchListener<T>> implements WatchHandle<T> {
|
||||
export class Watch<T> extends WatchLifecycle<T, WatchListener<T>, WatchDelivery<T>> implements WatchHandle<T> {
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -249,41 +268,76 @@ export class Watch<T> extends WatchLifecycle<T, WatchListener<T>> implements Wat
|
|||
* whose values include `null`.
|
||||
*/
|
||||
retire(this: Watch<T | null>, 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<T>, frame: WatchFrame<T>, context: Context): Promise<void> {
|
||||
return listener(frame.value, frame.ops, context);
|
||||
}
|
||||
|
||||
protected overflowOps(value: T): readonly Op[] {
|
||||
protected next(): WatchDelivery<T> | 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<T>, delivery: WatchDelivery<T>, context: Context): Promise<void> {
|
||||
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<T> extends WatchLifecycle<T, NativeWatchListener<T>> implements NativeWatchHandle<T> {
|
||||
constructor(value: T, detach: () => void, report: (error: unknown) => void) {
|
||||
// No replacement: an overflow delivers the newest value.
|
||||
super(value, detach, report, undefined);
|
||||
export class NativeWatch<T>
|
||||
extends WatchLifecycle<T, NativeWatchListener<T>, Delivery<T>>
|
||||
implements NativeWatchHandle<T>
|
||||
{
|
||||
readonly #pending: Delivery<T>[] = [];
|
||||
#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<T>, frame: WatchFrame<T>, context: Context): Promise<void> {
|
||||
return listener(frame.value, context);
|
||||
protected next(): Delivery<T> | undefined {
|
||||
return this.#pending.shift();
|
||||
}
|
||||
|
||||
protected overflowOps(): readonly Op[] {
|
||||
return NO_OPS;
|
||||
protected invoke(listener: NativeWatchListener<T>, delivery: Delivery<T>, context: Context): Promise<void> {
|
||||
return listener(delivery.value, context);
|
||||
}
|
||||
|
||||
protected release(): void {
|
||||
this.#pending.length = 0;
|
||||
this.#replace = undefined;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
190
packages/chord/test/delta-append-text.test.ts
Normal file
190
packages/chord/test/delta-append-text.test.ts
Normal file
|
|
@ -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<T extends object>(tracker: ReturnType<typeof track<T>>, mutate: (draft: Draft<T>) => 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<Block>;
|
||||
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.
|
||||
});
|
||||
});
|
||||
80
packages/chord/test/delta-op-text-retention.test.ts
Normal file
80
packages/chord/test/delta-op-text-retention.test.ts
Normal file
|
|
@ -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<T>(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);
|
||||
});
|
||||
});
|
||||
733
packages/chord/test/delta-pending.test.ts
Normal file
733
packages/chord/test/delta-pending.test.ts
Normal file
|
|
@ -0,0 +1,733 @@
|
|||
// 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 = <T>(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<typeof random>;
|
||||
|
||||
/** 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<string | number> = 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<T extends object>(
|
||||
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<T>;
|
||||
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<JsonValue>(
|
||||
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 };
|
||||
}
|
||||
|
||||
/** 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 < 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 < 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 < 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 < 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. */
|
||||
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<void> => {
|
||||
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<object>; replaced: WeakRef<object> } => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<void>; readonly resolve: () =>
|
|||
}
|
||||
|
||||
/** A watch whose detach and reports are recorded. */
|
||||
function recordedWatch<T>(value: T, replace?: () => T) {
|
||||
function recordedWatch<T>(value: T) {
|
||||
const recorded = { detached: 0, reported: [] as unknown[] };
|
||||
const watch = new Watch<T>(
|
||||
value,
|
||||
() => recorded.detached++,
|
||||
(error) => recorded.reported.push(error),
|
||||
replace,
|
||||
);
|
||||
return { watch, recorded };
|
||||
}
|
||||
|
||||
/** A started watch collecting its frames. */
|
||||
function collectingWatch<T>(value: T, replace?: () => T) {
|
||||
const { watch, recorded } = recordedWatch(value, replace);
|
||||
/** A started watch collecting its calls. */
|
||||
function collectingWatch<T>(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<JsonValue>({ 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<JsonValue>({ 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<JsonValue>(0);
|
||||
const delivered: Array<readonly Op[]> = [];
|
||||
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<JsonValue>(0);
|
||||
it("awaits each callback and folds frames advanced while one is in flight into the next call", async () => {
|
||||
const { watch } = recordedWatch<JsonValue>({ 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<JsonValue>(0);
|
||||
const key = createContextKey<string>("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<Doc>({ entries: [], tokens: 0 });
|
||||
const { watch } = recordedWatch<Doc>(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<JsonValue>, 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<JsonValue>(0);
|
||||
for (let n = 1; n <= 101; n++) watch.advance(n, [["r", n]], context);
|
||||
const frames: Array<{ value: JsonValue; ops: readonly Op[] }> = [];
|
||||
watch.start(async (value, ops) => {
|
||||
frames.push({ value, ops });
|
||||
});
|
||||
it(`keeps ${MAX_PENDING_OPS} pending ops after folding and replaces the root past them`, async () => {
|
||||
const exact = collectingWatch<JsonValue>({ 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<JsonValue>({ 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<JsonValue>(0);
|
||||
for (let n = 1; n <= 100; n++) watch.advance(n, [["r", n]], context);
|
||||
const values: JsonValue[] = [];
|
||||
watch.start(async (value) => {
|
||||
values.push(value);
|
||||
});
|
||||
await flush();
|
||||
expect(values).toEqual(Array.from({ length: 100 }, (_, index) => index + 1));
|
||||
});
|
||||
|
||||
it("delivers the value of `replace` on overflow", async () => {
|
||||
const { watch } = recordedWatch<JsonValue>(0, () => "replacement");
|
||||
for (let n = 1; n <= 101; n++) watch.advance(n, [], context);
|
||||
const frames: Array<{ value: JsonValue; ops: readonly Op[] }> = [];
|
||||
watch.start(async (value, ops) => {
|
||||
frames.push({ value, ops });
|
||||
});
|
||||
await flush();
|
||||
expect(frames).toEqual([{ value: "replacement", ops: [["r", "replacement"]] }]);
|
||||
});
|
||||
|
||||
it("never folds the in-flight frame into an overflow", async () => {
|
||||
const { watch } = recordedWatch<JsonValue>(0);
|
||||
it("never folds the in-flight call into an overflow", async () => {
|
||||
const { watch } = recordedWatch<JsonValue>({ 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<JsonValue>({ 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<JsonValue>(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<JsonValue>(0);
|
||||
for (let n = 1; n <= 100; n++) watch.advance(n, [["r", n]], context);
|
||||
watch.retire(context);
|
||||
const frames: Array<{ value: JsonValue; ops: readonly Op[] }> = [];
|
||||
watch.start(async (value, ops) => {
|
||||
frames.push({ value, ops });
|
||||
});
|
||||
expect(await watch.closed).toEqual({ reason: "retired" });
|
||||
expect(frames).toEqual([{ value: null, ops: [["r", null]] }]);
|
||||
it("drops pending frames, also an overflow, for the retirement", async () => {
|
||||
for (const pending of [3, MAX_PENDING_OPS + 1]) {
|
||||
const { watch } = recordedWatch<JsonValue>({ 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<JsonValue>(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<JsonValue>(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<void> => {};
|
||||
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<JsonValue>, gate: () => void) => Promise<void>) => {
|
||||
const payload = { big: "x".repeat(1000) };
|
||||
const watch = new Watch<JsonValue>(
|
||||
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<string>(
|
||||
"",
|
||||
() => {},
|
||||
() => {},
|
||||
() => "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<number>(
|
||||
|
|
|
|||
|
|
@ -2,9 +2,25 @@
|
|||
|
||||
## [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))
|
||||
|
||||
### 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))
|
||||
|
||||
### 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))
|
||||
- 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))
|
||||
- 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
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ pi update [target] [options]
|
|||
pi list
|
||||
pi config [options]
|
||||
pi auth <check|print-api-key|print-bearer-token> [options]
|
||||
pi mcp <list|login|logout> [options]
|
||||
pi mcp <add|remove|list|login|logout> [options]
|
||||
```
|
||||
|
||||
<a id="modes"></a>
|
||||
|
|
@ -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 <server> [--timeout <seconds>]` | 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 <server>` | 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 <mode>` (see [Exposure](mcp.md#exposure)) and `--description <text>` 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 <mode>` (see [Exposure](mcp.md#control-tool-exposure)) and `--description <text>` 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.
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
<a id="extensioncontext"></a>
|
||||
<a id="extensioncommandcontext"></a>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ pi install ./local-package
|
|||
|
||||
`pi list` shows configured packages. Use `pi remove <source>` 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.
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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<number, { contentIndex: number; arguments?: ToolCallArgumentsStream }>();
|
||||
|
||||
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 });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 <command> Print credentials or check provider readiness
|
||||
${APP_NAME} mcp <command> Check MCP servers, sign in to or out of OAuth servers
|
||||
${APP_NAME} mcp <command> Manage MCP servers
|
||||
${APP_NAME} <command> --help Show help for install/remove/uninstall/update/list/config/auth/mcp
|
||||
|
||||
${chalk.bold("Options:")}
|
||||
|
|
@ -334,7 +334,7 @@ ${chalk.bold("Options:")}
|
|||
--use-theme <name[/name]> 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 <file> Export session file to HTML and exit
|
||||
--export <file> [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 <mode> TUI mode: fullscreen (default) or regular
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<MissingSourceAction>,
|
||||
): Promise<void> {
|
||||
): Promise<PackageInstallFailure[]> {
|
||||
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<boolean> => {
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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<PackageInstallFailure[] | undefined>
|
||||
): 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -236,6 +236,7 @@ export {
|
|||
type ModelRuntimeAuthOverrides,
|
||||
} from "./core/model-runtime.ts";
|
||||
export type {
|
||||
PackageInstallFailure,
|
||||
PackageManager,
|
||||
PathMetadata,
|
||||
ProgressCallback,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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<string, ToolConfig> = {
|
||||
|
|
@ -34,38 +46,14 @@ const TOOLS: Record<string, ToolConfig> = {
|
|||
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<string> {
|
|||
|
||||
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...` });
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<typeof Os>();
|
||||
return {
|
||||
...actual,
|
||||
arch: vi.fn(() => "x64"),
|
||||
platform: vi.fn(() => "linux"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof ChildProcess>();
|
||||
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.`,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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()`.
|
||||
- Opening a SQLite storage migrates it to schema version 3 automatically, stamping conversations, tasks, and submissions with a commit sequence and indexing every stamp so a commit log can be read in ranges. A migrated database cannot be opened by 1.4.0 or earlier.
|
||||
|
||||
|
|
@ -18,7 +19,14 @@
|
|||
|
||||
### 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
|
||||
|
||||
- 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -5560,9 +5564,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
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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<AgentEventStream> {
|
||||
const views = conversationViews(harness);
|
||||
let watch!: Watch<readonly AgentEvent[]>;
|
||||
let watch!: NativeWatch<readonly AgentEvent[]>;
|
||||
let snapshot!: SnapshotEvent;
|
||||
await views.attach(
|
||||
conversationId,
|
||||
|
|
@ -178,8 +178,9 @@ export async function watchEvents(
|
|||
const held = new Set<TaskId>(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<readonly AgentEvent[]>(
|
||||
// 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<readonly AgentEvent[]>(
|
||||
[],
|
||||
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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<LiveState>, 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<void> | 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<Record<string, JsonValue>>, "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<Record<string, JsonValue>>, 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,53 @@
|
|||
import type { JsonValue } from "@earendil-works/chord";
|
||||
import { appendText, type Draft } from "@earendil-works/chord/delta";
|
||||
|
||||
type JsonContainer = Record<string, JsonValue> | 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<string | number, AssignPlan> };
|
||||
|
||||
/**
|
||||
* 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<string | number, JsonValue>;
|
||||
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<Record<string, string>>, String(key), plan.text);
|
||||
return;
|
||||
}
|
||||
if (current !== value) slots[key] = value;
|
||||
}
|
||||
|
||||
|
|
|
|||
166
packages/durable/src/harness/partial-changes.ts
Normal file
166
packages/durable/src/harness/partial-changes.ts
Normal file
|
|
@ -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<string, JsonValue> | 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<number, { readonly changes: readonly JsonChange[]; readonly snapshot: JsonValue }>;
|
||||
};
|
||||
|
||||
/** 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<number, { readonly field: "text" | "thinking"; text: string }>();
|
||||
readonly #toolCalls = new Map<number, ToolCallState>();
|
||||
|
||||
/** 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<string | number, AssignPlan>();
|
||||
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<number, { readonly changes: readonly JsonChange[]; readonly snapshot: JsonValue }>();
|
||||
for (const [index, call] of this.#toolCalls) {
|
||||
const block = message.content[index] as Record<string, JsonValue>;
|
||||
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<Record<string, JsonValue>>, 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<string, JsonValue>[] }).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<Record<string, JsonValue>>[] }).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<Record<string, JsonValue>>, 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<JsonContainer>;
|
||||
for (const segment of change.path.slice(0, -1)) {
|
||||
parent = (parent as Record<string | number, Draft<JsonContainer>>)[segment]!;
|
||||
}
|
||||
const slots = parent as Record<string | number, JsonValue>;
|
||||
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<Record<string, string>>, String(key), change.text);
|
||||
}
|
||||
|
||||
/** The value a `set` change reports: JSON data, which pi-ai types with read-only arrays. */
|
||||
function value(change: Extract<JsonChange, { kind: "set" }>): JsonValue {
|
||||
return change.value as JsonValue;
|
||||
}
|
||||
|
||||
function isRecord(value: JsonValue | undefined): boolean {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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<TaskGraphWatch> {
|
||||
const { observer } = await this.#attach(
|
||||
(value, release) => new Watch<TaskGraph>(value, release, (error) => this.#session.report(error)),
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -778,7 +778,7 @@ export interface Conversation {
|
|||
* owned, transitively, by its non-background tasks.
|
||||
*/
|
||||
waitForIdle(context: Context): Promise<void>;
|
||||
/** 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<ConversationWatch>;
|
||||
}
|
||||
|
||||
|
|
@ -837,8 +837,8 @@ export interface Harness extends Session {
|
|||
/** Session total: every conversation's `pi.usage` summed. */
|
||||
usage(context: Context): Promise<UsageState>;
|
||||
/**
|
||||
* 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<TaskGraphWatch>;
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue