mirror of
https://github.com/vercel-labs/fx.git
synced 2026-10-28 10:15:17 +08:00
Merge fazxes/mcp-reliability-fixes into fazxes/update-mcp-tla-models
This commit is contained in:
129 files changed
+10837
-709
No files matched your search
@@ -304,6 +304,7 @@ jobs:
|
||||
|
||||
- name: Test durable sessions on N-API
|
||||
run: |
|
||||
node sdk/tests/test-vercel-storage.mjs
|
||||
node sdk/tests/test-durable.mjs memory native
|
||||
env LIBFX_TEST_HOOKS=1 node sdk/tests/test-durable.mjs memory native
|
||||
node sdk/tests/test-durable.mjs local native
|
||||
@@ -389,6 +390,10 @@ jobs:
|
||||
bun tests/tla/drivers/drive.mjs cutoff --walks 20 --seed 5
|
||||
bun tests/tla/drivers/drive.mjs leased --walks 40 --seed 12
|
||||
bun tests/tla/drivers/drive.mjs queue --walks 40 --seed 4
|
||||
bun tests/tla/drivers/drive-intake.mjs --walks 60 --seed 1
|
||||
bun tests/tla/drivers/drive-intake.mjs --walks 30 --seed 2 --durability world
|
||||
bun tests/tla/drivers/drive-images.mjs --walks 40 --seed 1
|
||||
bun tests/tla/drivers/drive-images.mjs --walks 20 --seed 2 --backend wasm
|
||||
bun tests/tla/drivers/drive-agents.mjs --walks 60 --seed 11
|
||||
bun tests/tla/drivers/drive-tenants.mjs --walks 60 --seed 11
|
||||
bun tests/tla/drivers/drive-steer.mjs ideal --walks 20 --seed 2
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
- Prompts sent to a busy libfx session queue behind its running turn and start when it finishes.
|
||||
- Reusing a libfx `messageId` follows the original turn instead of starting it again.
|
||||
- libfx now includes TypeScript types for its public APIs.
|
||||
- Pass `hooks` to `createFxAgent()` or `createFxEngine()` to observe model calls, tools, approvals, subagents, and saved-session activity on native and WebAssembly backends. Content capture is opt-in, and callback failures do not interrupt or retry agent work.
|
||||
- With `--sessions-v2`, opening a session saved in the previous format converts it together with its subagent sessions. The previous-format files are removed only after the converted sessions are saved.
|
||||
- Use `fx --sessions-v2 sessions convert` to convert all sessions saved in the previous format at once.
|
||||
- On Vercel, libfx can use deployment OIDC credentials instead of an API key.
|
||||
- `createFxEngine()` adds queued follow-ups with `followUp()` and lets you withdraw pending steering with `turn.withdraw()`.
|
||||
- `/usage` and `fx usage` now show your provider's usage reports instead of locally recorded usage.
|
||||
@@ -39,6 +42,8 @@
|
||||
- Native libfx reuses HTTP connections between turns without delaying streamed output.
|
||||
- libfx shares token refreshes across sessions and supports `refreshLock` for multi-server apps.
|
||||
- `@` suggestions reuse a saved workspace index, and file-content searches run in parallel.
|
||||
- `@` suggestions rank exact file names, name prefixes, and whole-word matches above scattered letters. Searches can span a file's containing folder and its name, and highlighted letters show the match used to rank it.
|
||||
- Short `@` searches and searches with many exact matches return faster. The file picker shows up to 10 rows when the terminal has room.
|
||||
- Repeated file searches use less memory during long turns.
|
||||
- `grep_files` now searches files larger than 200 KiB and reports when results are incomplete.
|
||||
- `ctrl+o` now wraps long commands and tool details instead of cutting them off.
|
||||
@@ -63,6 +68,8 @@
|
||||
- libfx saves each tool result as it finishes, so recovery can reuse completed work.
|
||||
- Interrupted libfx tools no longer appear to run forever after recovery. Their events report that the call may have partly run.
|
||||
- libfx rejects checkpoints created by a newer version instead of attempting to restore them.
|
||||
- A steer that starts a durable libfx turn is no longer applied twice or dropped when it contains images.
|
||||
- Cancelling a durable libfx session no longer stops a queued prompt that starts after the cancellation. Steering already assigned to another queued prompt no longer reaches the running turn.
|
||||
- Usage totals stay visible when a refresh fails or the local cache becomes unreadable.
|
||||
- Gateway requests no longer fail intermittently at startup.
|
||||
- libfx rejects invalid host HTTP statuses instead of crashing the process.
|
||||
@@ -79,6 +86,8 @@
|
||||
- Grok token refresh rejects credentials belonging to a different account.
|
||||
- libfx sign-in helpers reject pasted callbacks from a different sign-in.
|
||||
- Custom libfx endpoints require HTTPS outside localhost.
|
||||
- libfx routes queued work to the named agent on the intended store, rather than another store with the same agent name. Use a separate `world()` store for each customer whose sessions must stay separate.
|
||||
- libfx ignores queue deliveries that do not refer to an input already saved by the app. A delivery cannot replace that input or its caller context and session settings; apps must still authorize session access and authenticate their queue routes.
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
@@ -89,6 +98,8 @@
|
||||
- Native libfx runs idempotent tools in parallel unless marked `writes: true`. Other tools run alone by default.
|
||||
- Images sent to durable libfx sessions must be Base64-encoded. `createFxEngine()` still accepts image files and raw bytes directly.
|
||||
- Disconnecting from a durable libfx turn no longer stops it. Use `session.cancel()` or an abort signal.
|
||||
- In one process, libfx agents with the same name on the same store must use the same configuration. Creating a conflicting agent throws `FX_AGENT_NAME_IN_USE`; give different configurations separate names or stores.
|
||||
- Durable libfx prompts and steers, including their context and session settings, must fit within 8 MiB of JSON. Larger inputs throw `FX_INPUT_TOO_LARGE` before anything is saved.
|
||||
- ACP's `code` mode has been replaced by `auto`. Clients should use `auto`, `ask`, or `full-access`.
|
||||
- `/cost` has been removed. Use `/usage`.
|
||||
- `fx usage --period` now accepts `today`, `7d`, and `month`. The `24h` and `30d` periods have been removed.
|
||||
|
||||
+4
-1
@@ -27,6 +27,8 @@ The SDK has two WebAssembly surfaces and one shared JavaScript host layer:
|
||||
| A durability over a World the app supplies; `local()` and `vercel()` are built on it | `sdk/durable/world.mjs` |
|
||||
| TypeScript declarations for every entry point | `sdk/types/`, checked by `sdk/tests/test-types.mjs` |
|
||||
| Checkpoint format, including the libfx version, tool set, and model each checkpoint records | `src/core/agent/runtime/checkpoint.zig` |
|
||||
| Images a session saves once, by name: naming in journal events and checkpoints, and the two-phase open | `src/core/session/session_codec.zig`, `src/acp/libfx_journal.zig`, and `src/acp/server.zig` |
|
||||
| A session's image store (the internal `persistenceImages` key) and storing before sending | `sdk/fx-sdk.js`; a durable session's images are World streams in `sdk/durable.js` |
|
||||
| Commands required by CI | `.github/workflows/ci.yml` |
|
||||
|
||||
Do not treat the demos or this file as the implementation contract. When prose and behavior disagree, establish the current behavior from code and focused tests before changing either.
|
||||
@@ -52,6 +54,7 @@ Do not treat the demos or this file as the implementation contract. When prose a
|
||||
| Public export, option, event, or result shape | Update `sdk/types/` and run `node sdk/tests/test-types.mjs` after `npm ci --prefix sdk/node` |
|
||||
| `createFxAgent()`, ACP translation, core session persistence, streaming, or cancellation | Core build, core Node tests, and the browser test when browser behavior is involved |
|
||||
| Live Gateway request or model-catalog translation | Core tests plus the opt-in live smoke test when a credential is available |
|
||||
| How a session saves images: naming, the image store, staging at open | Core and terminal builds, `bun sdk/tests/test-journal.mjs native wasm` and `wasm native`, `test-checkpoint.mjs` both ways, the Zig tests of `checkpoint.zig` and `journal.zig`, then `tests/tla/sdk/check.sh` and `bun tests/tla/drivers/drive-images.mjs` with `--backend native` and `wasm`, which walks the graph of `ImageStore.tla`; change the spec with the code. For durable sessions, also the durable sessions row |
|
||||
| Terminal adapter, input encoding, resize, cleanup, config, or prompt history | Terminal build and the headless terminal suite |
|
||||
| Terminal session persistence or browser device login | Terminal build plus `sdk/tests/test-term-session-resume.mjs` or `sdk/tests/test-term-login.mjs` |
|
||||
| Browser workspace metadata, permissions, execution, limits, or cancellation | Terminal build plus `sdk/node/test-term-workspace.mjs` |
|
||||
@@ -59,7 +62,7 @@ Do not treat the demos or this file as the implementation contract. When prose a
|
||||
| `encodeXtermKeyEvent()` or `xtermAdapter()` only | `sdk/node/test-xterm-adapter.mjs` |
|
||||
| Core debugger query behavior or automation state | `sdk/tests/test-core-browser.mjs` |
|
||||
| Terminal demo asset references, integrity, or cache policy | Package into a fresh temporary directory and inspect the generated HTML, manifest, and headers |
|
||||
| Durable sessions: leases, fencing, takeovers, tool reruns, the UI stream | `sdk/tests/test-durable-core.mjs` on memory, local, world, and queue (the core with a scripted harness; world is an app-built World passed to `world()`, queue one whose queue delivers as Vercel Queues does, with `queueLeases`), `sdk/tests/test-durable.mjs` on memory, local, and world, then `tests/tla/sdk/check.sh` and `bun tests/tla/drivers/drive.mjs send`, `lookup`, `write`, `cutoff` (deadline cut-offs up to their bound), `leased` (a World without liveness, where each worker renews a short lease), and `queue` (a World whose queue shows whether a worker lives, so leases are held without renewals), which walk the real code along the TLC state graphs of `DurableSession.tla`; change the spec with the code. For agent names and routing, also `bun tests/tla/drivers/drive-agents.mjs`, which walks `AgentRouting.tla`'s graph with each process in a worker thread of its own. For what a delivery may run, on whose agent and store, and input limits, also `bun tests/tla/drivers/drive-tenants.mjs`, which walks `TenantIsolation.tla`'s graph with four tenants and a forger sharing one route. With a Vercel access token, also run both against Vercel's World: `test-durable.mjs vercel native` and `drive.mjs --durability vercel`, configured as `sdk/tests/vercel-storage.mjs` describes |
|
||||
| Durable sessions: leases, fencing, takeovers, tool reruns, the UI stream | `sdk/tests/test-durable-core.mjs` on memory, local, world, and queue (the core with a scripted harness; world is an app-built World passed to `world()`, queue one whose queue delivers as Vercel Queues does, with `queueLeases`), `sdk/tests/test-durable.mjs` on memory, local, and world, then `tests/tla/sdk/check.sh` and `bun tests/tla/drivers/drive.mjs send`, `lookup`, `write`, `cutoff` (deadline cut-offs up to their bound), `leased` (a World without liveness, where each worker renews a short lease), and `queue` (a World whose queue shows whether a worker lives, so leases are held without renewals), which walk the real code along the TLC state graphs of `DurableSession.tla`; change the spec with the code. For how a session object takes prompts in (images and their image writes, cancels, and closing the agent), also run `bun tests/tla/drivers/drive-intake.mjs` on memory and `--durability world`, which walks the graph of `PromptIntake.tla`. For agent names and routing, also `bun tests/tla/drivers/drive-agents.mjs`, which walks `AgentRouting.tla`'s graph with each process in a worker thread of its own. For what a delivery may run, on whose agent and store, and input limits, also `bun tests/tla/drivers/drive-tenants.mjs`, which walks `TenantIsolation.tla`'s graph with four tenants and a forger sharing one route. With a Vercel access token, also run both against Vercel's World: `test-durable.mjs vercel native` and `drive.mjs --durability vercel`, configured as `sdk/tests/vercel-storage.mjs` describes |
|
||||
| Steering: interrupts, waits during tools, joins, hand-backs on cancel, image handoffs, steer ids | The libfx steering tests in `src/acp/libfx_steering.zig`, `sdk/tests/test-agent-steering.mjs` and `sdk/tests/test-durable.mjs` on native and wasm, then `tests/tla/sdk/check.sh` and `bun tests/tla/drivers/drive-steer.mjs ideal` and `faults`, with `--backend wasm` too, which walk the real code along the TLC state graphs of `DurableSteer.tla`; change the spec with the code |
|
||||
| Supported public behavior or setup | Update `sdk/README.md` in the same change |
|
||||
|
||||
|
||||
+6
-5
@@ -55,9 +55,10 @@ agent.session(sessionId).prompt("Start with the schema.");
|
||||
|
||||
- **One turn at a time.** A prompt sent while a turn runs waits behind it, even when it reaches a different server.
|
||||
- **Turns finish on their own.** A turn runs to the end whether or not anyone reads its events, and a turn nobody reads holds no connection open: its view starts reading only once you use its events, `readable` or `result`. Stop it with `session.cancel()`, or with the `signal` you passed to `prompt()`.
|
||||
- **Steering.** `session.steer(input)` steers the running turn the way the fx CLI does. While the model streams a reply, the steer cuts it short and the turn continues with it. While a tool runs, the steer waits for the tool. A steer sent while a turn waits to start joins that turn, and when no turn runs or waits, the steer becomes the next turn. A steer with images can't join a turn, so the running turn ends at its next safe point and the steer runs next.
|
||||
- **Steering.** `session.steer(input)` steers the running turn the way the fx CLI does. While the model streams a reply, the steer cuts it short and the turn continues with it. While a tool runs, the steer waits for the tool. A steer sent while a turn waits to start joins that turn, and when no turn runs or waits, the steer becomes the next turn. A steer with images can't join a turn, so the running turn ends at its next safe point and the steer runs next. Its images are saved once with the session before the steer is sent, as a prompt's are, and a steer whose image can't be saved throws the store's error.
|
||||
- **Retries are safe.** Give a prompt or a steer a `messageId`, and a repeated request with the same id runs nothing new. A repeated prompt follows the original turn, and its result has `repeated: true`. One id names one input, so a steer that reuses a prompt's id is rejected with `FX_MESSAGE_ID_IN_USE`. A steer's `user_message` event carries its id as `id` where it joined a turn.
|
||||
- **Inputs have a size limit.** A prompt or steer whose input, with the session's `context` and settings, comes to more than 8 MiB of JSON is rejected with `FX_INPUT_TOO_LARGE` before anything is stored. The model can't take a larger one.
|
||||
- **A refused prompt is never stored.** `prompt()` checks a prompt as the engine's `prompt()` does and throws a `TypeError` or `RangeError` for one libfx would refuse, such as an empty prompt, base64 that isn't canonical, an image whose bytes aren't its type, or more than 8 images. A prompt whose `signal` aborts before the prompt is stored isn't stored either, and its turn ends `cancelled`.
|
||||
|
||||
Each conversation can bring its own settings, which apply to the turns that session object starts:
|
||||
|
||||
@@ -263,7 +264,7 @@ const agent = createFxAgent({
|
||||
| `signal` | Aborts when the turn is cancelled |
|
||||
| `executionId` | The call's id, the same if the call runs again after a crash |
|
||||
| `sessionId` | The session the call belongs to |
|
||||
| `context` | The JSON passed as `context` to `agent.session()` |
|
||||
| `context` | A copy of the JSON passed as `context` to `agent.session()`, taken when you call it |
|
||||
|
||||
Return a string to send it as is, or any other value to send it as JSON. A thrown error reaches the model as a failed tool result. To return images, return `{ type: "libfx.tool-result", text, images }`, with base64 image data.
|
||||
|
||||
@@ -407,7 +408,7 @@ agent.session().prompt([
|
||||
]);
|
||||
```
|
||||
|
||||
A session stores each prompt as JSON before it runs, so a session prompt takes image data as a base64 string. `createFxEngine()` also takes bytes, a `Blob` or a `File`. Each prompt takes up to 8 PNG, JPEG, GIF or WebP images, at most 3.75 MiB each and 6 MiB together. Pass `resizeImage` to downscale or convert prompt images before they're sent.
|
||||
A session stores each prompt as JSON before it runs, so a session prompt takes image data as a base64 string. `createFxEngine()` also takes bytes, a `Blob` or a `File`. A session saves each image once, named by its SHA-256, before it stores the prompt, and the saved conversation refers to it by that name, so images don't count toward the 4 MiB the session's own checkpoints hold. Images a host tool returns are saved the same way; one that can't be saved stays in the tool's result, held inline. A session keeps its newest 4,096 images, up to 512 MiB of them; an older image leaves the conversation, and the model sees a note in its place. A checkpoint you take with `checkpoint()` carries the images' bytes, within its 4 MiB. An engine with a `persistence` store of its own keeps images inside its records and checkpoints, so they count toward that 4 MiB; for a conversation with many images, use a session with [durability](#durability). libfx never deletes a saved image; it lasts as long as the session's storage. Each prompt takes up to 8 PNG, JPEG, GIF or WebP images, at most 3.75 MiB each and 6 MiB together. Pass `resizeImage` to downscale or convert prompt images before they're sent.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
@@ -498,9 +499,9 @@ interface JournalRecord {
|
||||
}
|
||||
```
|
||||
|
||||
Reject an `append()` whose `expected` isn't the last stored cursor with `FxFencedError`, so two processes can't write the same conversation. If your store keeps entries of its own after the last record, return the cursor the next `append()` should name as `head`. When a resumed turn left a tool call running, `resume({ onAmbiguous })` lets you return `"rerun"` to run it again under the same `executionId`.
|
||||
Reject an `append()` whose `expected` isn't the last stored cursor with `FxFencedError`, so two processes can't write the same conversation. If your store keeps entries of its own after the last record, return the cursor the next `append()` should name as `head`. When a resumed turn left a tool call running, `resume({ onAmbiguous })` lets you return `"rerun"` to run it again under the same `executionId`. A resumed turn first sends, as `text_delta`, the reply the last process had started, since the new engine's host saw none of it. If that process stopped after the turn's final reply was stored but before the turn's end was, `resume()` then ends the turn with that reply instead of asking the model again.
|
||||
|
||||
The engine sends one `append()` at a time, and everything that happens while one is in flight goes in the next record. `barrier` is `true` when the engine waits for the record before it goes on, such as before a tool call with effects. A store may acknowledge a record with `barrier: false` before it is durable, as long as it keeps records in order and acknowledges a barrier only once it and every record before it are durable. Set `marks: true` to receive each record's `marks`, which say where turns start (`{ start: turnId }`), yield and end, and which inputs a turn took (`{ accepted: id }`), so a store can track turns without reading records.
|
||||
The engine sends one `append()` at a time, and everything that happens while one is in flight goes in the next record. `barrier` is `true` when something waits for the record, such as a tool call with effects, or a turn's `result`, which resolves only once the record that ends the turn is stored and rejects if that record can't be stored. A store may acknowledge a record with `barrier: false` before it is durable, as long as it keeps records in order and acknowledges a barrier only once it and every record before it are durable. Set `marks: true` to receive each record's `marks`, which say where turns start (`{ start: turnId }`), yield and end, and which inputs a turn took (`{ accepted: id }`), so a store can track turns without reading records.
|
||||
|
||||
`prompt()` and `resume()` take `yieldAt`, a time in milliseconds since the epoch: the turn stops before its next model request after that time, with the stop reason `yielded`, and the next `resume()` continues it without telling the model it was interrupted.
|
||||
|
||||
|
||||
+337
-40
@@ -75,14 +75,129 @@ const recentIdsKept = 256;
|
||||
// The longest a message waits for a live holder before it is delivered again.
|
||||
const maxBackstopSeconds = 300;
|
||||
|
||||
// Base64 of a Uint8Array: the runtime's own encoder where it has one, and
|
||||
// otherwise four letters from the table for each three bytes. Both are fast
|
||||
// enough for a large image; btoa over String.fromCharCode is not.
|
||||
const base64Letters = encoder.encode("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/");
|
||||
const toBase64 = (bytes) => {
|
||||
let text = "";
|
||||
for (let index = 0; index < bytes.length; index += 0x8000) {
|
||||
text += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
|
||||
if (typeof bytes.toBase64 === "function") return bytes.toBase64();
|
||||
const letters = new Uint8Array(Math.ceil(bytes.length / 3) * 4);
|
||||
const whole = bytes.length - (bytes.length % 3);
|
||||
let at = 0;
|
||||
for (let index = 0; index < whole; index += 3) {
|
||||
const group = (bytes[index] << 16) | (bytes[index + 1] << 8) | bytes[index + 2];
|
||||
letters[at] = base64Letters[group >> 18];
|
||||
letters[at + 1] = base64Letters[(group >> 12) & 63];
|
||||
letters[at + 2] = base64Letters[(group >> 6) & 63];
|
||||
letters[at + 3] = base64Letters[group & 63];
|
||||
at += 4;
|
||||
}
|
||||
return btoa(text);
|
||||
if (whole < bytes.length) {
|
||||
// One or two bytes left: two or three letters, then "=" padding.
|
||||
const two = whole + 1 < bytes.length;
|
||||
const group = (bytes[whole] << 16) | (two ? bytes[whole + 1] << 8 : 0);
|
||||
letters[at] = base64Letters[group >> 18];
|
||||
letters[at + 1] = base64Letters[(group >> 12) & 63];
|
||||
letters[at + 2] = two ? base64Letters[(group >> 6) & 63] : 61;
|
||||
letters[at + 3] = 61;
|
||||
}
|
||||
return decoder.decode(letters);
|
||||
};
|
||||
const fromBase64 = (text) => Uint8Array.from(atob(text), (char) => char.charCodeAt(0));
|
||||
const fromBase64 = (text) => {
|
||||
if (typeof Uint8Array.fromBase64 === "function") return Uint8Array.fromBase64(text);
|
||||
const binary = atob(text);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
// A session keeps each prompt image once, in its image store, named by the
|
||||
// lowercase hex SHA-256 of its bytes, as libfx's core names it; the log only
|
||||
// names it.
|
||||
async function imageName(bytes) {
|
||||
const digest = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", bytes));
|
||||
let name = "";
|
||||
for (const byte of digest) name += byte.toString(16).padStart(2, "0");
|
||||
return name;
|
||||
}
|
||||
const base64url = (text) => toBase64(encoder.encode(text)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
// The key a harness's store gives its image store under, `{ put, get }`:
|
||||
// `persistenceImages` in fx-sdk.js, which engines read it from.
|
||||
const persistenceImages = Symbol.for("libfx.persistenceImages");
|
||||
|
||||
// A view of the bytes in an ArrayBuffer or typed array, or null.
|
||||
const byteView = (value) => (value instanceof ArrayBuffer ? new Uint8Array(value)
|
||||
: ArrayBuffer.isView(value) ? new Uint8Array(value.buffer, value.byteOffset, value.byteLength) : null);
|
||||
|
||||
// A prompt's input as the queue and the log hold it: JSON, so image data is
|
||||
// base64 text. With `named`, the session stores each image in its image
|
||||
// store and the log names it (`storePromptImages`); `images` says whether any
|
||||
// image has data to store.
|
||||
//
|
||||
// Each image block is copied once and only the copy is read, since an
|
||||
// accessor can return something else the next time it is read, and the log
|
||||
// must hold what was checked. The blocks are walked by index for the same
|
||||
// reason: an Array subclass can make map() return anything. Pure.
|
||||
function promptInputForLog(input, { named = false } = {}) {
|
||||
let images = false;
|
||||
const blocks = [];
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
const block = input[index];
|
||||
if (block?.type !== "image") {
|
||||
blocks.push(block);
|
||||
continue;
|
||||
}
|
||||
const image = { ...block };
|
||||
if (image.data !== undefined && typeof image.data !== "string") throw new TypeError("a durable prompt carries image data as a base64 string");
|
||||
if (typeof image.data === "string") images = named;
|
||||
blocks.push(image);
|
||||
}
|
||||
return { input: blocks, images };
|
||||
}
|
||||
|
||||
// Stores each image a prompt's input holds in the session's image store,
|
||||
// before the input that names it is stored, and returns the input naming
|
||||
// them: `{ type: "image", mimeType, sourceRef?, data: { sha256 } }`. An older
|
||||
// libfx refuses data it can't read, so a worker that predates names fails
|
||||
// the turn rather than run it without its images. A store that fails rejects
|
||||
// with its own error. With `images` null it only names them, for a prompt the
|
||||
// log already holds.
|
||||
async function storePromptImages(images, input) {
|
||||
const blocks = [];
|
||||
for (const block of input) {
|
||||
if (block?.type !== "image" || block.data === undefined) {
|
||||
blocks.push(block);
|
||||
continue;
|
||||
}
|
||||
const { data, ...rest } = block;
|
||||
const bytes = fromBase64(data);
|
||||
const sha256 = await imageName(bytes);
|
||||
if (images) await images.put(sha256, bytes);
|
||||
blocks.push({ ...rest, data: { sha256 } });
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
// A prompt's input with each named image read back from the session's image
|
||||
// store, as base64, which is how a harness has always received image data.
|
||||
// `missing` names the first image the store does not hold.
|
||||
async function resolvePromptImages(images, input) {
|
||||
if (!Array.isArray(input)) return { input };
|
||||
const blocks = [];
|
||||
for (const block of input) {
|
||||
const name = block?.type === "image" ? block.data?.sha256 : undefined;
|
||||
if (typeof name !== "string") {
|
||||
blocks.push(block);
|
||||
continue;
|
||||
}
|
||||
const bytes = await images.get(name);
|
||||
if (!bytes) return { missing: name };
|
||||
// Bytes that aren't the ones named are not the image its caller sent.
|
||||
if (await imageName(bytes) !== name) return { missing: name, corrupt: true };
|
||||
blocks.push({ ...block, data: toBase64(bytes) });
|
||||
}
|
||||
return { input: blocks };
|
||||
}
|
||||
|
||||
/**
|
||||
* The durability that keeps sessions in this process only. Agents given the
|
||||
@@ -451,7 +566,7 @@ function createMemoryBackend({ maxDurationMs, reserveMs } = {}) {
|
||||
const sessionFor = (id) => {
|
||||
let session = sessions.get(id);
|
||||
if (!session) {
|
||||
session = { entries: [], head: null, ui: [], uiWaiters: new Set(), watchers: new Set() };
|
||||
session = { entries: [], head: null, ui: [], uiWaiters: new Set(), watchers: new Set(), images: new Map() };
|
||||
sessions.set(id, session);
|
||||
}
|
||||
return session;
|
||||
@@ -530,6 +645,14 @@ function createMemoryBackend({ maxDurationMs, reserveMs } = {}) {
|
||||
session.watchers.add(fn);
|
||||
return () => session.watchers.delete(fn);
|
||||
},
|
||||
images: {
|
||||
async put(name, bytes) {
|
||||
if (!session.images.has(name)) session.images.set(name, bytes.slice());
|
||||
},
|
||||
async get(name) {
|
||||
return session.images.get(name)?.slice();
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
async write(lines) {
|
||||
session.ui.push(...lines);
|
||||
@@ -580,6 +703,9 @@ const logStep = "fx.log";
|
||||
// the run's id, then `_user_` and the base64url of the namespace
|
||||
// "libfx-ui".
|
||||
const uiStreamOf = (runId) => `${runId.replace(/^wrun_/, "strm_")}_user_bGliZngtdWk`;
|
||||
// The stream that holds an image, named as the UI stream is, with the
|
||||
// namespace "libfx-image-" and the image's name.
|
||||
const imageStreamOf = (runId, name) => `${runId.replace(/^wrun_/, "strm_")}_user_${base64url(`libfx-image-${name}`)}`;
|
||||
const queuePrefix = "__libfx_wkf_workflow_";
|
||||
// The name of an agent created without one, and of whatever a message or a
|
||||
// session from before names belonged to.
|
||||
@@ -867,6 +993,8 @@ function createWorldBackend(world, durability) {
|
||||
async session(runId) {
|
||||
// The newest slot this worker has seen.
|
||||
let known = 0;
|
||||
// The images this worker knows the run's image store holds.
|
||||
const storedImages = new Set();
|
||||
// Where the World has the in-band writer fence, chained entries are
|
||||
// written in-band, each expecting the World's count of in-band writes
|
||||
// at the entry it continues, so the World refuses one that continues a
|
||||
@@ -1047,6 +1175,44 @@ function createWorldBackend(world, durability) {
|
||||
set.add(fn);
|
||||
return () => set.delete(fn);
|
||||
},
|
||||
// Each image is a stream of the run, named after it, so it is read by
|
||||
// name wherever checkpoints fall. Two writers of one image each add
|
||||
// its bytes once; a read takes the first chunk that matches the name.
|
||||
images: {
|
||||
async put(name, bytes) {
|
||||
if (storedImages.has(name)) return;
|
||||
// Refused here, before the prompt is stored, rather than by every
|
||||
// worker that reads the image back.
|
||||
if (typeof world.streams.getChunks !== "function") throw new TypeError("a World needs streams.getChunks() to keep a session's images");
|
||||
await ensureRun();
|
||||
const stream = imageStreamOf(runId, name);
|
||||
let present = false;
|
||||
try {
|
||||
present = Number((await world.streams.getInfo(runId, stream))?.tailIndex ?? -1) >= 0;
|
||||
} catch (error) {
|
||||
if (!missingRun(error)) throw error;
|
||||
}
|
||||
if (!present) await world.streams.write(runId, stream, bytes);
|
||||
storedImages.add(name);
|
||||
},
|
||||
async get(name) {
|
||||
let page;
|
||||
try {
|
||||
page = await world.streams.getChunks(runId, imageStreamOf(runId, name), { limit: 8 });
|
||||
} catch (error) {
|
||||
if (missingRun(error)) return undefined;
|
||||
throw error;
|
||||
}
|
||||
for (const chunk of page?.data ?? []) {
|
||||
const bytes = byteView(chunk?.data);
|
||||
if (bytes && bytes.byteLength > 0 && await imageName(bytes) === name) {
|
||||
storedImages.add(name);
|
||||
return bytes.slice();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
async write(lines) {
|
||||
await ensureRun();
|
||||
@@ -1236,6 +1402,12 @@ const knownMessage = (state, message) => state.inputKeys.has(`${message.type}:${
|
||||
// input could never run; it is refused before anything is stored.
|
||||
const maxInputBytes = 8 * 1024 * 1024;
|
||||
const jsonBytes = (value) => (value === undefined ? 0 : encoder.encode(JSON.stringify(value)).byteLength);
|
||||
// A JSON value as it stands now, so what libfx measures is what it stores,
|
||||
// however the caller changes its object later. Pure.
|
||||
const jsonSnapshot = (value) => {
|
||||
const text = JSON.stringify(value);
|
||||
return text === undefined ? undefined : JSON.parse(text);
|
||||
};
|
||||
const inputTooLarge = (bytes) => Object.assign(
|
||||
new RangeError(`a durable input with its context and settings is ${bytes} bytes; the most is ${maxInputBytes}`),
|
||||
{ code: "FX_INPUT_TOO_LARGE", bytes, limit: maxInputBytes },
|
||||
@@ -1583,6 +1755,8 @@ class SessionWorker {
|
||||
const ui = uiWriter(log, (error) => agent.emit("ui.error", { sessionId: this.sessionId, error: error?.name ?? "Error" }), lease.epoch);
|
||||
// When the lease runs out, as this worker last renewed it.
|
||||
let leaseExpiresAt = lease.expiresAt;
|
||||
// An image read that threw while the engine opened.
|
||||
let imageReadFailed = null;
|
||||
const store = {
|
||||
load: async () => ({
|
||||
...(state.checkpoint?.data ? { checkpoint: { through: state.checkpoint.through ?? "0", data: fromBase64(state.checkpoint.data) } } : {}),
|
||||
@@ -1591,6 +1765,20 @@ class SessionWorker {
|
||||
head: head ?? state.head,
|
||||
}),
|
||||
append: ({ idempotencyKey, data, marks }) => chained({ k: "record", key: idempotencyKey, data: toBase64(data), marks }).then((cursor) => ({ cursor })),
|
||||
// The engine stores each prompt image before it sends it; a prompt
|
||||
// from the log names images already here.
|
||||
[persistenceImages]: {
|
||||
put: (name, bytes) => log.images.put(name, bytes),
|
||||
get: async (name) => {
|
||||
try {
|
||||
return await log.images.get(name);
|
||||
} catch (error) {
|
||||
// The store failed to answer, which says nothing of the image.
|
||||
imageReadFailed = error;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
saveCheckpoint: async ({ through, data }) => {
|
||||
// What a fold that starts at this checkpoint would otherwise miss:
|
||||
// inputs before it that no turn took or that the open turn holds
|
||||
@@ -1680,6 +1868,10 @@ class SessionWorker {
|
||||
harnessRun = runOf(state.openTurn ?? state.pending[0]);
|
||||
harness = await agent.openHarness(this.sessionId, store, harnessRun, ready);
|
||||
} catch (error) {
|
||||
// The image store failed to answer while the engine read the
|
||||
// session's images: as when the log can't be read, the delivery
|
||||
// fails and its message runs again; nothing failed the turn.
|
||||
if (imageReadFailed) throw imageReadFailed;
|
||||
// A harness that cannot start fails the turn waiting on it, once:
|
||||
// the open turn, or after it failed, the next prompt.
|
||||
const waiting = state.openTurn && !state.openFailed ? state.openTurn.id : state.pending[0]?.messageId;
|
||||
@@ -1757,8 +1949,21 @@ class SessionWorker {
|
||||
harness = await agent.openHarness(this.sessionId, store, wanted, ready);
|
||||
harnessRun = wanted;
|
||||
}
|
||||
// A prompt names the images it stored; the engine gets their bytes.
|
||||
const withImages = await resolvePromptImages(log.images, next.input);
|
||||
if (withImages.missing) {
|
||||
// A history without the image is not the one its caller sent.
|
||||
await log.append({ k: "ended", messageId });
|
||||
const message = withImages.corrupt
|
||||
? `the session's image store holds other bytes under image ${withImages.missing}`
|
||||
: `the session's image store does not hold image ${withImages.missing}`;
|
||||
ui.push({ type: "turn_end", messageId, stopReason: "error", usage: {}, error: { name: "Error", message } });
|
||||
await ui.settle();
|
||||
state = this.fold(await log.read());
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
turn = harness.prompt(next.input ?? "", { turnId: messageId, yieldAt });
|
||||
turn = harness.prompt(withImages.input ?? "", { turnId: messageId, yieldAt });
|
||||
} catch (error) {
|
||||
await log.append({ k: "stopped", messageId });
|
||||
return unusable(error);
|
||||
@@ -2221,6 +2426,9 @@ function turnView({ messageId, resumeRequest = null, start }) {
|
||||
* sets over the agent's options for the turns it starts, as JSON the core
|
||||
* stores with each prompt and passes to `open()`, or undefined for none.
|
||||
* It throws a TypeError for options it refuses.
|
||||
* - `checkPrompt(input)` (optional): throws a TypeError or RangeError for
|
||||
* a prompt input the harness would refuse. A session's `prompt()` calls it
|
||||
* before it stores anything, so a prompt it refuses is never stored.
|
||||
*
|
||||
* A harness session has `prompt(input, { turnId, yieldAt })` and
|
||||
* `resume({ yieldAt, rerun })`, each returning a turn, where `rerun: false`
|
||||
@@ -2238,7 +2446,8 @@ function turnView({ messageId, resumeRequest = null, start }) {
|
||||
* end. Either way the steer runs as the next turn. A turn stops before a
|
||||
* model request once `yieldAt` has passed, with the stop reason `yielded`.
|
||||
*
|
||||
* An input the harness refuses is a turn that ends with an error.
|
||||
* An input the harness refuses only once a turn runs it is a turn that ends
|
||||
* with an error.
|
||||
* `prompt()` and `resume()` throw only when the harness cannot run a turn,
|
||||
* and a turn whose harness stopped under it rejects with a
|
||||
* `FX_HARNESS_STOPPED` code; either way the same message runs the session
|
||||
@@ -2375,11 +2584,9 @@ export function createDurableAgentFactory({ harness, defaultDurability, defaultS
|
||||
|
||||
const openSession = (requestedId, sessionOptions = {}) => {
|
||||
if (requestedId !== undefined && !validId(requestedId)) throw new TypeError(`session id must be ${idRule}`);
|
||||
const context = sessionOptions?.context;
|
||||
if (context !== undefined) {
|
||||
try { JSON.stringify(context); } catch { throw new TypeError("session context must be JSON"); }
|
||||
}
|
||||
const settings = plugged.settings?.(sessionOptions ?? {});
|
||||
let context;
|
||||
try { context = jsonSnapshot(sessionOptions?.context); } catch { throw new TypeError("session context must be JSON"); }
|
||||
const settings = jsonSnapshot(plugged.settings?.(sessionOptions ?? {}));
|
||||
// What every input this session object sends carries besides itself.
|
||||
const carried = jsonBytes(context) + jsonBytes(settings);
|
||||
// Refuses an input too large to store before anything is stored
|
||||
@@ -2438,7 +2645,16 @@ export function createDurableAgentFactory({ harness, defaultDurability, defaultS
|
||||
await created.queue.send(queued, { idempotencyKey });
|
||||
seeded = true;
|
||||
};
|
||||
const startTurn = (type, messageId, input, retried = false, aborted = false) => {
|
||||
// Settles once the latest prompt from this session object with images
|
||||
// to store is stored or has failed; null while none has. Each later
|
||||
// prompt waits for it, so they are stored in the order made.
|
||||
let storing = null;
|
||||
// `ready`, when given, resolves the input once it can be stored; only a
|
||||
// prompt that is stored calls it.
|
||||
// `aborted()` says whether the prompt's signal has aborted. One that
|
||||
// aborted before the prompt's send began never stores it; `progress.sent`
|
||||
// is set as the send begins, after which an abort cancels the prompt.
|
||||
const startTurn = (type, messageId, input, retried = false, aborted = () => false, ready = null, progress = null) => {
|
||||
let accept;
|
||||
const accepted = new Promise((resolve, reject) => { accept = { resolve, reject }; });
|
||||
void accepted.catch(() => {});
|
||||
@@ -2447,20 +2663,27 @@ export function createDurableAgentFactory({ harness, defaultDurability, defaultS
|
||||
resumeRequest: type === "resume" ? messageId : null,
|
||||
start: async () => {
|
||||
try {
|
||||
const read = input === undefined || ready === null || aborted() ? input : await ready();
|
||||
const message = { type, messageId, ...(input === undefined ? {} : { input: read }) };
|
||||
const queue = () => {
|
||||
if (progress) progress.sent = true;
|
||||
return send(message, { inRequest: type === "prompt" });
|
||||
};
|
||||
const { created, id: sessionId } = await resolved;
|
||||
const log = await created.session(sessionId);
|
||||
let attaching = type === "resume";
|
||||
let from = null;
|
||||
if (retried && !aborted) {
|
||||
// The viewer's start is read before the message is queued, so
|
||||
// every line this message's own turn writes comes after it,
|
||||
// and an end for this id before it can only be from a turn
|
||||
// this id already ran. The log, for that answer, is read
|
||||
// alongside the one queue send.
|
||||
from = await log.ui.length();
|
||||
// The viewer's start is read before the message is queued, so
|
||||
// every line this message's own turn writes comes after it, and
|
||||
// an end for this id before it can only be from a turn this id
|
||||
// already ran.
|
||||
if (retried) from = await log.ui.length();
|
||||
if (retried && !aborted()) {
|
||||
// The log, for that answer, is read alongside the one queue
|
||||
// send.
|
||||
const [state] = await Promise.all([
|
||||
log.read().then(foldSessionLog),
|
||||
send({ type, messageId, ...(input === undefined ? {} : { input }) }, { inRequest: type === "prompt" }).then(() => accept.resolve({ messageId, sessionId })),
|
||||
queue().then(() => accept.resolve({ messageId, sessionId })),
|
||||
]);
|
||||
// One the log does not know yet is this send's own. When the
|
||||
// log says it ran and nothing shows how, either this send's
|
||||
@@ -2473,9 +2696,8 @@ export function createDurableAgentFactory({ harness, defaultDurability, defaultS
|
||||
}
|
||||
if (retried) {
|
||||
// A turn this id already ran answers from the log, without
|
||||
// queueing it again. The viewer's start is read first, so a
|
||||
// queueing it again. The viewer's start was read first, so a
|
||||
// turn that ends after it has its end in view.
|
||||
from = await log.ui.length();
|
||||
const outcome = await retryOutcome(log, foldSessionLog(await log.read()), messageId, from, { sent: false });
|
||||
if (outcome.follow) attaching = true;
|
||||
else if (!outcome.fresh) {
|
||||
@@ -2484,15 +2706,17 @@ export function createDurableAgentFactory({ harness, defaultDurability, defaultS
|
||||
return { log, from, attaching: true, endWithinMs: retryEndWaitMs };
|
||||
}
|
||||
}
|
||||
if (aborted && !attaching) {
|
||||
// A new prompt whose signal had aborted is never stored.
|
||||
accept.resolve({ messageId, sessionId });
|
||||
return { settled: { messageId, stopReason: "cancelled", usage: {} } };
|
||||
}
|
||||
// The viewer starts where the stream is now, before the
|
||||
// message can produce anything.
|
||||
from ??= await log.ui.length();
|
||||
await send({ type, messageId, ...(input === undefined ? {} : { input }) }, { inRequest: type === "prompt" });
|
||||
if (aborted()) {
|
||||
// A prompt whose signal aborted before its send began is never
|
||||
// stored; a retry of one the log holds only follows its turn.
|
||||
accept.resolve({ messageId, sessionId });
|
||||
if (!attaching) return { settled: { messageId, stopReason: "cancelled", usage: {} } };
|
||||
return { log, from, attaching };
|
||||
}
|
||||
await queue();
|
||||
accept.resolve({ messageId, sessionId });
|
||||
return { log, from, attaching };
|
||||
} catch (error) {
|
||||
@@ -2509,25 +2733,83 @@ export function createDurableAgentFactory({ harness, defaultDurability, defaultS
|
||||
* Queues `input` as the session's next turn and returns a view of it.
|
||||
* With `messageId`, a retried call is the same turn: it continues a
|
||||
* turn still running and answers one that ended, without running it
|
||||
* again.
|
||||
* again. Each image is stored once in the session's image store and
|
||||
* the log names it. A later prompt from this session object waits for
|
||||
* an earlier one's images. An input the harness would refuse throws
|
||||
* here and is never stored. A prompt whose signal aborts before it is
|
||||
* stored is never stored either: it ends cancelled.
|
||||
*/
|
||||
prompt(input, promptOptions = {}) {
|
||||
if (closed) throw new Error(`${label} is closed`);
|
||||
const messageId = promptOptions.messageId ?? newId("msg");
|
||||
if (!validId(messageId)) throw new TypeError(`messageId must be ${idRule}`);
|
||||
if (typeof input !== "string" && !Array.isArray(input)) throw new TypeError("prompt input must be a string or an array of content blocks");
|
||||
// The input travels through the queue and the log as JSON.
|
||||
if (Array.isArray(input) && input.some((block) => block?.type === "image" && block.data !== undefined && typeof block.data !== "string")) {
|
||||
throw new TypeError("a durable prompt carries image data as a base64 string");
|
||||
}
|
||||
admit(input);
|
||||
const signal = promptOptions.signal;
|
||||
if (signal !== undefined && typeof signal?.addEventListener !== "function") throw new TypeError("prompt signal must be an AbortSignal");
|
||||
const turn = startTurn("prompt", messageId, input, promptOptions.messageId !== undefined, signal?.aborted === true);
|
||||
const aborted = signal?.aborted === true;
|
||||
const retried = promptOptions.messageId !== undefined;
|
||||
// The input travels through the queue and the log as JSON. Its
|
||||
// images go to the session's image store first and the log names
|
||||
// them; a prompt aborted before the call stores nothing, so it
|
||||
// keeps them as base64.
|
||||
const named = !aborted;
|
||||
const stored = Array.isArray(input) ? promptInputForLog(input, { named }) : { input, images: false };
|
||||
// The copy the log will hold is what is checked: a getter can't
|
||||
// show the check one value and the log another.
|
||||
admit(stored.input);
|
||||
plugged.checkPrompt?.(stored.input);
|
||||
const before = storing;
|
||||
const ready = before || stored.images ? async () => {
|
||||
// The prompt waits for an earlier one and its image writes only
|
||||
// until its signal aborts; what it waited for may still land, but
|
||||
// the prompt is not stored.
|
||||
let release = () => {};
|
||||
const abortion = signal === undefined ? null : new Promise((resolve) => {
|
||||
signal.addEventListener("abort", resolve, { once: true });
|
||||
release = () => signal.removeEventListener("abort", resolve);
|
||||
});
|
||||
const unlessAborted = (promise) => {
|
||||
if (abortion === null) return promise;
|
||||
void promise.catch(() => {});
|
||||
return Promise.race([promise, abortion]);
|
||||
};
|
||||
try {
|
||||
await unlessAborted(before ?? Promise.resolve());
|
||||
if (signal?.aborted) return null;
|
||||
let read = stored.input;
|
||||
if (stored.images) {
|
||||
const { created, id: sessionId } = await resolved;
|
||||
const log = await created.session(sessionId);
|
||||
// A retry of a prompt the log already holds is answered from
|
||||
// the log: its images are named, not written again.
|
||||
const known = retried && knownMessage(foldSessionLog(await log.read()), { type: "prompt", messageId });
|
||||
if (signal?.aborted) return null;
|
||||
read = await unlessAborted(storePromptImages(known ? null : log.images, read));
|
||||
if (signal?.aborted) return null;
|
||||
}
|
||||
// An agent closed meanwhile stores nothing more.
|
||||
if (closed) throw new Error(`${label} is closed`);
|
||||
return read;
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
} : null;
|
||||
const progress = { sent: false };
|
||||
const turn = startTurn("prompt", messageId, stored.input, retried, () => signal?.aborted === true, ready, progress);
|
||||
if (stored.images && !aborted) {
|
||||
// A later prompt waits for this one and every one before it: one
|
||||
// withdrawn while it waited settles before them.
|
||||
const settled = Promise.all([before, turn.accepted.then(() => {}, () => {})]).then(() => {});
|
||||
storing = settled;
|
||||
void settled.then(() => { if (storing === settled) storing = null; });
|
||||
}
|
||||
if (signal !== undefined && !signal.aborted) {
|
||||
// The prompt's own cancel: it stops this turn whether it runs or
|
||||
// still waits, and never a later one.
|
||||
const stop = () => { void send({ type: "cancel", messageId: newId("cnl"), target: messageId }).catch(() => {}); };
|
||||
// still waits, and never a later one. A prompt whose send had not
|
||||
// begun is never stored, so it needs none.
|
||||
const stop = () => {
|
||||
if (progress.sent) void send({ type: "cancel", messageId: newId("cnl"), target: messageId }).catch(() => {});
|
||||
};
|
||||
signal.addEventListener("abort", stop, { once: true });
|
||||
void turn.result.finally(() => signal.removeEventListener("abort", stop)).catch(() => {});
|
||||
}
|
||||
@@ -2629,7 +2911,16 @@ export function createDurableAgentFactory({ harness, defaultDurability, defaultS
|
||||
const messageId = steerOptions.messageId ?? newId("str");
|
||||
if (!validId(messageId)) throw new TypeError(`messageId must be ${idRule}`);
|
||||
admit(input);
|
||||
await send({ type: "steer", messageId, input });
|
||||
// A steer's images go in the session's image store before the steer
|
||||
// that names them, as a prompt's do; storing one again under its
|
||||
// name is harmless, so a retry needs no check.
|
||||
let named = input;
|
||||
if (Array.isArray(input) && input.some((block) => block?.type === "image" && typeof block.data === "string")) {
|
||||
const { created, id: sessionId } = await resolved;
|
||||
named = await storePromptImages((await created.session(sessionId)).images, input);
|
||||
if (closed) throw new Error(`${label} is closed`);
|
||||
}
|
||||
await send({ type: "steer", messageId, input: named });
|
||||
return { messageId };
|
||||
},
|
||||
/** Cancels the running turn; dropping a turn's view does not. */
|
||||
@@ -2648,6 +2939,12 @@ export function createDurableAgentFactory({ harness, defaultDurability, defaultS
|
||||
head: state.head,
|
||||
}),
|
||||
append: async () => { throw new Error("a checkpoint reads the session without changing it"); },
|
||||
// The records name the session's images; the engine reads them
|
||||
// from its image store to export them.
|
||||
[persistenceImages]: {
|
||||
get: (name) => log.images.get(name),
|
||||
put: async () => { throw new Error("a checkpoint reads the session without changing it"); },
|
||||
},
|
||||
};
|
||||
const harness = await agent.openHarness(sessionId, readOnly, null);
|
||||
try {
|
||||
|
||||
+9
-2
@@ -1,7 +1,7 @@
|
||||
// fx as a harness of the durable core in durable.js: `createFxAgent` is that
|
||||
// core running fx engines. Everything the core needs to know about fx is
|
||||
// here; the core itself knows only the harness contract.
|
||||
import { coreAnswered, engineInternals, journalMarksWanted, normalizeInstructions, normalizeModelChoice } from "./fx-sdk.js";
|
||||
import { checkPromptInput, coreAnswered, engineInternals, journalMarksWanted, normalizeInstructions, normalizeModelChoice, resumeOutputShown } from "./fx-sdk.js";
|
||||
import { createCredentialMemory, credentialMemory } from "./subscription.js";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
@@ -28,6 +28,11 @@ export function fxHarness({ createEngine, defaultApiKey = async () => undefined
|
||||
return {
|
||||
...(checkpoint === undefined ? {} : { seed: snapshotOf(checkpointBytesOf(checkpoint)) }),
|
||||
settings: sessionSettings,
|
||||
// What a session's prompt() refuses before storing anything: whatever
|
||||
// this agent's engines would refuse.
|
||||
checkPrompt(input) {
|
||||
checkPromptInput(input, { resizeImage: engineOptions.resizeImage });
|
||||
},
|
||||
async open({ sessionId, store, context, settings, durability, ready }) {
|
||||
const apiKey = engineOptions.apiKey ?? await defaultApiKey(durability);
|
||||
const engine = await createEngine({
|
||||
@@ -147,7 +152,9 @@ function fxSession(engine, idempotent) {
|
||||
// A call left running reruns only when running it twice is safe, and
|
||||
// with `rerun: false` not even then. Any other call is never run again:
|
||||
// the model is told it may have partly run, and the turn goes on.
|
||||
resume: ({ rerun = true, ...options } = {}) => fxTurn(engine.resume({ ...options, onAmbiguous: (call) => (rerun && idempotent.has(call.name) ? "rerun" : undefined) }), internals.exited),
|
||||
// The session's stream already holds what the turn sent before it
|
||||
// stopped, so the resume does not send it again.
|
||||
resume: ({ rerun = true, ...options } = {}) => fxTurn(engine.resume({ ...options, [resumeOutputShown]: true, onAmbiguous: (call) => (rerun && idempotent.has(call.name) ? "rerun" : undefined) }), internals.exited),
|
||||
get openTurn() {
|
||||
return internals.openTurn;
|
||||
},
|
||||
|
||||
+232
-34
@@ -37,7 +37,10 @@ const maxUnreadEvents = 256;
|
||||
// Prompt images travel as raw bytes beside the ACP frame and are base64
|
||||
// encoded only in the model request. An image may use 5 MiB of encoded
|
||||
// request data, and a prompt's images 8 MiB, so the raw limits are 3/4 of that.
|
||||
// The kernel still validates content and media type.
|
||||
// The kernel still validates content and media type, and holds a prompt to
|
||||
// these limits too: `max_libfx_prompt_images` and
|
||||
// `max_libfx_prompt_images_bytes` in src/core/images/image_attachments.zig
|
||||
// must match them.
|
||||
const maxPromptImages = 8;
|
||||
const maxPromptImageDataBytes = 5 * 1024 * 1024;
|
||||
const maxPromptImagesDataBytes = 8 * 1024 * 1024;
|
||||
@@ -45,6 +48,7 @@ const maxPromptImageBytes = (maxPromptImageDataBytes / 4) * 3;
|
||||
const maxPromptImagesBytes = (maxPromptImagesDataBytes / 4) * 3;
|
||||
// Matches the native attachment table: one prompt's images or one checkpoint.
|
||||
const maxPendingAttachments = 8;
|
||||
const maxPendingAttachmentBytes = 8 * 1024 * 1024;
|
||||
const maxOutboundAttachments = 4;
|
||||
// Matches the core's kernel checkpoint limit (max_checkpoint_bytes).
|
||||
const maxCheckpointBytes = 4 * 1024 * 1024;
|
||||
@@ -234,6 +238,14 @@ export function normalizeAgentOptions(value) {
|
||||
// public surface.
|
||||
export const engineInternals = Symbol.for("libfx.engineInternals");
|
||||
export const journalMarksWanted = Symbol.for("libfx.journalMarks");
|
||||
// A persistence's image store, `{ put(name, bytes), get(name) }`, which keeps
|
||||
// each image once, named by the SHA-256 of its bytes, so records and
|
||||
// checkpoints only name it. Durable sessions give one to the engines they
|
||||
// run; without it, images stay inline.
|
||||
export const persistenceImages = Symbol.for("libfx.persistenceImages");
|
||||
// `{ images, bytes }`: a smaller image window than the core's, the newest
|
||||
// images a session with an image store keeps, so tests can reach its edge.
|
||||
const imageWindowLimits = Symbol.for("libfx.imageWindow");
|
||||
// Marks an error the core answered a request with, as opposed to one from a
|
||||
// core that stopped answering.
|
||||
export const coreAnswered = Symbol.for("libfx.coreAnswered");
|
||||
@@ -279,8 +291,26 @@ const journalOpenErrorCodes = new Map([
|
||||
["libfx journal events are out of order", "FX_JOURNAL_INVALID"],
|
||||
["libfx journal is too large", "FX_JOURNAL_TOO_LARGE"],
|
||||
["libfx journal holds more than 1024 turns", "FX_JOURNAL_TOO_LARGE"],
|
||||
["libfx journal images are too large", "FX_JOURNAL_TOO_LARGE"],
|
||||
["Invalid libfx journal images", "FX_JOURNAL_INVALID"],
|
||||
["libfx image does not match its name", "FX_JOURNAL_IMAGE_MISSING"],
|
||||
]);
|
||||
|
||||
// An image's name in a persistence's image store: the lowercase hex SHA-256
|
||||
// of its bytes, as the core names it.
|
||||
async function imageName(bytes) {
|
||||
const digest = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", bytes));
|
||||
let name = "";
|
||||
for (const byte of digest) name += byte.toString(16).padStart(2, "0");
|
||||
return name;
|
||||
}
|
||||
|
||||
function imageMissingError(name, cause) {
|
||||
const error = journalLoadError(`libfx journal names an image the store does not hold: ${name}`, "FX_JOURNAL_IMAGE_MISSING", cause);
|
||||
error.image = name;
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* A persistence store that keeps a session's records and its latest
|
||||
* checkpoint in memory, for tests and for hosts that copy them elsewhere.
|
||||
@@ -327,6 +357,11 @@ const resumeTurn = Symbol("libfx.resume");
|
||||
const followUpInput = Symbol("libfx.followUp");
|
||||
// Carries the ids of the calls a resumed turn runs again.
|
||||
const rerunCallIds = Symbol("libfx.rerun");
|
||||
// Marks a resume whose host already holds what the turn sent before it
|
||||
// stopped, as a durable session's stream does, so the turn does not send it
|
||||
// again. Any other resume runs in a host that saw none of it. Never a
|
||||
// caller-visible option; `fx-harness.js` sets it.
|
||||
export const resumeOutputShown = Symbol.for("libfx.resumeOutputShown");
|
||||
|
||||
function journalAppendError(cause) {
|
||||
const error = new Error("libfx journal append failed", { cause });
|
||||
@@ -470,6 +505,10 @@ async function loadPersistence(persistence) {
|
||||
return { checkpoint, events, head, tailBytes: bytes, lastSeq: events.at(-1)?.seq ?? covered };
|
||||
}
|
||||
|
||||
// Events someone waits on, so the record holding one is a barrier: a steer
|
||||
// or withdrawal its caller awaits, and the end or yield a turn's result does.
|
||||
const waitedForEvents = new Set(["input_accepted", "input_withdrawn", "turn_committed", "turn_progress_cleared", "turn_yielded"]);
|
||||
|
||||
// What a record's events say about turns, in order, for a store that tracks
|
||||
// them without reading records: a turn started, ended, or yielded, an input
|
||||
// was accepted or handed back unplaced, or the step the open turn is at, as
|
||||
@@ -1977,6 +2016,62 @@ function normalizePromptInput(input, { deferImageLimits = false } = {}) {
|
||||
return prompt;
|
||||
}
|
||||
|
||||
// The image types libfx's core takes, each known by how its bytes start.
|
||||
const imageSignatures = [
|
||||
["image/png", 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]],
|
||||
["image/jpeg", 0, [0xff, 0xd8, 0xff]],
|
||||
["image/gif", 0, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]],
|
||||
["image/gif", 0, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]],
|
||||
["image/webp", 0, [0x52, 0x49, 0x46, 0x46]],
|
||||
];
|
||||
const imageTypes = new Set(imageSignatures.map(([type]) => type));
|
||||
const webpMarker = [0x57, 0x45, 0x42, 0x50];
|
||||
|
||||
// The image type `bytes` start as, or null.
|
||||
function imageTypeOf(bytes) {
|
||||
const startsWith = (offset, signature) => bytes.length >= offset + signature.length && signature.every((byte, index) => bytes[offset + index] === byte);
|
||||
for (const [type, offset, signature] of imageSignatures) {
|
||||
if (!startsWith(offset, signature)) continue;
|
||||
if (type === "image/webp" && !startsWith(8, webpMarker)) continue;
|
||||
return type;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Refuses image bytes that are not the type they declare, as libfx's core does.
|
||||
function checkImageBytesType(bytes, mimeType, index) {
|
||||
const type = imageTypeOf(bytes);
|
||||
if (type === null) throw new TypeError(`image prompt block ${index} is not a PNG, JPEG, GIF or WebP image`);
|
||||
if (type !== mimeType) throw new TypeError(`image prompt block ${index} holds ${type} data, not ${mimeType}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a prompt's input as libfx takes it and returns it normalized:
|
||||
* every check `prompt()` makes, and the ones libfx's core makes on images,
|
||||
* which would otherwise refuse the prompt only once it reached the core: a
|
||||
* supported image type, and bytes or base64 that are that type. A Blob is not
|
||||
* read, so its bytes are checked once read (`checkImageBytesType`). With
|
||||
* `resizeImage`, image bytes are checked after it runs instead. Throws a
|
||||
* TypeError or RangeError.
|
||||
*/
|
||||
export function checkPromptInput(input, { resizeImage } = {}) {
|
||||
const resized = resizeImage !== undefined;
|
||||
const normalized = normalizePromptInput(input, { deferImageLimits: resized });
|
||||
// The core takes no prompt without text or an image; a resource adds neither.
|
||||
if (!normalized.some((block) => block.type === "image" || (block.type === "text" && block.text.length > 0))) {
|
||||
throw new TypeError("prompt is empty");
|
||||
}
|
||||
// resizeImage decides the final image sizes, so they are counted after it runs.
|
||||
checkPromptFrameSize(normalized, !resized);
|
||||
normalized.forEach((block, index) => {
|
||||
if (block.type !== "image" || (resized && block.data !== undefined)) return;
|
||||
if (!imageTypes.has(block.mimeType)) throw new TypeError(`image prompt block ${index} mimeType must be image/png, image/jpeg, image/gif or image/webp`);
|
||||
if (block.source === "bytes") checkImageBytesType(block.data, block.mimeType, index);
|
||||
else if (block.source === "base64") checkImageBytesType(base64ToBytes(block.data.slice(0, 16)), block.mimeType, index);
|
||||
});
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// Image bytes travel beside the frame as attachment references, but the model
|
||||
// request carries them base64 encoded and the native host caps that request at
|
||||
// 8 MiB. Images therefore count at their encoded size, the same budget they
|
||||
@@ -2062,6 +2157,11 @@ async function materializePromptImages(blocks, isCancelled, resizeImage) {
|
||||
}
|
||||
if (promptFrameSize(prepared) > maxPromptFrameBytes) prepared = omitReferencedImageData(prepared);
|
||||
checkPromptFrameSize(prepared, true);
|
||||
// A file's bytes, or what resizeImage made, are checked once there are
|
||||
// some, after the size limits.
|
||||
prepared.forEach((image, index) => {
|
||||
if (image.type === "image" && image.bytes !== undefined) checkImageBytesType(image.bytes, image.mimeType, index);
|
||||
});
|
||||
return prepared;
|
||||
}
|
||||
|
||||
@@ -2192,7 +2292,10 @@ export function normalizeInstructions(value) {
|
||||
return instructions;
|
||||
}
|
||||
|
||||
function hostToolContent(value) {
|
||||
// `stored` maps an image's base64 data to its name in the image store, for
|
||||
// the images stored before the result goes to the core: their blocks carry
|
||||
// the name, so the core names them too.
|
||||
function hostToolContent(value, stored = null) {
|
||||
if (value?.type === "libfx.tool-result") {
|
||||
if (typeof value.text !== "string" || !Array.isArray(value.images) || value.images.length > 8) {
|
||||
throw new TypeError("invalid typed tool result");
|
||||
@@ -2204,11 +2307,13 @@ function hostToolContent(value) {
|
||||
if ((!referenceOnly && typeof image.data !== "string") || typeof image.mimeType !== "string" || image.mimeType.length === 0 || image.mimeType.length > 128 || (image.data?.length > maxPromptImageDataBytes && sourceRef === undefined)) {
|
||||
throw new TypeError("invalid tool image");
|
||||
}
|
||||
const data = !referenceOnly && image.data.length <= maxPromptImageDataBytes ? image.data : undefined;
|
||||
return {
|
||||
type: "image",
|
||||
...(!referenceOnly && image.data.length <= maxPromptImageDataBytes ? { data: image.data } : {}),
|
||||
...(data === undefined ? {} : { data }),
|
||||
mimeType: image.mimeType,
|
||||
...(sourceRef === undefined ? {} : { sourceRef }),
|
||||
...(data !== undefined && stored?.has(data) ? { stored: stored.get(data) } : {}),
|
||||
};
|
||||
});
|
||||
if (promptImageDataBytes(images) > maxPromptImagesDataBytes) images = omitReferencedImageData(images);
|
||||
@@ -2219,7 +2324,7 @@ function hostToolContent(value) {
|
||||
content = JSON.stringify({ text: value.text, images });
|
||||
}
|
||||
if (encoder.encode(content).length > maxPromptImagesDataBytes) throw new RangeError("typed tool result exceeds the result limit");
|
||||
return { content, rich: true, isError: value.isError === true };
|
||||
return { content, rich: true, isError: value.isError === true, images };
|
||||
}
|
||||
if (typeof value === "string") return { content: value, rich: false };
|
||||
if (value === undefined) return { content: "null", rich: false };
|
||||
@@ -2308,6 +2413,11 @@ export async function createFxEngine(options = {}) {
|
||||
// waits for it: the core before an action others can see, such as a tool
|
||||
// call with effects, or a caller for its input.
|
||||
const persistence = options.persistence ?? null;
|
||||
// A persistence with an image store keeps each prompt image once, by
|
||||
// name, and the core names it in records and checkpoints instead of
|
||||
// holding its bytes.
|
||||
const imageStorage = persistence?.[persistenceImages] ?? null;
|
||||
const imageStore = imageStorage !== null;
|
||||
const wantsMarks = persistence?.marks === true || persistence?.[journalMarksWanted] === true;
|
||||
const checkpointAfterBytes = options.checkpointAfterBytes ?? defaultCheckpointAfterBytes;
|
||||
// The store's last cursor, and the last seq its records hold.
|
||||
@@ -2444,8 +2554,9 @@ export async function createFxEngine(options = {}) {
|
||||
return;
|
||||
}
|
||||
journalQueue.push(...events);
|
||||
// A caller waits for its steer or withdrawal to be stored.
|
||||
if (barrier || events.some((event) => event?.type === "input_accepted" || event?.type === "input_withdrawn")) journalBarrier = true;
|
||||
// A caller waits for its steer or withdrawal to be stored, and a turn's
|
||||
// result for the turn's end or yield.
|
||||
if (barrier || events.some((event) => waitedForEvents.has(event?.type))) journalBarrier = true;
|
||||
// A turn notes a new config right before its first progress, a barrier:
|
||||
// the two go out in one write instead of two in a row.
|
||||
if (events.every((event) => event?.type === "session_config")) return;
|
||||
@@ -2615,6 +2726,30 @@ export async function createFxEngine(options = {}) {
|
||||
}
|
||||
throw new Error("transport retry exhausted");
|
||||
};
|
||||
// A typed tool result's images go in the image store by name before the
|
||||
// result reaches the core, as a prompt's do, so the session's records name
|
||||
// them. The tool has already run, so an image the store refuses is not
|
||||
// lost: it stays in the result, and the records hold it inline.
|
||||
const storedToolContent = async (value) => {
|
||||
const normalized = hostToolContent(value);
|
||||
if (!imageStore || !normalized.images?.some((image) => image.data !== undefined)) return normalized;
|
||||
const stored = new Map();
|
||||
for (const image of normalized.images) {
|
||||
if (image.data === undefined || stored.has(image.data)) continue;
|
||||
let bytes;
|
||||
// Data that is not base64 is the core's to refuse.
|
||||
try { bytes = base64ToBytes(image.data); } catch { continue; }
|
||||
const name = await imageName(bytes);
|
||||
try {
|
||||
await imageStorage.put(name, bytes);
|
||||
stored.set(image.data, name);
|
||||
} catch {
|
||||
// Not stored: the result keeps the image, and the records hold it
|
||||
// inline.
|
||||
}
|
||||
}
|
||||
return stored.size ? hostToolContent(value, stored) : normalized;
|
||||
};
|
||||
const executeHostTool = async (name, input, requestedSessionId, toolCallId) => {
|
||||
const execute = hostTools.executors.get(name);
|
||||
const issuerSessionId = requestedSessionId ?? sessionId;
|
||||
@@ -2648,7 +2783,7 @@ export async function createFxEngine(options = {}) {
|
||||
});
|
||||
const value = await Promise.race([execution, aborted]);
|
||||
if (!controller.signal.aborted) {
|
||||
const normalized = hostToolContent(value);
|
||||
const normalized = await storedToolContent(value);
|
||||
content = normalized.content;
|
||||
rich = normalized.rich;
|
||||
isError = normalized.isError === true;
|
||||
@@ -2657,7 +2792,7 @@ export async function createFxEngine(options = {}) {
|
||||
isError = true;
|
||||
if (error?.toolResult?.type === "libfx.tool-result") {
|
||||
try {
|
||||
const normalized = hostToolContent(error.toolResult);
|
||||
const normalized = await storedToolContent(error.toolResult);
|
||||
content = normalized.content;
|
||||
rich = normalized.rich;
|
||||
} catch {
|
||||
@@ -2736,12 +2871,31 @@ export async function createFxEngine(options = {}) {
|
||||
if (!(bytes instanceof Uint8Array) || bytes.byteLength === 0) throw new Error("fx returned an invalid checkpoint");
|
||||
return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
}
|
||||
const sendPrompt = (blocks, resuming = false, followUp = null, turnId = null, rerun = [], yieldAt = undefined) => {
|
||||
// Stores each prompt image in the persistence's image store, by name,
|
||||
// before the prompt that carries it is sent: nothing the core writes may
|
||||
// name an image the store does not hold. Returns the blocks with their
|
||||
// names, or null when the prompt stopped meanwhile.
|
||||
async function storePromptImages(blocks, stopped) {
|
||||
const named = [];
|
||||
for (const block of blocks) {
|
||||
if (block.type !== "image" || block.bytes === undefined) {
|
||||
named.push(block);
|
||||
continue;
|
||||
}
|
||||
const name = await imageName(block.bytes);
|
||||
if (stopped()) return null;
|
||||
await imageStorage.put(name, block.bytes);
|
||||
if (stopped()) return null;
|
||||
named.push({ ...block, stored: name });
|
||||
}
|
||||
return named;
|
||||
}
|
||||
const sendPrompt = (blocks, resuming = false, followUp = null, turnId = null, rerun = [], yieldAt = undefined, outputShown = false) => {
|
||||
// A journaled turn given a time to stop at yields before its first model
|
||||
// request after it, keeping the turn open.
|
||||
const stopAt = journal && yieldAt !== undefined ? { yieldAt } : {};
|
||||
if (resuming) {
|
||||
return request("session/prompt", { sessionId, prompt: [], _meta: { fx: { continueRecovery: true, ...(rerun.length ? { rerun } : {}), ...stopAt } } });
|
||||
return request("session/prompt", { sessionId, prompt: [], _meta: { fx: { continueRecovery: true, ...(outputShown ? {} : { freshOutput: true }), ...(rerun.length ? { rerun } : {}), ...stopAt } } });
|
||||
}
|
||||
const images = blocks.filter((block) => block.type === "image" && block.bytes !== undefined);
|
||||
const ids = images.length ? attachBytes(images.map((block) => block.bytes)) : [];
|
||||
@@ -2750,7 +2904,8 @@ export async function createFxEngine(options = {}) {
|
||||
type: "image",
|
||||
mimeType: block.mimeType,
|
||||
...(block.sourceRef === undefined ? {} : { sourceRef: block.sourceRef }),
|
||||
...(block.bytes === undefined ? {} : { _meta: { fx: { attachment: ids[next++] } } }),
|
||||
// An image the store holds carries its name, so the core names it too.
|
||||
...(block.bytes === undefined ? {} : { _meta: { fx: { attachment: ids[next++], ...(block.stored === undefined ? {} : { stored: block.stored }) } } }),
|
||||
});
|
||||
// A follow-up's turn places it; the core records it as accepted first
|
||||
// when the host could not. A journaled turn's first progress records its id.
|
||||
@@ -2868,24 +3023,62 @@ export async function createFxEngine(options = {}) {
|
||||
}
|
||||
if (journal) {
|
||||
const { events, checkpoint } = loaded;
|
||||
const params = { sessionId, ...(options.inputsDurable === true ? { inputsDurable: true } : {}) };
|
||||
// One call: attaching again would drop the first attachment.
|
||||
const attachments = [];
|
||||
if (events.length > 0) {
|
||||
const bytes = encoder.encode(JSON.stringify(withoutSupersededProgress(events)));
|
||||
if (bytes.byteLength > maxJournalBytes) throw journalLoadError("libfx journal is too large", "FX_JOURNAL_TOO_LARGE");
|
||||
attachments.push(["journalAttachment", bytes]);
|
||||
}
|
||||
if (checkpoint) attachments.push(["snapshotAttachment", checkpoint]);
|
||||
const ids = attachments.length ? attachBytes(attachments.map(([, bytes]) => bytes)) : [];
|
||||
attachments.forEach(([name], index) => { params[name] = ids[index]; });
|
||||
const opened = await request("libfx/journal_open", params).catch((error) => {
|
||||
const journalBytes = events.length > 0 ? encoder.encode(JSON.stringify(withoutSupersededProgress(events))) : null;
|
||||
if (journalBytes && journalBytes.byteLength > maxJournalBytes) throw journalLoadError("libfx journal is too large", "FX_JOURNAL_TOO_LARGE");
|
||||
const openFailed = (error) => {
|
||||
const message = error?.message ?? "";
|
||||
if (/newer fx/.test(message)) throw new FxJournalVersionError(message);
|
||||
const code = journalOpenErrorCodes.get(message);
|
||||
if (code) throw journalLoadError(message, code, error);
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
const openOnce = () => {
|
||||
const params = {
|
||||
sessionId,
|
||||
...(options.inputsDurable === true ? { inputsDurable: true } : {}),
|
||||
...(imageStore ? { imageStore: true } : {}),
|
||||
...(imageStore && options[imageWindowLimits] ? { imageWindow: options[imageWindowLimits] } : {}),
|
||||
};
|
||||
// One call: attaching again would drop the first attachment.
|
||||
const attachments = [];
|
||||
if (journalBytes) attachments.push(["journalAttachment", journalBytes]);
|
||||
if (checkpoint) attachments.push(["snapshotAttachment", checkpoint]);
|
||||
const ids = attachments.length ? attachBytes(attachments.map(([, bytes]) => bytes)) : [];
|
||||
attachments.forEach(([name], index) => { params[name] = ids[index]; });
|
||||
return request("libfx/journal_open", params).catch(openFailed);
|
||||
};
|
||||
// Stages the images a history names, read from the store, in batches
|
||||
// the attachment table holds. A store that lacks one fails the open:
|
||||
// the history without it is not the one saved.
|
||||
const stageImages = async (names) => {
|
||||
let batch = [];
|
||||
let batchBytes = 0;
|
||||
const flush = async () => {
|
||||
if (batch.length === 0) return;
|
||||
const ids = attachBytes(batch.map(({ bytes }) => bytes));
|
||||
const images = batch.map(({ name }, index) => ({ name, attachment: ids[index] }));
|
||||
batch = [];
|
||||
batchBytes = 0;
|
||||
await request("libfx/journal_images", { sessionId, images }).catch(openFailed);
|
||||
};
|
||||
for (const name of new Set(names.map(String))) {
|
||||
const stored = imageStore ? await imageStorage.get(name) : undefined;
|
||||
if (stored === undefined || stored === null) throw imageMissingError(name);
|
||||
const bytes = persistenceBytes(stored, "an image the image store returns");
|
||||
if (batch.length === maxPendingAttachments || batchBytes + bytes.byteLength > maxPendingAttachmentBytes) await flush();
|
||||
batch.push({ name, bytes });
|
||||
batchBytes += bytes.byteLength;
|
||||
}
|
||||
await flush();
|
||||
};
|
||||
// A history that names images opens once they are staged from the
|
||||
// store: the core answers with the names it needs, all at once.
|
||||
let opened = await openOnce();
|
||||
if (Array.isArray(opened?.imagesNeeded)) {
|
||||
await stageImages(opened.imagesNeeded);
|
||||
opened = await openOnce();
|
||||
if (Array.isArray(opened?.imagesNeeded)) throw imageMissingError(String(opened.imagesNeeded[0]));
|
||||
}
|
||||
compareCheckpointMeta(opened?.checkpointMeta);
|
||||
resumable = opened?.resumable === true;
|
||||
openTurnId = resumable && validTurnId(opened?.openTurnId) ? opened.openTurnId : null;
|
||||
@@ -3201,13 +3394,12 @@ export async function createFxEngine(options = {}) {
|
||||
const resizeImage = options.resizeImage;
|
||||
const resuming = promptOptions[resumeTurn] === true;
|
||||
const followUp = promptOptions[followUpInput] ?? null;
|
||||
const normalized = resuming ? [] : normalizePromptInput(input, { deferImageLimits: resizeImage !== undefined });
|
||||
const normalized = resuming ? [] : checkPromptInput(input, { resizeImage });
|
||||
const hasPixels = normalized.some((block) => block.type === "image" && block.data !== undefined);
|
||||
// Blob reads and resizeImage run before the prompt frame is sent.
|
||||
const asyncImages = hasPixels && (resizeImage !== undefined ||
|
||||
// Blob reads, resizeImage and storing images run before the prompt
|
||||
// frame is sent.
|
||||
const asyncImages = hasPixels && (resizeImage !== undefined || imageStore ||
|
||||
normalized.some((block) => block.type === "image" && block.source === "blob"));
|
||||
// resizeImage decides the final image sizes, so they are counted after it runs.
|
||||
checkPromptFrameSize(normalized, resizeImage === undefined);
|
||||
// Snapshot caller-owned bytes, which could change before an async send.
|
||||
const prompt = asyncImages
|
||||
? normalized.map((block) => block.type === "image" && block.source === "bytes" ? { ...block, data: block.data.slice() } : block)
|
||||
@@ -3412,8 +3604,11 @@ export async function createFxEngine(options = {}) {
|
||||
}) : null;
|
||||
let response;
|
||||
if (asyncImages) {
|
||||
const stopped = () => cancelled || closing;
|
||||
response = Promise.race([
|
||||
Promise.resolve().then(() => materializePromptImages(prompt, () => cancelled || closing, resizeImage)),
|
||||
Promise.resolve()
|
||||
.then(() => materializePromptImages(prompt, stopped, resizeImage))
|
||||
.then((prepared) => prepared && imageStore ? storePromptImages(prepared, stopped) : prepared),
|
||||
imagePrepCancelled,
|
||||
]).then((prepared) => {
|
||||
cancelImagePrep = null;
|
||||
@@ -3427,12 +3622,15 @@ export async function createFxEngine(options = {}) {
|
||||
return sendPrompt(prepared, false, followUp, turn.id, [], yieldAt);
|
||||
});
|
||||
} else {
|
||||
try { response = sendPrompt(prompt, resuming, followUp, turn.id, promptOptions[rerunCallIds] ?? [], yieldAt); } catch (error) { response = Promise.reject(error); }
|
||||
try { response = sendPrompt(prompt, resuming, followUp, turn.id, promptOptions[rerunCallIds] ?? [], yieldAt, promptOptions[resumeOutputShown] === true); } catch (error) { response = Promise.reject(error); }
|
||||
}
|
||||
// Appends after a turn's first progress are lazy, so the result does not
|
||||
// wait for them; `close()` does. A failure already seen fails the turn.
|
||||
// Appends after a turn's first progress are lazy, so the turn runs ahead
|
||||
// of them, but its result waits until every record it wrote is stored:
|
||||
// a turn counts as done only once its end is durable, and a failure
|
||||
// among those records fails the turn.
|
||||
turn.result = response
|
||||
.then((value) => {
|
||||
.then(async (value) => {
|
||||
if (journal) await journalSettled().catch(() => {});
|
||||
if (journalFailure) throw reportJournalFailure();
|
||||
if (value.stopReason === "yielded" && !cancelled) {
|
||||
// The core keeps the turn open: `resume()` continues it here, or
|
||||
|
||||
@@ -608,9 +608,9 @@ for (const resizeImage of [undefined, (image) => image]) {
|
||||
}
|
||||
|
||||
// The SDK decodes base64 itself, so non-canonical base64 throws before the
|
||||
// turn starts. Kernel-side content validation stays authoritative: a sniffed
|
||||
// media type that contradicts the declaration fails the turn with a typed error
|
||||
// rather than reaching the model.
|
||||
// turn starts, and so does an image whose bytes aren't the type it declares,
|
||||
// or a type libfx doesn't take, as the kernel would refuse it. A Blob's bytes
|
||||
// are checked once read, which rejects the turn before the prompt is sent.
|
||||
{
|
||||
const gateway = mockGateway();
|
||||
const agent = await createAgent(gateway, { model: "sdk/vision-model" });
|
||||
@@ -621,17 +621,27 @@ for (const resizeImage of [undefined, (image) => image]) {
|
||||
);
|
||||
}
|
||||
const jpegBytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3, 4]).toString("base64");
|
||||
const mismatch = agent.prompt([{ type: "image", data: jpegBytes, mimeType: "image/png" }]);
|
||||
await assert.rejects(mismatch.result, /Invalid image prompt block/);
|
||||
const mislabeled = (error) => error instanceof TypeError && /holds image\/jpeg data, not image\/png/.test(error.message);
|
||||
assert.throws(() => agent.prompt([{ type: "image", data: jpegBytes, mimeType: "image/png" }]), mislabeled);
|
||||
assert.throws(() => agent.prompt([{ type: "image", data: Buffer.from(jpegBytes, "base64"), mimeType: "image/png" }]), mislabeled);
|
||||
assert.throws(
|
||||
() => agent.prompt([{ type: "image", data: Buffer.from("not an image"), mimeType: "image/png" }]),
|
||||
(error) => error instanceof TypeError && /is not a PNG, JPEG, GIF or WebP image/.test(error.message),
|
||||
);
|
||||
const blobMismatch = agent.prompt([
|
||||
{ type: "image", data: new Blob([Buffer.from(jpegBytes, "base64")], { type: "image/png" }) },
|
||||
]);
|
||||
await assert.rejects(blobMismatch.result, /Invalid image prompt block/);
|
||||
await assert.rejects(blobMismatch.result, mislabeled);
|
||||
for (const mimeType of ["image/svg+xml", "text/plain", "image/png\n"]) {
|
||||
const referenceOnly = agent.prompt([{ type: "image", mimeType, sourceRef: "host:unsupported-mime" }]);
|
||||
await assert.rejects(referenceOnly.result, /Invalid image prompt block/);
|
||||
assert.throws(
|
||||
() => agent.prompt([{ type: "image", mimeType, sourceRef: "host:unsupported-mime" }]),
|
||||
(error) => error instanceof TypeError && /mimeType must be image\/png, image\/jpeg, image\/gif or image\/webp/.test(error.message),
|
||||
);
|
||||
}
|
||||
assert.throws(() => agent.prompt(""), (error) => error instanceof TypeError && /prompt is empty/.test(error.message));
|
||||
assert.equal(gateway.state.chatBodies.length, 0);
|
||||
// The agent still takes a valid prompt.
|
||||
assert.equal((await runPrompt(agent, [{ type: "image", data: pngData, mimeType: "image/png" }])).stopReason, "end_turn");
|
||||
await agent.close();
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//
|
||||
// node sdk/tests/test-durable-core.mjs [memory|local|world|queue]
|
||||
import { strict as assert } from "node:assert";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createDurableAgentFactory, foldSessionLog, memory, wakeHandler } from "../durable.js";
|
||||
@@ -964,6 +964,48 @@ if (durabilityKind === "queue") {
|
||||
await agent.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("an image prompt run in the request reads its image from the store, and no queued copy holds it", async () => {
|
||||
const internals = (agent) => agent[Symbol.for("libfx.durableInternals")];
|
||||
for (const maxDurationMs of [60_000, 300]) {
|
||||
const dir = await mkdtemp(join(tmpdir(), "libfx-in-request-image-"));
|
||||
dirs.push(dir);
|
||||
const waited = [];
|
||||
const { durability, broker } = await queueWorldAt(dir, { queueLeases: true, runInRequest: true, maxDurationMs, reserveMs: 100, waitUntil: (work) => waited.push(work) });
|
||||
const { agent, seen } = recordingAgent(durability);
|
||||
const session = agent.session();
|
||||
// With the short deadline, a step outlasts the request.
|
||||
if (maxDurationMs < 1000) {
|
||||
const slow = holdStep("image-1");
|
||||
void slow.holding.then(() => setTimeout(slow.release, 600));
|
||||
}
|
||||
const messageId = `turn-image-${maxDurationMs}`;
|
||||
const turn = session.prompt([{ type: "text", text: "look" }, image], { messageId });
|
||||
assert.equal((await turn.result).stopReason, "end_turn");
|
||||
assert.equal(waited.length, 1, "the request ran the turn");
|
||||
assert.deepEqual(seen, [[{ type: "text", text: "look" }, image]], "with its image, read once");
|
||||
const sent = broker.sent.filter((message) => message.payload?.input?.messageId === messageId);
|
||||
assert.equal(sent[0].attempt, 0, "the backstop waited");
|
||||
if (maxDurationMs < 1000) assert.ok(sent.length > 1, "a turn longer than the request went on in a fresh message");
|
||||
for (const message of sent) {
|
||||
const payload = JSON.stringify(message.payload);
|
||||
assert.ok(!payload.includes(png), "a queued copy holds none of the image");
|
||||
}
|
||||
await waited[0];
|
||||
for (let tries = 0; internals(agent).liveWorkers() > 0; tries += 1) {
|
||||
assert.ok(tries < 200, "the request's worker never let go");
|
||||
await sleep(10);
|
||||
}
|
||||
// The backstop, delivered at last, runs nothing.
|
||||
broker.lapse(sent[0]);
|
||||
for (let tries = 0; broker.held(() => true).some((message) => message.id === sent[0].id); tries += 1) {
|
||||
assert.ok(tries < 200, "the backstop was never acknowledged");
|
||||
await sleep(10);
|
||||
}
|
||||
assert.equal(seen.length, 1, "the backstop ran nothing");
|
||||
await agent.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("a reconnect reads the stream from its cursor's line, not its start", async () => {
|
||||
@@ -1052,6 +1094,421 @@ test("each prompt's turn runs with its session object's settings, resumed or not
|
||||
await agent.close();
|
||||
});
|
||||
|
||||
// A real 1x1 PNG, as a caller would send it.
|
||||
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jP0cAAAAASUVORK5CYII=";
|
||||
const pngBytes = Uint8Array.from(atob(png), (char) => char.charCodeAt(0));
|
||||
const image = { type: "image", data: png, mimeType: "image/png" };
|
||||
// An image block of its own: the PNG with byte `n` after it, so its name is
|
||||
// new and storing it writes it.
|
||||
const imageOf = (n) => ({ type: "image", data: Buffer.from([...pngBytes, n]).toString("base64"), mimeType: "image/png" });
|
||||
// An agent whose harness records each prompt input it is handed, and runs an
|
||||
// image prompt as the word "image"; with `refuseFirst`, its first prompt
|
||||
// throws, as a harness that cannot run a turn does.
|
||||
function recordingAgent(durability, { refuseFirst = false } = {}) {
|
||||
const seen = [];
|
||||
let refusals = refuseFirst ? 1 : 0;
|
||||
const agent = createDurableAgentFactory({
|
||||
harness: () => {
|
||||
const base = scriptedHarness()();
|
||||
return {
|
||||
open: async (options) => {
|
||||
const session = await base.open(options);
|
||||
return {
|
||||
...session,
|
||||
prompt: (input, opts) => {
|
||||
seen.push(input);
|
||||
if (refusals > 0) {
|
||||
refusals -= 1;
|
||||
throw new Error("the harness stopped");
|
||||
}
|
||||
return session.prompt(typeof input === "string" ? input : "image", opts);
|
||||
},
|
||||
get openTurn() { return session.openTurn; },
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
defaultDurability: async () => memory(),
|
||||
})({ durability });
|
||||
return { agent, seen };
|
||||
}
|
||||
const kinds = (seen) => seen.map((input) => (typeof input === "string" ? input : "image"));
|
||||
|
||||
test("a durable prompt carries image data as base64: bytes and files throw from prompt(), and nothing is queued", async () => {
|
||||
const { agent, seen } = recordingAgent(await durabilityFor());
|
||||
const session = agent.session();
|
||||
const refused = [
|
||||
{ type: "image", data: 42, mimeType: "image/png" },
|
||||
{ type: "image", data: new Uint8Array(pngBytes), mimeType: "image/png" },
|
||||
{ type: "image", data: new Uint8Array(pngBytes).buffer, mimeType: "image/png" },
|
||||
{ type: "image", data: new Blob([pngBytes], { type: "image/png" }) },
|
||||
{ type: "image", data: new File([pngBytes], "shot.png", { type: "image/png" }) },
|
||||
];
|
||||
for (const block of refused) {
|
||||
assert.throws(
|
||||
() => session.prompt([{ type: "text", text: "look" }, block]),
|
||||
(error) => error instanceof TypeError && /^a durable prompt carries image data as a base64 string$/.test(error.message),
|
||||
);
|
||||
}
|
||||
const turn = session.prompt([{ type: "text", text: "look" }, image, { ...image, sourceRef: "uploads:shot" }]);
|
||||
assert.equal((await turn.result).stopReason, "end_turn");
|
||||
assert.deepEqual(seen, [[{ type: "text", text: "look" }, image, { ...image, sourceRef: "uploads:shot" }]], "base64 passes as given");
|
||||
await agent.close();
|
||||
});
|
||||
|
||||
test("a prompt storing its image is stored before the prompts made after it, and a failed image write stores nothing", async () => {
|
||||
const fault = { writeMs: 150 };
|
||||
const { agent, seen } = recordingAgent(faultyImages(await durabilityFor(), fault));
|
||||
const session = agent.session();
|
||||
const first = session.prompt([imageOf(1)]);
|
||||
const second = session.prompt("second");
|
||||
assert.deepEqual([(await first.result).stopReason, (await second.result).stopReason], ["end_turn", "end_turn"]);
|
||||
assert.deepEqual(kinds(seen), ["image", "second"]);
|
||||
fault.failWrites = 1;
|
||||
const failing = session.prompt([imageOf(2)]);
|
||||
const after = session.prompt("after");
|
||||
await assert.rejects(failing.accepted, /^Error: the store is full$/);
|
||||
await assert.rejects(failing.result, /^Error: the store is full$/);
|
||||
assert.equal((await after.result).stopReason, "end_turn");
|
||||
assert.deepEqual(kinds(seen), ["image", "second", "after"], "the failed write stored no turn, and the prompt behind it ran");
|
||||
await agent.close();
|
||||
});
|
||||
|
||||
test("a prompt's signal or the agent closing while its image is stored keeps its turn from running", async () => {
|
||||
const fault = { writeMs: 400 };
|
||||
const { agent, seen } = recordingAgent(faultyImages(await durabilityFor(), fault));
|
||||
const session = agent.session();
|
||||
const controller = new AbortController();
|
||||
const cancelled = session.prompt([imageOf(3)], { messageId: "turn-store", signal: controller.signal });
|
||||
await sleep(20);
|
||||
const aborted = Date.now();
|
||||
controller.abort();
|
||||
await cancelled.accepted;
|
||||
assert.equal((await cancelled.result).stopReason, "cancelled");
|
||||
assert.ok(Date.now() - aborted < 300, "the prompt stopped waiting for its image write at once");
|
||||
// Neither the prompt nor a cancel naming it was stored: its id runs anew.
|
||||
assert.equal((await session.prompt("after", { messageId: "turn-store" }).result).stopReason, "end_turn");
|
||||
assert.deepEqual(kinds(seen), ["after"]);
|
||||
fault.writeMs = 150;
|
||||
const closing = session.prompt([imageOf(4)]);
|
||||
await agent.close();
|
||||
await assert.rejects(closing.accepted, /agent is closed/);
|
||||
await assert.rejects(closing.result, /agent is closed/);
|
||||
assert.deepEqual(kinds(seen), ["after"]);
|
||||
});
|
||||
|
||||
test("a prompt withdrawn while it waits keeps the prompts behind it waiting for the ones before it", async () => {
|
||||
const { agent, seen } = recordingAgent(faultyImages(await durabilityFor(), { writeMs: 300 }));
|
||||
const session = agent.session();
|
||||
const first = session.prompt([imageOf(5)], { messageId: "turn-first" });
|
||||
const controller = new AbortController();
|
||||
const second = session.prompt([imageOf(6)], { messageId: "turn-second", signal: controller.signal });
|
||||
const third = session.prompt("third", { messageId: "turn-third" });
|
||||
await sleep(20);
|
||||
controller.abort();
|
||||
assert.equal((await second.result).stopReason, "cancelled");
|
||||
assert.equal((await first.result).stopReason, "end_turn");
|
||||
assert.equal((await third.result).stopReason, "end_turn");
|
||||
assert.deepEqual(kinds(seen), ["image", "third"], "the prompt behind the withdrawn one still came after the one before it");
|
||||
await agent.close();
|
||||
});
|
||||
|
||||
test("an image prompt its first worker could not start runs from the log with the same base64, once", async () => {
|
||||
const { agent, seen } = recordingAgent(await durabilityFor(), { refuseFirst: true });
|
||||
const session = agent.session();
|
||||
const shot = { ...image, sourceRef: "uploads:shot" };
|
||||
assert.equal((await session.prompt([shot], { messageId: "turn-img" }).result).stopReason, "end_turn");
|
||||
assert.deepEqual(seen, [[shot], [shot]]);
|
||||
const again = await agent.session(session.id).prompt([shot], { messageId: "turn-img" }).result;
|
||||
assert.equal(again.repeated, true);
|
||||
assert.equal(seen.length, 2, "the retry ran nothing");
|
||||
await agent.close();
|
||||
});
|
||||
|
||||
test("an image block is read once, so the log holds what was checked", async () => {
|
||||
const { agent, seen } = recordingAgent(await durabilityFor());
|
||||
const session = agent.session();
|
||||
// Accessors that answer one way when checked and another way after.
|
||||
let dataReads = 0;
|
||||
let mimeReads = 0;
|
||||
const shifting = {
|
||||
type: "image",
|
||||
get data() { dataReads += 1; return dataReads === 1 ? png : new Uint8Array([1, 2, 3]); },
|
||||
mimeType: "image/png",
|
||||
sourceRef: "uploads:shifting",
|
||||
};
|
||||
const relabelled = {
|
||||
type: "image",
|
||||
data: png,
|
||||
get mimeType() { mimeReads += 1; return mimeReads === 1 ? "image/png" : undefined; },
|
||||
};
|
||||
assert.equal((await session.prompt([{ type: "text", text: "look" }, shifting, relabelled]).result).stopReason, "end_turn");
|
||||
assert.deepEqual(seen.at(-1), [{ type: "text", text: "look" }, { ...image, sourceRef: "uploads:shifting" }, image]);
|
||||
assert.deepEqual([dataReads, mimeReads], [1, 1]);
|
||||
// An Array subclass whose map() returns something else still stores its blocks.
|
||||
class Remapped extends Array { map() { return "not the caller's blocks"; } }
|
||||
const blocks = Remapped.from([{ type: "text", text: "remapped" }, image]);
|
||||
assert.equal((await session.prompt(blocks).result).stopReason, "end_turn");
|
||||
assert.deepEqual(seen.at(-1), [{ type: "text", text: "remapped" }, image]);
|
||||
await agent.close();
|
||||
});
|
||||
|
||||
const pngName = Buffer.from(await crypto.subtle.digest("SHA-256", pngBytes)).toString("hex");
|
||||
// Every image stream's name holds this: "_user_", then the base64url of the
|
||||
// namespace "libfx-image-".
|
||||
const imageStreamTag = `_user_${Buffer.from("libfx-image-").toString("base64url")}`;
|
||||
|
||||
// The durability with a faulty image store: with `fault.lose`, every image
|
||||
// reads back as missing; with `fault.corrupt`, every image reads back with
|
||||
// its last byte changed; `fault.failWrites` fails that many image writes;
|
||||
// `fault.writeMs` makes each image write wait that long first. The test may
|
||||
// change `fault` as it goes. A World's faults are in its streams, under the
|
||||
// code that names images.
|
||||
function faultyImages(durability, fault = {}) {
|
||||
fault.failWrites ??= 0;
|
||||
const slowly = async () => { if (fault.writeMs) await sleep(fault.writeMs); };
|
||||
const refuse = () => {
|
||||
fault.failWrites -= 1;
|
||||
throw new Error("the store is full");
|
||||
};
|
||||
const flipped = (bytes) => {
|
||||
if (!(bytes instanceof Uint8Array) || bytes.byteLength === 0) return bytes;
|
||||
const copy = bytes.slice();
|
||||
copy[copy.length - 1] ^= 0xff;
|
||||
return copy;
|
||||
};
|
||||
if (typeof durability.create === "function") {
|
||||
return {
|
||||
...durability,
|
||||
create() {
|
||||
const backend = durability.create();
|
||||
return {
|
||||
...backend,
|
||||
async session(id) {
|
||||
const log = await backend.session(id);
|
||||
return {
|
||||
...log,
|
||||
images: {
|
||||
put: async (name, bytes) => {
|
||||
await slowly();
|
||||
return fault.failWrites > 0 ? refuse() : log.images.put(name, bytes);
|
||||
},
|
||||
get: async (name) => {
|
||||
if (fault.lose) return undefined;
|
||||
const bytes = await log.images.get(name);
|
||||
return fault.corrupt ? flipped(bytes) : bytes;
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
const isImage = (name) => String(name).includes(imageStreamTag);
|
||||
const bound = (target, value) => (typeof value === "function" ? value.bind(target) : value);
|
||||
return {
|
||||
...durability,
|
||||
async world() {
|
||||
const world = await durability.world();
|
||||
const streams = new Proxy(world.streams, {
|
||||
get(target, prop) {
|
||||
const value = Reflect.get(target, prop);
|
||||
if (prop === "write") {
|
||||
return async (runId, name, chunk) => {
|
||||
if (!isImage(name)) return value.call(target, runId, name, chunk);
|
||||
await slowly();
|
||||
return fault.failWrites > 0 ? refuse() : value.call(target, runId, name, chunk);
|
||||
};
|
||||
}
|
||||
if (prop === "getChunks") {
|
||||
return async (runId, name, options) => {
|
||||
if (fault.lose && isImage(name)) return { data: [], cursor: null, hasMore: false, done: false };
|
||||
const result = await value.call(target, runId, name, options);
|
||||
if (!fault.corrupt || !isImage(name) || !Array.isArray(result?.data)) return result;
|
||||
return { ...result, data: result.data.map((chunk) => ({ ...chunk, data: flipped(chunk?.data) })) };
|
||||
};
|
||||
}
|
||||
return bound(target, value);
|
||||
},
|
||||
});
|
||||
return new Proxy(world, { get: (target, prop) => (prop === "streams" ? streams : bound(target, Reflect.get(target, prop))) });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Every file under a World's folder, for checking what it stored. A lock or
|
||||
// temporary file gone by the time it is read is skipped.
|
||||
async function filesUnder(dir) {
|
||||
const files = [];
|
||||
for (const entry of await readdir(dir, { withFileTypes: true, recursive: true })) {
|
||||
if (!entry.isFile()) continue;
|
||||
try {
|
||||
files.push(await readFile(join(entry.parentPath ?? entry.path, entry.name)));
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
test("a durable prompt stores each image once in the session's image store, and the log only names it", async () => {
|
||||
const durability = await durabilityFor();
|
||||
const { agent, seen } = recordingAgent(durability);
|
||||
const session = agent.session();
|
||||
const turn = session.prompt([
|
||||
{ type: "text", text: "look" },
|
||||
image,
|
||||
{ ...image, sourceRef: "uploads:shot" },
|
||||
image,
|
||||
]);
|
||||
assert.equal((await turn.result).stopReason, "end_turn");
|
||||
assert.deepEqual(seen, [[{ type: "text", text: "look" }, image, { ...image, sourceRef: "uploads:shot" }, image]]);
|
||||
if (typeof durability.create === "function") {
|
||||
const log = await durability.create().session(session.id);
|
||||
const entries = (await log.readSince("0")).map(({ entry }) => entry);
|
||||
assert.deepEqual(entries.find((entry) => entry.k === "input")?.input, [
|
||||
{ type: "text", text: "look" },
|
||||
{ type: "image", mimeType: "image/png", data: { sha256: pngName } },
|
||||
{ type: "image", mimeType: "image/png", sourceRef: "uploads:shot", data: { sha256: pngName } },
|
||||
{ type: "image", mimeType: "image/png", data: { sha256: pngName } },
|
||||
]);
|
||||
assert.ok(!JSON.stringify(entries).includes(png), "no entry holds the image's base64");
|
||||
assert.deepEqual(await log.images.get(pngName), pngBytes);
|
||||
}
|
||||
await agent.close();
|
||||
if (typeof durability.create !== "function") {
|
||||
// On disk: the image's bytes once, in its stream, and its base64 nowhere.
|
||||
const files = await filesUnder(dirs.at(-1));
|
||||
assert.ok(!files.some((bytes) => bytes.includes(Buffer.from(png))), "no file holds the image's base64");
|
||||
assert.equal(files.filter((bytes) => bytes.includes(Buffer.from(pngBytes))).length, 1, "one file holds the image's bytes");
|
||||
}
|
||||
});
|
||||
|
||||
test("an image the session's store no longer holds fails its turn, naming it", async () => {
|
||||
const { agent, seen } = recordingAgent(faultyImages(await durabilityFor(), { lose: true }));
|
||||
const session = agent.session();
|
||||
const result = await session.prompt([{ type: "text", text: "look" }, image]).result;
|
||||
assert.equal(result.stopReason, "error");
|
||||
assert.match(result.error.message, new RegExp(`does not hold image ${pngName}`));
|
||||
assert.deepEqual(seen, [], "the harness never ran it");
|
||||
assert.equal((await session.prompt("after").result).stopReason, "end_turn");
|
||||
await agent.close();
|
||||
});
|
||||
|
||||
test("an image whose stored bytes changed fails its turn, naming it", async () => {
|
||||
const { agent, seen } = recordingAgent(faultyImages(await durabilityFor(), { corrupt: true }));
|
||||
const session = agent.session();
|
||||
const result = await session.prompt([{ type: "text", text: "look" }, image]).result;
|
||||
assert.equal(result.stopReason, "error");
|
||||
// A World's read skips bytes that don't match the name, so to it the image
|
||||
// is missing; a store that returns them fails the check after the read.
|
||||
assert.match(result.error.message, new RegExp(`(holds other bytes under|does not hold) image ${pngName}`));
|
||||
assert.deepEqual(seen, [], "the harness never ran it");
|
||||
assert.equal((await session.prompt("after").result).stopReason, "end_turn");
|
||||
await agent.close();
|
||||
});
|
||||
|
||||
test("a World whose streams lack getChunks refuses an image prompt before storing it, and text still runs", async () => {
|
||||
const durability = await durabilityFor();
|
||||
// memory() keeps images itself; the others keep them in World streams.
|
||||
if (typeof durability.world !== "function") return;
|
||||
const bound = (target, value) => (typeof value === "function" ? value.bind(target) : value);
|
||||
const withoutGetChunks = {
|
||||
...durability,
|
||||
async world() {
|
||||
const world = await durability.world();
|
||||
const streams = new Proxy(world.streams, { get: (target, prop) => (prop === "getChunks" ? undefined : bound(target, Reflect.get(target, prop))) });
|
||||
return new Proxy(world, { get: (target, prop) => (prop === "streams" ? streams : bound(target, Reflect.get(target, prop))) });
|
||||
},
|
||||
};
|
||||
const { agent, seen } = recordingAgent(withoutGetChunks);
|
||||
const session = agent.session();
|
||||
const refused = session.prompt([{ type: "text", text: "look" }, image]);
|
||||
await assert.rejects(refused.accepted, (error) => error instanceof TypeError && /getChunks/.test(error.message));
|
||||
await assert.rejects(refused.result);
|
||||
assert.equal((await session.prompt("text only").result).stopReason, "end_turn");
|
||||
assert.deepEqual(kinds(seen), ["text only"]);
|
||||
await agent.close();
|
||||
});
|
||||
|
||||
test("a failed image write rejects the prompt before anything is stored", async () => {
|
||||
const { agent, seen } = recordingAgent(faultyImages(await durabilityFor(), { failWrites: 1 }));
|
||||
const session = agent.session();
|
||||
const refused = session.prompt([{ type: "text", text: "look" }, image], { messageId: "refused-img" });
|
||||
// The store's own error.
|
||||
const storeFailed = (error) => error.message === "the store is full";
|
||||
await assert.rejects(refused.accepted, storeFailed);
|
||||
await assert.rejects(refused.result, storeFailed);
|
||||
assert.equal((await session.prompt([{ type: "text", text: "again" }, image]).result).stopReason, "end_turn");
|
||||
assert.deepEqual(kinds(seen), ["image"], "only the second prompt was stored and ran");
|
||||
// The refused prompt was never stored, so its retry is a new turn.
|
||||
const retried = await session.prompt([{ type: "text", text: "look" }, image], { messageId: "refused-img" }).result;
|
||||
assert.equal(retried.stopReason, "end_turn");
|
||||
assert.notEqual(retried.repeated, true);
|
||||
assert.deepEqual(kinds(seen), ["image", "image"]);
|
||||
await agent.close();
|
||||
});
|
||||
|
||||
test("a failed image write rejects a steer before it is sent", async () => {
|
||||
const fault = {};
|
||||
const { agent, seen } = recordingAgent(faultyImages(await durabilityFor(), fault));
|
||||
const session = agent.session();
|
||||
assert.equal((await session.prompt("first").result).stopReason, "end_turn");
|
||||
fault.failWrites = 1;
|
||||
// The store's own error.
|
||||
await assert.rejects(session.steer([{ type: "text", text: "look" }, image]), (error) => error.message === "the store is full");
|
||||
// The steer was never sent, so no turn ran for it before the next prompt.
|
||||
assert.equal((await session.prompt("after").result).stopReason, "end_turn");
|
||||
assert.deepEqual(kinds(seen), ["first", "after"]);
|
||||
await agent.close();
|
||||
});
|
||||
|
||||
test("a retried image prompt the log already holds is answered without writing its images again", async () => {
|
||||
const fault = {};
|
||||
const { agent, seen } = recordingAgent(faultyImages(await durabilityFor(), fault));
|
||||
const session = agent.session();
|
||||
assert.equal((await session.prompt([{ type: "text", text: "look" }, image], { messageId: "img-once" }).result).stopReason, "end_turn");
|
||||
// From now on every image write fails, so a retry that wrote again would fail.
|
||||
fault.failWrites = Infinity;
|
||||
const again = await agent.session(session.id).prompt([{ type: "text", text: "look" }, image], { messageId: "img-once" }).result;
|
||||
assert.equal(again.repeated, true);
|
||||
assert.equal(again.stopReason, "end_turn");
|
||||
assert.deepEqual(kinds(seen), ["image"], "the retry ran nothing");
|
||||
await agent.close();
|
||||
});
|
||||
|
||||
test("base64 is the same with and without the runtime's own encoder and decoder", async () => {
|
||||
const reference = (bytes) => btoa(String.fromCharCode(...bytes));
|
||||
const inputs = [...Array.from({ length: 9 }, (_, n) => pngBytes.subarray(0, n + 1)), Uint8Array.from({ length: 3000 }, (_, i) => (i * 131) & 255)];
|
||||
const run = async () => {
|
||||
const { agent, seen } = recordingAgent(await durabilityFor());
|
||||
const session = agent.session();
|
||||
// Each image is decoded to be stored, and encoded again for the harness.
|
||||
for (const bytes of inputs) assert.equal((await session.prompt([{ type: "image", data: reference(bytes), mimeType: "image/png" }]).result).stopReason, "end_turn");
|
||||
// Each turn's records went through the log as base64 and back.
|
||||
const words = JSON.parse(decoder.decode(await session.checkpoint()));
|
||||
await agent.close();
|
||||
return { seen, words };
|
||||
};
|
||||
const native = await run();
|
||||
const encode = Uint8Array.prototype.toBase64;
|
||||
const decode = Uint8Array.fromBase64;
|
||||
delete Uint8Array.prototype.toBase64;
|
||||
delete Uint8Array.fromBase64;
|
||||
let fallback;
|
||||
try {
|
||||
fallback = await run();
|
||||
} finally {
|
||||
if (encode) Uint8Array.prototype.toBase64 = encode;
|
||||
if (decode) Uint8Array.fromBase64 = decode;
|
||||
}
|
||||
for (const result of [native, fallback]) {
|
||||
assert.deepEqual(result.seen.map((input) => input[0].data), inputs.map(reference));
|
||||
assert.equal(result.words.length, inputs.length * 3);
|
||||
}
|
||||
});
|
||||
|
||||
test("a worker lives only while a delivery runs it", async () => {
|
||||
const agent = createAgent({ durability: await durabilityFor() });
|
||||
const internals = agent[Symbol.for("libfx.durableInternals")];
|
||||
@@ -1493,6 +1950,26 @@ if (durabilityKind === "memory") {
|
||||
assert.equal(await inputs(), before, "no input was stored");
|
||||
await agent.close();
|
||||
});
|
||||
|
||||
test("a session stores its context as it was when the session opened", async () => {
|
||||
const agent = createAgent({ durability: memory() });
|
||||
const context = { user: "a" };
|
||||
const session = agent.session(undefined, { context });
|
||||
try {
|
||||
await session.prompt("first").result;
|
||||
const log = await agent[Symbol.for("libfx.durableInternals")].openLog(session.id);
|
||||
const inputs = async () => (await log.read()).filter(({ entry }) => entry.k === "input").map(({ entry }) => entry);
|
||||
// A change the caller makes later reaches neither the size check nor
|
||||
// the log, so the 8 MiB limit holds for what is stored.
|
||||
context.user = "x".repeat(8 * 1024 * 1024);
|
||||
await session.steer("next", { messageId: "steer-after-change" });
|
||||
const stored = (await inputs()).find((entry) => entry.messageId === "steer-after-change");
|
||||
assert.deepEqual(stored.context, { user: "a" });
|
||||
assert.ok(JSON.stringify(stored).length < 1024, "the changed context was not stored");
|
||||
} finally {
|
||||
await agent.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (durabilityKind === "queue") {
|
||||
|
||||
+353
-18
@@ -5,7 +5,8 @@
|
||||
// node --experimental-wasm-jspi sdk/tests/test-durable.mjs [memory|local] [native|wasm]
|
||||
import { strict as assert } from "node:assert";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
@@ -61,6 +62,8 @@ const crashSteer = (kind) => (kind === "image"
|
||||
? [{ type: "text", text: "use lookup with this" }, { type: "image", data: onePixelPng, mimeType: "image/png" }]
|
||||
: "use lookup");
|
||||
const resumeNotice = "Resuming from unexpected session interruption.";
|
||||
// The user message the gateway adds after a tool result's images.
|
||||
const toolImageFollowUp = "Attached image(s) from the tool result.";
|
||||
|
||||
const textOf = (message) => typeof message.content === "string"
|
||||
? message.content
|
||||
@@ -81,7 +84,7 @@ const answer = (text) => [{ type: "text-delta", id: "answer", delta: text }, fin
|
||||
// lookup call's input a little at a time for a few seconds; anything else is
|
||||
// echoed.
|
||||
function framesFor(prompt) {
|
||||
const users = userTexts(prompt).filter((text) => text !== resumeNotice);
|
||||
const users = userTexts(prompt).filter((text) => text !== resumeNotice && text !== toolImageFollowUp);
|
||||
const text = users.at(-1) ?? "";
|
||||
const results = toolResults(prompt);
|
||||
const turnResults = results.filter((part) => String(part.toolCallId).startsWith(`call-${users.length}-`));
|
||||
@@ -105,7 +108,6 @@ function framesFor(prompt) {
|
||||
// records how many words it sent.
|
||||
const slowReply = Symbol("slow reply");
|
||||
const slowFrames = 75;
|
||||
const slowReplyMs = slowFrames * 40;
|
||||
const slow = [];
|
||||
const stalledReply = Symbol("stalled reply");
|
||||
const slowToolCall = Symbol("slow tool call");
|
||||
@@ -122,8 +124,11 @@ const server = createServer((request, response) => {
|
||||
request.on("end", () => {
|
||||
if (request.method === "GET") {
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
// It takes images, for the steers that carry one.
|
||||
response.end(JSON.stringify({ object: "list", data: [{ id: "durable/model", type: "language", tags: ["vision", "file-input"] }] }));
|
||||
response.end(JSON.stringify({ object: "list", data: [
|
||||
// It takes images, for the steers that carry one.
|
||||
{ id: "durable/model", type: "language", tags: ["vision", "file-input"] },
|
||||
{ id: "durable/vision-model", type: "language", tags: ["tool-use", "vision", "file-input"] },
|
||||
] }));
|
||||
return;
|
||||
}
|
||||
const prompt = JSON.parse(body).prompt;
|
||||
@@ -1031,7 +1036,7 @@ async function linesUntil(session, done, label, ms = 15_000 * timeScale) {
|
||||
test("a steer cuts a streaming reply short, and the turn goes on with it", async () => {
|
||||
const agent = createFxAgent(agentOptions(await durabilityFor()));
|
||||
const session = agent.session();
|
||||
const started = Date.now();
|
||||
const before = slow.length;
|
||||
const turn = session.prompt("slow reply");
|
||||
const { messageId } = await turn.accepted;
|
||||
// Steer once the reply has reached the turn's viewer.
|
||||
@@ -1040,9 +1045,10 @@ test("a steer cuts a streaming reply short, and the turn goes on with it", async
|
||||
assert.deepEqual(await session.steer("be brief", { messageId: "m-brief" }), { messageId: "m-brief" });
|
||||
for (let next = await events.next(); !next.done; next = await events.next());
|
||||
const result = await turn.result;
|
||||
const elapsed = Date.now() - started;
|
||||
assert.equal(result.stopReason, "end_turn");
|
||||
assert.ok(elapsed < slowReplyMs, `the turn ended after ${elapsed} ms, not after the whole reply`);
|
||||
// Counted by the model server, so a remote World's latency cannot hide a
|
||||
// reply that ran to its end.
|
||||
assert.ok(slow[before].sent < slowFrames, `the reply sent ${slow[before].sent} of ${slowFrames} words, not cut short`);
|
||||
const last = requests.at(-1);
|
||||
assert.ok(userTexts(last).some((text) => text.includes("be brief")), "the next request carried the steer");
|
||||
assert.ok(JSON.stringify(last).includes("w0"), "the reply so far stays in the turn");
|
||||
@@ -1074,16 +1080,14 @@ test("a steer with an image ends the streaming turn, and runs as the next turn",
|
||||
const agent = createFxAgent(agentOptions(await durabilityFor()));
|
||||
const session = agent.session();
|
||||
const before = slow.length;
|
||||
const started = Date.now();
|
||||
const turn = session.prompt("slow reply");
|
||||
const { messageId } = await turn.accepted;
|
||||
await until(() => slow.length > before && slow.at(-1).sent > 0, "the reply to stream");
|
||||
await session.steer([{ type: "text", text: "match this" }, { type: "image", data: onePixelPng, mimeType: "image/png" }], { messageId: "m-image" });
|
||||
await session.steer("and keep it small", { messageId: "m-after-image" });
|
||||
const first = await turn.result;
|
||||
const elapsed = Date.now() - started;
|
||||
assert.notEqual(first.stopReason, "end_turn", "the running turn did not finish its reply");
|
||||
assert.ok(elapsed < slowReplyMs, `the running turn ended after ${elapsed} ms, not after the whole reply`);
|
||||
assert.ok(slow[before].sent < slowFrames, `the running reply sent ${slow[before].sent} of ${slowFrames} words, not cut short`);
|
||||
const lines = await linesUntil(session, (line) => line.type === "turn_end" && line.messageId === "m-image", "the image's turn");
|
||||
assert.deepEqual(lines.filter((line) => line.type === "turn_start").map((line) => line.messageId), [messageId, "m-image"]);
|
||||
const imageRequest = requests.findLast((prompt) => userTexts(prompt).some((text) => text.includes("match this")));
|
||||
@@ -1673,19 +1677,332 @@ test("an option the backend rejects fails the first turn once", async () => {
|
||||
await agent.close();
|
||||
});
|
||||
|
||||
test("a prompt the engine refuses ends its own turn once, and the session goes on", async () => {
|
||||
const agent = createFxAgent(agentOptions(await durabilityFor()));
|
||||
// A 1x1 PNG, as bytes, for the image tests below.
|
||||
const pngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jP0cAAAAASUVORK5CYII=";
|
||||
const pngBytes = () => Uint8Array.from(atob(pngBase64), (char) => char.charCodeAt(0));
|
||||
|
||||
test("a prompt the engine would refuse throws, stores nothing, and the session goes on", async () => {
|
||||
const agent = createFxAgent(agentOptions(await durabilityFor(), { model: "durable/vision-model" }));
|
||||
const session = agent.session();
|
||||
const empty = await session.prompt("").result;
|
||||
assert.equal(empty.stopReason, "error");
|
||||
const malformed = await session.prompt([{ type: "text" }]).result;
|
||||
assert.equal(malformed.stopReason, "error");
|
||||
const { text, result } = await collect(session.prompt("hello again"));
|
||||
const image = (data, mimeType = "image/png") => ({ type: "image", data, mimeType });
|
||||
for (const [input, refusal] of [
|
||||
["", /prompt is empty/],
|
||||
[[{ type: "text" }], /text prompt block 0 requires text/],
|
||||
[[{ type: "text", text: "look" }, image("not base64!")], /image prompt block 1 requires canonical base64 data/],
|
||||
[[{ type: "text", text: "look" }, image(pngBytes())], /a durable prompt carries image data as a base64 string/],
|
||||
[[{ type: "text", text: "look" }, image(pngBase64, "image/jpeg")], /image prompt block 1 holds image\/png data, not image\/jpeg/],
|
||||
[[{ type: "text", text: "look" }, image(pngBase64, "image/gif")], /image prompt block 1 holds image\/png data, not image\/gif/],
|
||||
[[{ type: "text", text: "look" }, image(Buffer.from([1, 2, 3, 4]).toString("base64"))], /image prompt block 1 is not a PNG, JPEG, GIF or WebP image/],
|
||||
[[{ type: "text", text: "look" }, image(pngBase64, "image/bmp")], /image prompt block 1 mimeType must be image\/png, image\/jpeg, image\/gif or image\/webp/],
|
||||
[[{ type: "text", text: "look" }, ...Array.from({ length: 9 }, () => image(pngBase64))], /prompt cannot contain more than 8 images/],
|
||||
]) {
|
||||
assert.throws(() => session.prompt(input, { messageId: "refused" }), refusal);
|
||||
}
|
||||
// Nothing was stored under that id, so a prompt with it runs.
|
||||
const { text, result } = await collect(session.prompt("hello again", { messageId: "refused" }));
|
||||
assert.equal(result.stopReason, "end_turn");
|
||||
assert.equal(text, "echo: hello again");
|
||||
await agent.close();
|
||||
});
|
||||
|
||||
test("a durable prompt's images reach the model as the base64 it was given", async () => {
|
||||
const png = pngBase64;
|
||||
const agent = createFxAgent(agentOptions(await durabilityFor(), { model: "durable/vision-model" }));
|
||||
const { text, result } = await collect(agent.session().prompt([
|
||||
{ type: "text", text: "look" },
|
||||
{ type: "image", data: png, mimeType: "image/png" },
|
||||
{ type: "image", data: png, mimeType: "image/png", sourceRef: "uploads:shot" },
|
||||
]));
|
||||
assert.equal(result.stopReason, "end_turn");
|
||||
// The model hears each image's placeholder alongside its text.
|
||||
assert.equal(text, "echo: look\n[Image #1]\n[Image #2]");
|
||||
const files = chats.at(-1).prompt
|
||||
.filter((message) => message.role === "user" && Array.isArray(message.content))
|
||||
.flatMap((message) => message.content)
|
||||
.filter((part) => part.type === "file");
|
||||
const file = { type: "file", mediaType: "image/png", data: { type: "data", data: png } };
|
||||
assert.deepEqual(files, [file, file]);
|
||||
await agent.close();
|
||||
});
|
||||
|
||||
test("an image read that fails while the engine opens runs the turn again instead of failing it", async () => {
|
||||
const base = await durabilityFor();
|
||||
// memory() hands out its sessions directly, so one image read can fail.
|
||||
if (typeof base.create !== "function") return;
|
||||
const fault = { failReads: 0 };
|
||||
const durability = {
|
||||
...base,
|
||||
create() {
|
||||
const backend = base.create();
|
||||
return {
|
||||
...backend,
|
||||
async session(id) {
|
||||
const log = await backend.session(id);
|
||||
return {
|
||||
...log,
|
||||
images: {
|
||||
put: (name, bytes) => log.images.put(name, bytes),
|
||||
get: async (name) => {
|
||||
if (fault.failReads > 0) {
|
||||
fault.failReads -= 1;
|
||||
throw new Error("the image store did not answer");
|
||||
}
|
||||
return log.images.get(name);
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const png = pngBase64;
|
||||
const first = createFxAgent(agentOptions(durability, { model: "durable/vision-model" }));
|
||||
const session = first.session();
|
||||
assert.equal((await session.prompt([{ type: "text", text: "look" }, { type: "image", data: png, mimeType: "image/png" }]).result).stopReason, "end_turn");
|
||||
await first.close();
|
||||
// A new agent's engine reads the session's image as it opens; that read
|
||||
// fails once.
|
||||
fault.failReads = 1;
|
||||
const second = createFxAgent(agentOptions(durability, { model: "durable/vision-model" }));
|
||||
const { result } = await collect(second.session(session.id).prompt("and now"));
|
||||
assert.equal(result.stopReason, "end_turn");
|
||||
assert.equal(fault.failReads, 0, "the failed read happened");
|
||||
const files = chats.at(-1).prompt
|
||||
.filter((message) => message.role === "user" && Array.isArray(message.content))
|
||||
.flatMap((message) => message.content)
|
||||
.filter((part) => part.type === "file");
|
||||
assert.deepEqual(files.map((part) => part.data?.data), [png]);
|
||||
await second.close();
|
||||
});
|
||||
|
||||
test("an engine given an image the log names refuses it, so a worker that can't read names fails the turn", async () => {
|
||||
// The log holds a stored image as `data: { sha256 }`, which an engine that
|
||||
// predates names can't read, with or without a sourceRef: such a worker
|
||||
// fails the turn instead of running it without the image.
|
||||
const { durability: _durability, tools: _tools, ...engineOptions } = agentOptions(null, { model: "durable/vision-model" });
|
||||
const engine = await createFxEngine(engineOptions);
|
||||
const named = { type: "image", mimeType: "image/png", data: { sha256: "a".repeat(64) } };
|
||||
for (const block of [named, { ...named, sourceRef: "uploads:shot" }]) {
|
||||
assert.throws(() => engine.prompt([{ type: "text", text: "look" }, block]), /requires base64 data, bytes, or a Blob/);
|
||||
}
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
test("a durable prompt checks the copy its log holds, so a getter can't pass one image and store another", async () => {
|
||||
const agent = createFxAgent(agentOptions(await durabilityFor(), { model: "durable/vision-model" }));
|
||||
let reads = 0;
|
||||
const block = { type: "image", mimeType: "image/png" };
|
||||
Object.defineProperty(block, "data", { enumerable: true, get: () => (reads++ === 0 ? pngBase64 : Buffer.from([1, 2, 3, 4]).toString("base64")) });
|
||||
const { result } = await collect(agent.session().prompt([{ type: "text", text: "look" }, block]));
|
||||
assert.equal(result.stopReason, "end_turn");
|
||||
assert.equal(reads, 1, "the block was read once");
|
||||
const files = chats.at(-1).prompt.flatMap((message) => Array.isArray(message.content) ? message.content : []).filter((part) => part.type === "file");
|
||||
assert.deepEqual(files.map((part) => part.data?.data), [pngBase64]);
|
||||
await agent.close();
|
||||
});
|
||||
|
||||
test("a durable prompt cancelled while its image is stored is never stored, and sends no cancel", async () => {
|
||||
const base = await durabilityFor();
|
||||
// memory() hands out its sessions directly, so its image writes can be held.
|
||||
if (typeof base.create !== "function") return;
|
||||
let release;
|
||||
const held = new Promise((resolveHeld) => { release = resolveHeld; });
|
||||
const durability = {
|
||||
...base,
|
||||
create() {
|
||||
const backend = base.create();
|
||||
return {
|
||||
...backend,
|
||||
async session(id) {
|
||||
const log = await backend.session(id);
|
||||
return { ...log, images: { put: async (name, bytes) => { await held; return log.images.put(name, bytes); }, get: (name) => log.images.get(name) } };
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const agent = createFxAgent(agentOptions(durability, { model: "durable/vision-model" }));
|
||||
const session = agent.session();
|
||||
const controller = new AbortController();
|
||||
const turn = session.prompt([{ type: "text", text: "look" }, { type: "image", data: pngBase64, mimeType: "image/png" }], { messageId: "withdrawn", signal: controller.signal });
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 20));
|
||||
controller.abort();
|
||||
// The prompt stops waiting for its image write at once.
|
||||
await turn.accepted;
|
||||
assert.equal((await turn.result).stopReason, "cancelled");
|
||||
release();
|
||||
// Neither the prompt nor a cancel naming it was stored: one stored would
|
||||
// answer a prompt with its id from the log, and a cancel would end it.
|
||||
const { text, result } = await collect(session.prompt("hello again", { messageId: "withdrawn" }));
|
||||
assert.equal(result.stopReason, "end_turn");
|
||||
assert.equal(text, "echo: hello again");
|
||||
await agent.close();
|
||||
});
|
||||
|
||||
test("an engine refuses the same prompts in prompt(), before its core sees them", async () => {
|
||||
const { durability: _durability, tools: _tools, ...engineOptions } = agentOptions(null, { model: "durable/vision-model" });
|
||||
const engine = await createFxEngine(engineOptions);
|
||||
assert.throws(() => engine.prompt(""), /prompt is empty/);
|
||||
assert.throws(() => engine.prompt([{ type: "text", text: "look" }, { type: "image", data: pngBytes(), mimeType: "image/jpeg" }]), /holds image\/png data, not image\/jpeg/);
|
||||
await assert.rejects(engine.prompt([{ type: "text", text: "look" }, { type: "image", data: new File([pngBytes()], "shot.jpg", { type: "image/jpeg" }) }]).result, /holds image\/png data, not image\/jpeg/);
|
||||
const { result } = await collect(engine.prompt("hello"));
|
||||
assert.equal(result.stopReason, "end_turn");
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
test("a durable session saves each image once, and a new agent sends it again", async () => {
|
||||
// A PNG header, then filler, so its bytes are easy to look for.
|
||||
const header = Uint8Array.from(atob("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jP0cAAAAASUVORK5CYII="), (char) => char.charCodeAt(0));
|
||||
const bytes = new Uint8Array(300_000);
|
||||
bytes.set(header);
|
||||
for (let index = header.length; index < bytes.length; index += 1) bytes[index] = (index * 31 + 7) & 255;
|
||||
const base64 = Buffer.from(bytes).toString("base64");
|
||||
const durability = await durabilityFor();
|
||||
const holdsImage = (data) => data.includes(Buffer.from(base64.slice(1000, 1100))) || data.includes(Buffer.from(bytes.subarray(1000, 1064)));
|
||||
const savedData = (entry) => (entry.k === "record" || entry.k === "checkpoint" ? Buffer.from(entry.data, "base64") : Buffer.from(JSON.stringify(entry)));
|
||||
// A remote World keeps nothing on this machine, so what reaches it is
|
||||
// watched instead: each entry the log appends, and each stream write.
|
||||
const sent = { entries: [], imageWrites: 0 };
|
||||
if (remote) {
|
||||
const open = durability.world.bind(durability);
|
||||
durability.world = async () => {
|
||||
const world = await open();
|
||||
const create = world.events.create.bind(world.events);
|
||||
world.events.create = async (runId, event, ...rest) => {
|
||||
const input = event?.eventData?.input;
|
||||
if (input instanceof Uint8Array) {
|
||||
try {
|
||||
const entry = JSON.parse(Buffer.from(input).toString("utf8"));
|
||||
if (typeof entry?.k === "string") sent.entries.push(entry);
|
||||
} catch {}
|
||||
}
|
||||
return create(runId, event, ...rest);
|
||||
};
|
||||
const write = world.streams.write.bind(world.streams);
|
||||
world.streams.write = async (runId, stream, data, ...rest) => {
|
||||
if (data instanceof Uint8Array && Buffer.from(data).equals(Buffer.from(bytes))) sent.imageWrites += 1;
|
||||
return write(runId, stream, data, ...rest);
|
||||
};
|
||||
return world;
|
||||
};
|
||||
}
|
||||
const first = createFxAgent(agentOptions(durability, { model: "durable/vision-model" }));
|
||||
const session = first.session();
|
||||
// Records that only name the image stay small, so the second prompt is
|
||||
// `bulky`: its turn's end saves a checkpoint, which must name the images too.
|
||||
for (const text of ["look", bulky]) {
|
||||
const { result } = await collect(session.prompt([{ type: "text", text }, { type: "image", data: base64, mimeType: "image/png" }]));
|
||||
assert.equal(result.stopReason, "end_turn");
|
||||
}
|
||||
// A turn's checkpoint is saved after its end, and close() waits for it.
|
||||
await first.close();
|
||||
if (durabilityKind === "memory" || remote) {
|
||||
// Every entry, records and checkpoints decoded: names only.
|
||||
const entries = remote ? sent.entries : (await (await durability.create().session(session.id)).readSince("0")).map(({ entry }) => entry);
|
||||
assert.ok(entries.some((entry) => entry.k === "checkpoint"));
|
||||
assert.ok(!entries.map(savedData).some(holdsImage), "no entry holds the image");
|
||||
}
|
||||
if (remote) {
|
||||
assert.equal(sent.imageWrites, 1, "the World got the image's bytes once");
|
||||
} else if (durabilityKind !== "memory") {
|
||||
const files = [];
|
||||
for (const entry of await readdir(dirs.at(-1), { withFileTypes: true, recursive: true })) {
|
||||
if (!entry.isFile()) continue;
|
||||
try {
|
||||
files.push(await readFile(join(entry.parentPath ?? entry.path, entry.name)));
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
}
|
||||
}
|
||||
assert.ok(!files.some((data) => data.includes(Buffer.from(base64.slice(1000, 1100)))), "no file holds the image as base64");
|
||||
assert.equal(files.filter((data) => data.includes(Buffer.from(bytes.subarray(1000, 1064)))).length, 1, "one file holds the image's bytes");
|
||||
}
|
||||
|
||||
const second = createFxAgent(agentOptions(durabilityKind === "memory" ? durability : await durabilityFor({ dir: dirs.at(-1) }), { model: "durable/vision-model" }));
|
||||
const { result } = await collect(second.session(session.id).prompt("and now"));
|
||||
assert.equal(result.stopReason, "end_turn");
|
||||
const files = chats.at(-1).prompt
|
||||
.filter((message) => message.role === "user" && Array.isArray(message.content))
|
||||
.flatMap((message) => message.content)
|
||||
.filter((part) => part.type === "file");
|
||||
assert.deepEqual(files.map((part) => part.data?.data), [base64, base64]);
|
||||
await second.close();
|
||||
});
|
||||
|
||||
// A PNG of 300 KB with filler after its header, so its bytes are easy to
|
||||
// look for, as base64.
|
||||
const shotBytes = (() => {
|
||||
const header = Uint8Array.from(atob(onePixelPng), (char) => char.charCodeAt(0));
|
||||
const bytes = new Uint8Array(300_000);
|
||||
bytes.set(header);
|
||||
for (let index = header.length; index < bytes.length; index += 1) bytes[index] = (index * 17 + 3) & 255;
|
||||
return bytes;
|
||||
})();
|
||||
const shotBase64 = Buffer.from(shotBytes).toString("base64");
|
||||
const shotName = createHash("sha256").update(shotBytes).digest("hex");
|
||||
const holdsShot = (data) => data.includes(Buffer.from(shotBase64.slice(1000, 1100))) || data.includes(Buffer.from(shotBytes.subarray(1000, 1064)));
|
||||
const entryData = (entry) => (entry.k === "record" || entry.k === "checkpoint" ? Buffer.from(entry.data, "base64") : Buffer.from(JSON.stringify(entry)));
|
||||
// Returns the screenshot as a typed result.
|
||||
const screenshot = {
|
||||
name: "screenshot",
|
||||
description: "Takes a screenshot",
|
||||
idempotent: true,
|
||||
inputSchema: { type: "object", properties: { key: { type: "string" } } },
|
||||
execute: async () => ({ type: "libfx.tool-result", text: "the screen", images: [{ type: "image", data: shotBase64, mimeType: "image/png" }] }),
|
||||
};
|
||||
const requestFiles = (prompt) => prompt
|
||||
.filter((message) => message.role === "user" && Array.isArray(message.content))
|
||||
.flatMap((message) => message.content)
|
||||
.filter((part) => part.type === "file")
|
||||
.map((part) => part.data?.data ?? part.data);
|
||||
|
||||
test("a durable session saves a tool's image once, its log only names it, and a new agent sends it again", async () => {
|
||||
const durability = await durabilityFor();
|
||||
const withShots = { model: "durable/vision-model", tools: [lookup, send, save, screenshot] };
|
||||
const first = createFxAgent(agentOptions(durability, withShots));
|
||||
const session = first.session();
|
||||
const { result } = await collect(session.prompt("use screenshot"));
|
||||
assert.equal(result.stopReason, "end_turn");
|
||||
assert.deepEqual(requestFiles(chats.at(-1).prompt), [shotBase64], "the model saw the screenshot");
|
||||
await first.close();
|
||||
if (durabilityKind === "memory") {
|
||||
const log = await durability.create().session(session.id);
|
||||
const entries = (await log.readSince("0")).map(({ entry }) => entry);
|
||||
assert.ok(!entries.map(entryData).some(holdsShot), "no entry holds the screenshot");
|
||||
assert.ok(Buffer.from(await log.images.get(shotName)).equals(Buffer.from(shotBytes)), "the image store holds it by name");
|
||||
}
|
||||
|
||||
const second = createFxAgent(agentOptions(durabilityKind === "memory" ? durability : await durabilityFor({ dir: dirs.at(-1) }), withShots));
|
||||
const { result: after } = await collect(second.session(session.id).prompt("and now"));
|
||||
assert.equal(after.stopReason, "end_turn");
|
||||
assert.deepEqual(requestFiles(chats.at(-1).prompt), [shotBase64], "a new agent sends the screenshot again");
|
||||
await second.close();
|
||||
});
|
||||
|
||||
test("a durable steer saves its image once, and its log entry only names it", async () => {
|
||||
const durability = await durabilityFor();
|
||||
const agent = createFxAgent(agentOptions(durability, { model: "durable/vision-model" }));
|
||||
const session = agent.session();
|
||||
const before = slow.length;
|
||||
const turn = session.prompt("slow reply");
|
||||
await turn.accepted;
|
||||
await until(() => slow.length > before && slow.at(-1).sent > 0, "the reply to stream");
|
||||
await session.steer([{ type: "text", text: "match the shot" }, { type: "image", data: shotBase64, mimeType: "image/png" }], { messageId: "m-shot" });
|
||||
await turn.result;
|
||||
await linesUntil(session, (line) => line.type === "turn_end" && line.messageId === "m-shot", "the steer's turn");
|
||||
const steered = chats.findLast(({ prompt }) => userTexts(prompt).some((text) => text.includes("match the shot")));
|
||||
assert.deepEqual(requestFiles(steered.prompt), [shotBase64], "the steer's image reached the model");
|
||||
await agent.close();
|
||||
if (durabilityKind === "memory") {
|
||||
const log = await durability.create().session(session.id);
|
||||
const entries = (await log.readSince("0")).map(({ entry }) => entry);
|
||||
const entry = entries.find((item) => item.k === "input" && item.messageId === "m-shot");
|
||||
assert.ok(entry, "the steer is in the log");
|
||||
assert.deepEqual(entry.input.find((block) => block.type === "image").data, { sha256: shotName });
|
||||
assert.ok(!entries.map(entryData).some(holdsShot), "no entry holds the image");
|
||||
assert.ok(Buffer.from(await log.images.get(shotName)).equals(Buffer.from(shotBytes)), "the image store holds it by name");
|
||||
}
|
||||
});
|
||||
|
||||
test("the fx harness tells a refused turn from one whose core exited under it", async () => {
|
||||
const { fxHarness } = await import("../fx-harness.js");
|
||||
const { coreAnswered, engineInternals } = await import("../fx-sdk.js");
|
||||
@@ -1842,6 +2159,24 @@ test("a checkpoint restores the conversation in a new agent", async () => {
|
||||
await restored.close();
|
||||
});
|
||||
|
||||
test("a checkpoint after an image prompt holds the image, and a new agent sends it", async () => {
|
||||
const png = pngBase64;
|
||||
const agent = createFxAgent(agentOptions(await durabilityFor(), { model: "durable/vision-model" }));
|
||||
const look = [{ type: "text", text: "look" }, { type: "image", data: png, mimeType: "image/png" }];
|
||||
assert.equal((await agent.prompt(look).result).stopReason, "end_turn");
|
||||
// The session's records name the image; the checkpoint carries its bytes.
|
||||
const checkpoint = await agent.checkpoint();
|
||||
await agent.close();
|
||||
const restored = createFxAgent(agentOptions(await durabilityFor(), { checkpoint, model: "durable/vision-model" }));
|
||||
assert.equal((await restored.prompt("what was in it").result).stopReason, "end_turn");
|
||||
const files = chats.at(-1).prompt
|
||||
.filter((message) => message.role === "user" && Array.isArray(message.content))
|
||||
.flatMap((message) => message.content)
|
||||
.filter((part) => part.type === "file");
|
||||
assert.deepEqual(files.map((part) => part.data?.data), [png]);
|
||||
await restored.close();
|
||||
});
|
||||
|
||||
// LIBFX_TEST_ONLY runs only the tests whose names contain it.
|
||||
const only = process.env.LIBFX_TEST_ONLY;
|
||||
const selected = only ? tests.filter(([name]) => name.includes(only)) : tests;
|
||||
|
||||
+738
-9
@@ -19,7 +19,10 @@ const usage = { inputTokens: { total: 1 }, outputTokens: { total: 1 } };
|
||||
const textOf = (message) => typeof message.content === "string"
|
||||
? message.content
|
||||
: message.content.filter((part) => part.type === "text").map((part) => part.text).join("");
|
||||
const lastUserText = (prompt) => textOf(prompt.filter((message) => message.role === "user").at(-1));
|
||||
// The user message the gateway adds after a tool result's images.
|
||||
const toolImageFollowUp = "Attached image(s) from the tool result.";
|
||||
const isPrompt = (message) => message.role === "user" && textOf(message) !== toolImageFollowUp;
|
||||
const lastUserText = (prompt) => textOf(prompt.filter(isPrompt).at(-1));
|
||||
const toolResults = (prompt) => prompt
|
||||
.flatMap((message) => Array.isArray(message.content) ? message.content : [])
|
||||
.filter((part) => part.type === "tool-result");
|
||||
@@ -28,6 +31,12 @@ const toolResults = (prompt) => prompt
|
||||
const encodeRecord = (events) => new TextEncoder().encode(JSON.stringify({ format: "libfx-journal-v1", events }));
|
||||
const decodeRecord = (data) => JSON.parse(new TextDecoder().decode(data)).events;
|
||||
const storedEvents = (store) => store.records.flatMap((record) => decodeRecord(record.data));
|
||||
// A crash during the last turn's model request: the store kept only the
|
||||
// progress written before that request.
|
||||
const crashDuringRequest = (events) => {
|
||||
const last = events.at(-1).turn;
|
||||
return events.slice(0, events.findIndex((event) => event.turn === last && event.type === "turn_progress") + 1);
|
||||
};
|
||||
|
||||
// The tests shape sessions as event lists, such as a crash that lost the last
|
||||
// events. `persisted` keeps such a list behind the persistence interface: each
|
||||
@@ -64,8 +73,13 @@ function persisted(journal) {
|
||||
// "gather everything" calls bulky bulkySteps times, one call per response.
|
||||
// Any other prompt is answered with its own text.
|
||||
const bulkySteps = 24;
|
||||
// An image prompt's text ends with a marker per image, which the scripts
|
||||
// ignore.
|
||||
function scriptText(prompt) {
|
||||
return lastUserText(prompt).replace(/(\n\[Image #\d+\])+$/u, "");
|
||||
}
|
||||
function framesFor(prompt) {
|
||||
const text = lastUserText(prompt);
|
||||
const text = scriptText(prompt);
|
||||
// "keep using the tool" asks for a lookup until three results are in, so an
|
||||
// agent that ignored a fence would keep calling it.
|
||||
if (text === "keep using the tool") {
|
||||
@@ -107,6 +121,35 @@ function framesFor(prompt) {
|
||||
{ type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage },
|
||||
];
|
||||
}
|
||||
// "album N" shows N album pages at once in one response, then says it saw
|
||||
// them.
|
||||
const pages = /^album (\d+)$/u.exec(text);
|
||||
if (pages) {
|
||||
if (toolResults(prompt.slice(prompt.findLastIndex(isPrompt) + 1)).length === 0) {
|
||||
return [
|
||||
...Array.from({ length: Number(pages[1]) }, (_, page) => ({ type: "tool-call", toolCallId: `call-page-${page}`, toolName: "album", input: { page } })),
|
||||
{ type: "finish", finishReason: { unified: "tool-calls", raw: "tool-calls" }, usage },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ type: "text-delta", id: "answer", delta: "saw the album" },
|
||||
{ type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage },
|
||||
];
|
||||
}
|
||||
// "screenshot N" takes screenshot N once in its turn, then says it saw it.
|
||||
const shot = /^screenshot (\d+)$/u.exec(text);
|
||||
if (shot) {
|
||||
if (toolResults(prompt.slice(prompt.findLastIndex(isPrompt) + 1)).length === 0) {
|
||||
return [
|
||||
{ type: "tool-call", toolCallId: `call-shot-${shot[1]}`, toolName: "screenshot", input: { shot: Number(shot[1]) } },
|
||||
{ type: "finish", finishReason: { unified: "tool-calls", raw: "tool-calls" }, usage },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ type: "text-delta", id: "answer", delta: `saw ${shot[1]}` },
|
||||
{ type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage },
|
||||
];
|
||||
}
|
||||
if (text === "use the tool" && toolResults(prompt).length === 0) {
|
||||
return [
|
||||
{ type: "tool-call", toolCallId: "call-1", toolName: "lookup", input: { key: "alpha" } },
|
||||
@@ -139,7 +182,7 @@ const server = createServer((request, response) => {
|
||||
Object.defineProperty(prompt, "toolChoice", { value: toolChoice?.type ?? null });
|
||||
requests.push(prompt);
|
||||
sessionHeaders.push([request.headers["x-session-id"], request.headers["x-session-affinity"]]);
|
||||
if (lastUserText(prompt) === "fail please") {
|
||||
if (scriptText(prompt) === "fail please") {
|
||||
response.writeHead(400, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ error: { message: "refused for the test", type: "invalid_request_error" } }));
|
||||
return;
|
||||
@@ -172,6 +215,35 @@ const bulky = {
|
||||
inputSchema: { type: "object", properties: { part: { type: "number" } } },
|
||||
execute: async ({ part }) => `${part}:${"x".repeat(60_000)}`,
|
||||
};
|
||||
// Returns screenshot `shot` as a typed result: a PNG distinct per shot.
|
||||
const screenshot = {
|
||||
name: "screenshot",
|
||||
description: "Takes a screenshot",
|
||||
inputSchema: { type: "object", properties: { shot: { type: "number" } }, required: ["shot"] },
|
||||
execute: async ({ shot }) => ({
|
||||
type: "libfx.tool-result",
|
||||
text: `shot ${shot}`,
|
||||
images: [{ type: "image", data: base64Of(screenshotOf(shot)), mimeType: "image/png" }],
|
||||
}),
|
||||
};
|
||||
// Returns album page `page`: a result's 8 images, small and distinct from
|
||||
// every other page's.
|
||||
const albumImage = (index) => {
|
||||
const bytes = new Uint8Array(71);
|
||||
bytes.set(Buffer.from(pngData, "base64"));
|
||||
new DataView(bytes.buffer).setUint32(67, index);
|
||||
return bytes;
|
||||
};
|
||||
const album = {
|
||||
name: "album",
|
||||
description: "Shows an album page",
|
||||
inputSchema: { type: "object", properties: { page: { type: "number" } }, required: ["page"] },
|
||||
execute: async ({ page }) => ({
|
||||
type: "libfx.tool-result",
|
||||
text: `page ${page}`,
|
||||
images: Array.from({ length: 8 }, (_, index) => ({ type: "image", data: base64Of(albumImage(page * 8 + index)), mimeType: "image/png" })),
|
||||
}),
|
||||
};
|
||||
const options = (backend, journal, extra = {}) => ({
|
||||
backend,
|
||||
nativeAddon: addon,
|
||||
@@ -460,9 +532,10 @@ test("resume continues a crashed turn and tells the model", async () => {
|
||||
await run(agent, "first");
|
||||
await run(agent, "remember plums");
|
||||
await agent.close();
|
||||
// The process died after the response, before the turn's commit.
|
||||
const crashed = eventJournal(journal.events.slice(0, -1));
|
||||
// The process died during the model request, before its response.
|
||||
const crashed = eventJournal(crashDuringRequest(journal.events));
|
||||
assert.equal(crashed.events.at(-1).type, "turn_progress");
|
||||
assert.equal(crashed.events.at(-1).data.assistant_source, "");
|
||||
|
||||
const events = [];
|
||||
const resumed = await createFxEngine(options(targetBackend, crashed, { onEvent: (event) => events.push(event) }));
|
||||
@@ -487,6 +560,141 @@ test("resume continues a crashed turn and tells the model", async () => {
|
||||
assertContiguous(crashed.events);
|
||||
});
|
||||
|
||||
test("a crash after the final reply, before its turn ends, delivers the saved reply without asking the model again", async () => {
|
||||
const journal = eventJournal();
|
||||
const agent = await createFxEngine(options(sourceBackend, journal));
|
||||
await run(agent, "first");
|
||||
await run(agent, "remember plums");
|
||||
await agent.close();
|
||||
// The process died after the reply was stored, before the turn's end.
|
||||
const crashed = eventJournal(journal.events.slice(0, -1));
|
||||
const saved = crashed.events.at(-1);
|
||||
assert.equal(saved.type, "turn_progress");
|
||||
assert.equal(saved.data.assistant_source, "answer to remember plums");
|
||||
assert.equal(saved.data.action, "delivering_response");
|
||||
|
||||
const resumed = await createFxEngine(options(targetBackend, crashed));
|
||||
requests.length = 0;
|
||||
const turn = resumed.resume();
|
||||
let text = "";
|
||||
for await (const update of turn) if (update.type === "text_delta") text += update.delta;
|
||||
assert.equal((await turn.result).stopReason, "end_turn");
|
||||
assert.equal(resumed.resume(), null, "the turn resumes once");
|
||||
assert.equal(requests.length, 0, "the saved reply is delivered without a model request");
|
||||
// The new engine's host holds none of the turn, so the resumed turn sends
|
||||
// the saved reply as its text, with no restart notice.
|
||||
assert.equal(text, "answer to remember plums", "the resumed turn sends the saved reply as its text");
|
||||
// The next turn's history holds the delivered reply once.
|
||||
await run(resumed, "and then");
|
||||
await resumed.close();
|
||||
const replies = requests[0].filter((message) => message.role === "assistant" && textOf(message) === "answer to remember plums");
|
||||
assert.equal(replies.length, 1);
|
||||
const commits = crashed.events.filter((event) => event.type === "turn_committed");
|
||||
assert.equal(commits.length, 3);
|
||||
assert.equal(commits[1].data.kind, "assistant");
|
||||
assert.equal(commits[1].data.user.text, "remember plums");
|
||||
assertContiguous(crashed.events);
|
||||
|
||||
// A durable session's stream already holds what the turn sent, so its
|
||||
// worker resumes saying so: the turn ends with the saved reply and sends
|
||||
// nothing again.
|
||||
const again = await createFxEngine(options(targetBackend, eventJournal(journal.events.slice(0, -1))));
|
||||
requests.length = 0;
|
||||
const shown = again.resume({ [Symbol.for("libfx.resumeOutputShown")]: true });
|
||||
let shownText = "";
|
||||
for await (const update of shown) if (update.type === "text_delta") shownText += update.delta;
|
||||
assert.equal((await shown.result).stopReason, "end_turn");
|
||||
await again.close();
|
||||
assert.equal(requests.length, 0);
|
||||
assert.equal(shownText, "", "a host that holds the reply is not sent it again");
|
||||
});
|
||||
|
||||
test("a resume that restarts a reply the last process had started sends that start first", async () => {
|
||||
const journal = eventJournal();
|
||||
const agent = await createFxEngine(options(sourceBackend, journal));
|
||||
await run(agent, "remember plums");
|
||||
await agent.close();
|
||||
// The process died after the response's checkpoint, before the turn
|
||||
// accepted the response as its reply: the reply is held for a restart.
|
||||
const crashed = eventJournal(journal.events.slice(0, -2));
|
||||
const held = crashed.events.at(-1);
|
||||
assert.equal(held.type, "turn_progress");
|
||||
assert.equal(held.data.assistant_source, "answer to remember plums");
|
||||
assert.notEqual(held.data.action, "delivering_response");
|
||||
|
||||
const resumed = await createFxEngine(options(targetBackend, crashed));
|
||||
requests.length = 0;
|
||||
const turn = resumed.resume();
|
||||
let text = "";
|
||||
for await (const update of turn) if (update.type === "text_delta") text += update.delta;
|
||||
assert.equal((await turn.result).stopReason, "end_turn");
|
||||
await resumed.close();
|
||||
assert.equal(requests.length, 1, "the restarted reply asks the model again");
|
||||
// The new engine's host saw none of the turn: it gets what the last
|
||||
// process had sent of the reply, then the restart.
|
||||
assert.ok(text.startsWith("answer to remember plums"), JSON.stringify(text));
|
||||
assert.ok(text.includes("[Response interrupted. Restarting.]"), JSON.stringify(text));
|
||||
});
|
||||
|
||||
test("a turn's result waits until its end is stored", async () => {
|
||||
const journal = eventJournal();
|
||||
const store = persisted(journal);
|
||||
let holding = false;
|
||||
let endBarrier = null;
|
||||
let release;
|
||||
const held = new Promise((resolveHeld) => { release = resolveHeld; });
|
||||
const holdingEnd = {
|
||||
...store,
|
||||
async append(input) {
|
||||
if (decodeRecord(input.data).some((event) => event.type === "turn_committed")) {
|
||||
holding = true;
|
||||
endBarrier = input.barrier;
|
||||
await held;
|
||||
}
|
||||
return store.append(input);
|
||||
},
|
||||
};
|
||||
const agent = await createFxEngine(options(sourceBackend, undefined, { persistence: holdingEnd }));
|
||||
const turn = agent.prompt("hold the end");
|
||||
let drained = false;
|
||||
const updates = (async () => { for await (const _ of turn) {} drained = true; })();
|
||||
let resolved = false;
|
||||
void turn.result.then(() => { resolved = true; });
|
||||
for (let waited = 0; !holding && waited < 5_000; waited += 10) await sleepMs(10);
|
||||
assert.ok(holding, "the turn's end reached the store");
|
||||
// A store may acknowledge other records before they are durable, but not
|
||||
// a barrier, so the record that ends the turn is one.
|
||||
assert.equal(endBarrier, true, "the record holding the turn's end is a barrier");
|
||||
await sleepMs(100);
|
||||
assert.equal(resolved, false, "the result waits while the turn's end is being stored");
|
||||
assert.equal(drained, false, "the turn's updates end with its result");
|
||||
release();
|
||||
await updates;
|
||||
assert.equal((await turn.result).stopReason, "end_turn");
|
||||
assert.equal(journal.events.at(-1).type, "turn_committed");
|
||||
await agent.close();
|
||||
});
|
||||
|
||||
test("a turn whose end fails to store rejects its result", async () => {
|
||||
const journal = eventJournal();
|
||||
const store = persisted(journal);
|
||||
const failingEnd = {
|
||||
...store,
|
||||
async append(input) {
|
||||
if (decodeRecord(input.data).some((event) => event.type === "turn_committed")) throw new Error("disk full");
|
||||
return store.append(input);
|
||||
},
|
||||
};
|
||||
const agent = await createFxEngine(options(sourceBackend, undefined, { persistence: failingEnd }));
|
||||
const turn = agent.prompt("lose the end");
|
||||
const failed = (error) => error.code === "FX_JOURNAL_APPEND_FAILED" && error.cause.message === "disk full";
|
||||
// The reply streamed, but the turn never counted as done: its updates and
|
||||
// its result end with the store's error.
|
||||
await assert.rejects((async () => { for await (const _ of turn) {} })(), failed);
|
||||
await assert.rejects(turn.result, failed);
|
||||
await agent.close().catch(() => {});
|
||||
});
|
||||
|
||||
test("a resumed turn answers a call a crash left running instead of running it again", async () => {
|
||||
const journal = eventJournal();
|
||||
let sends = 0;
|
||||
@@ -663,7 +871,7 @@ test("a journal that does not fold is refused", async () => {
|
||||
const invalid = (pattern) => (error) => pattern.test(error.message) && error.code === "FX_JOURNAL_INVALID";
|
||||
const duplicated = eventJournal([...journal.events, journal.events.at(-1)]);
|
||||
await assert.rejects(createFxEngine(options(targetBackend, duplicated)), invalid(/libfx journal events are out of order/));
|
||||
const newer = eventJournal([{ ...journal.events[0], v: 3 }]);
|
||||
const newer = eventJournal([{ ...journal.events[0], v: 4 }]);
|
||||
await assert.rejects(createFxEngine(options(targetBackend, newer)), /libfx journal was written by a newer fx/);
|
||||
const garbled = eventJournal([{ v: 1, seq: 1, turn: 1, type: "turn_committed", data: { kind: "nope" } }]);
|
||||
await assert.rejects(createFxEngine(options(targetBackend, garbled)), invalid(/Invalid libfx journal/));
|
||||
@@ -1039,7 +1247,7 @@ test("a turn left open resumes under the tools the new agent has", async () => {
|
||||
});
|
||||
|
||||
test("a journal from a newer libfx is refused", async () => {
|
||||
const future = eventJournal([{ v: 3, seq: 1, turn: 1, type: "turn_progress", data: {} }]);
|
||||
const future = eventJournal([{ v: 4, seq: 1, turn: 1, type: "turn_progress", data: {} }]);
|
||||
const newer = (error) => error instanceof FxJournalVersionError && error.code === "FX_JOURNAL_VERSION";
|
||||
await assert.rejects(createFxEngine(options(sourceBackend, future)), newer);
|
||||
const record = (format) => ({
|
||||
@@ -1312,6 +1520,489 @@ test("an image prompt after a crashed image turn gets the next image id", async
|
||||
assert.match(lastUserText(requests.at(-1)), /\[Image #2\]$/);
|
||||
});
|
||||
|
||||
// Images for the image store's tests: a PNG header, then filler that differs
|
||||
// per salt, so each image has its own name.
|
||||
const visionCatalog = { modelCatalog: [{ id: "journal/model", type: "language", tags: ["tool-use", "vision", "file-input"] }] };
|
||||
const pngOf = (size, salt) => {
|
||||
const bytes = new Uint8Array(size);
|
||||
bytes.set(Buffer.from(pngData, "base64"));
|
||||
for (let index = 67; index < size; index += 1) bytes[index] = (index * 31 + salt) & 255;
|
||||
return bytes;
|
||||
};
|
||||
const base64Of = (bytes) => Buffer.from(bytes).toString("base64");
|
||||
const imageBlock = (bytes) => ({ type: "image", data: bytes, mimeType: "image/png" });
|
||||
const nameOf = async (bytes) => Buffer.from(await crypto.subtle.digest("SHA-256", bytes)).toString("hex");
|
||||
// Whether saved bytes hold any of an image, raw or as base64.
|
||||
const holdsImage = (data, bytes) => Buffer.from(data).includes(Buffer.from(bytes.subarray(200, 264))) ||
|
||||
Buffer.from(data).includes(Buffer.from(base64Of(bytes).slice(400, 480)));
|
||||
const savedData = (store) => [...store.records.map((record) => record.data), ...(store.checkpoint ? [store.checkpoint.data] : [])];
|
||||
const requestImages = (prompt) => prompt
|
||||
.flatMap((message) => Array.isArray(message.content) ? message.content : [])
|
||||
.filter((part) => part.type === "file")
|
||||
.map((part) => part.data?.data ?? part.data);
|
||||
// libfx's own key for a persistence's image store, which durable sessions
|
||||
// give the engines they run.
|
||||
const persistenceImages = Symbol.for("libfx.persistenceImages");
|
||||
// `persistence` with an image store that keeps each image once, by name, in
|
||||
// `images`, as a durable session gives its engines.
|
||||
const withImageStore = (persistence, images = new Map()) => Object.assign(persistence, {
|
||||
images,
|
||||
[persistenceImages]: {
|
||||
put: async (name, bytes) => { if (!images.has(name)) images.set(name, bytes.slice()); },
|
||||
get: async (name) => images.get(name)?.slice(),
|
||||
},
|
||||
});
|
||||
const createImagePersistence = () => withImageStore(createMemoryPersistence());
|
||||
|
||||
test("a store keeps each prompt image once, and records and checkpoints only name it", async () => {
|
||||
const store = createImagePersistence();
|
||||
const first = pngOf(300_000, 1);
|
||||
const second = pngOf(200_000, 2);
|
||||
const source = await createFxEngine(options(sourceBackend, undefined, { ...visionCatalog, persistence: store, checkpointAfterBytes: 0 }));
|
||||
await run(source, [{ type: "text", text: "one" }, imageBlock(first)]);
|
||||
await run(source, [{ type: "text", text: "two" }, imageBlock(second), imageBlock(first)]);
|
||||
// A turn without images: its events name none.
|
||||
await run(source, "no image here");
|
||||
await source.close();
|
||||
assert.equal(store.images.size, 2);
|
||||
assert.deepEqual([...store.images.keys()].sort(), [await nameOf(first), await nameOf(second)].sort());
|
||||
assert.ok(store.checkpoint, "a checkpoint was saved");
|
||||
for (const data of savedData(store)) {
|
||||
assert.ok(!holdsImage(data, first) && !holdsImage(data, second), "saved data holds no image bytes");
|
||||
}
|
||||
// Events that name an image are version 3. A progress that extends another
|
||||
// (`base`) is version 2 and leaves out the user's turn, so it names none;
|
||||
// every other event stays version 1.
|
||||
const versions = storedEvents(store).map((event) => [JSON.stringify(event).includes("inline_ref"), event.v, event.base !== undefined || event.type === "tool_result"]);
|
||||
assert.ok(versions.some(([named]) => named), "some events name images");
|
||||
for (const [named, version, delta] of versions) {
|
||||
assert.ok(!(named && delta), "a delta names no image");
|
||||
assert.equal(version, named ? 3 : delta ? 2 : 1);
|
||||
}
|
||||
assert.ok(versions.some(([named]) => !named), "the text-only turn's events name none");
|
||||
|
||||
const target = await createFxEngine(options(targetBackend, undefined, { ...visionCatalog, persistence: store }));
|
||||
requests.length = 0;
|
||||
const { result } = await run(target, "four");
|
||||
await target.close();
|
||||
assert.equal(result.stopReason, "end_turn");
|
||||
assert.deepEqual(requestImages(requests.at(-1)), [base64Of(first), base64Of(second), base64Of(first)]);
|
||||
});
|
||||
|
||||
test("a session whose store lost an image is refused, naming it", async () => {
|
||||
const store = createImagePersistence();
|
||||
const kept = pngOf(50_000, 4);
|
||||
const lost = pngOf(60_000, 5);
|
||||
const source = await createFxEngine(options(sourceBackend, undefined, { ...visionCatalog, persistence: store, checkpointAfterBytes: 0 }));
|
||||
await run(source, [{ type: "text", text: "one" }, imageBlock(kept), imageBlock(lost)]);
|
||||
await source.close();
|
||||
const name = await nameOf(lost);
|
||||
store.images.delete(name);
|
||||
await assert.rejects(
|
||||
createFxEngine(options(targetBackend, undefined, { ...visionCatalog, persistence: store })),
|
||||
(error) => error.code === "FX_JOURNAL_IMAGE_MISSING" && error.image === name && error.message.includes(name),
|
||||
);
|
||||
});
|
||||
|
||||
test("a persistence without an image store keeps writing images inline", async () => {
|
||||
const image = pngOf(20_000, 6);
|
||||
const journal = eventJournal();
|
||||
const agent = await createFxEngine(options(sourceBackend, journal, visionCatalog));
|
||||
await run(agent, [{ type: "text", text: "inline" }, imageBlock(image)]);
|
||||
await agent.close();
|
||||
// No event is the version that names stored images; a progress that
|
||||
// extends another is version 2, every other event version 1.
|
||||
for (const event of journal.events) assert.equal(event.v, event.base !== undefined || event.type === "tool_result" ? 2 : 1);
|
||||
assert.ok(JSON.stringify(journal.events).includes(base64Of(image)));
|
||||
assert.ok(!JSON.stringify(journal.events).includes("inline_ref"));
|
||||
});
|
||||
|
||||
test("an image turn that crashed after a delta reopens with its image", async () => {
|
||||
// The turn's first progress names the image. The one after the tool step
|
||||
// extends it and leaves out the user's turn, so a crash right after that
|
||||
// one brings the image back from the first.
|
||||
const image = pngOf(50_000, 8);
|
||||
const images = new Map();
|
||||
const journal = eventJournal();
|
||||
const source = await createFxEngine(options(sourceBackend, undefined, { ...visionCatalog, persistence: withImageStore(persisted(journal), images) }));
|
||||
assert.equal((await run(source, [{ type: "text", text: "use the tool" }, imageBlock(image)])).text, "tool said beta");
|
||||
await source.close();
|
||||
const progress = journal.events.filter((event) => event.type === "turn_progress");
|
||||
assert.equal(progress[0].v, 3, "the first progress names the image");
|
||||
const stepped = progress.find((event) => event.steps === 1);
|
||||
assert.ok(stepped?.base !== undefined && stepped.v === 2, "the progress after the tool extends it");
|
||||
assert.ok(!JSON.stringify(stepped).includes("inline_ref"), "and names no image");
|
||||
const survived = journal.events.slice(0, journal.events.indexOf(stepped) + 1);
|
||||
const resumed = await createFxEngine(options(targetBackend, undefined, { ...visionCatalog, persistence: withImageStore(persisted(eventJournal(survived)), images) }));
|
||||
requests.length = 0;
|
||||
const turn = resumed.resume();
|
||||
for await (const _ of turn) {}
|
||||
assert.equal((await turn.result).stopReason, "end_turn");
|
||||
await resumed.close();
|
||||
assert.deepEqual(requestImages(requests[0]), [base64Of(image)], "the image came back from the first progress");
|
||||
assert.equal(toolResults(requests[0]).length, 1, "the step came back from the delta");
|
||||
});
|
||||
|
||||
// A smaller image window than the core's, so a test reaches its edge.
|
||||
const imageWindow = Symbol.for("libfx.imageWindow");
|
||||
const droppedNote = /\[Image #1 is no longer in this session, which keeps only its newest images\.\]/;
|
||||
|
||||
for (const [label, limits] of [["images", { images: 2 }], ["bytes", { bytes: 2 * 40_000 }]]) {
|
||||
test(`a session keeps its newest images within its window of ${label}, and an older one leaves as a note`, async () => {
|
||||
const store = createImagePersistence();
|
||||
const pictures = [pngOf(40_000, 11), pngOf(40_000, 12), pngOf(40_000, 13)];
|
||||
const window = { [imageWindow]: limits };
|
||||
const source = await createFxEngine(options(sourceBackend, undefined, { ...visionCatalog, persistence: store, checkpointAfterBytes: 0, ...window }));
|
||||
for (const [index, picture] of pictures.entries()) {
|
||||
await run(source, [{ type: "text", text: `look ${index}` }, imageBlock(picture)]);
|
||||
}
|
||||
// The third turn's request still carries every image; once it entered the
|
||||
// history, its image pushed the first out, and the next request carries
|
||||
// the two newest and a note where the first was.
|
||||
assert.deepEqual(requestImages(requests.at(-1)), pictures.map(base64Of));
|
||||
await run(source, "and then?");
|
||||
assert.deepEqual(requestImages(requests.at(-1)), [base64Of(pictures[1]), base64Of(pictures[2])]);
|
||||
assert.match(JSON.stringify(requests.at(-1)), droppedNote);
|
||||
await source.close();
|
||||
assert.ok(savedData(store).some((data) => Buffer.from(data).includes("\"dropped\":true")), "the checkpoint keeps the first image's name alone");
|
||||
|
||||
// A store that lost the first image still opens the session, which reads
|
||||
// only the images its window keeps.
|
||||
store.images.delete(await nameOf(pictures[0]));
|
||||
const read = [];
|
||||
const { get } = store[persistenceImages];
|
||||
store[persistenceImages].get = async (name) => {
|
||||
read.push(name);
|
||||
return get(name);
|
||||
};
|
||||
const target = await createFxEngine(options(targetBackend, undefined, { ...visionCatalog, persistence: store, ...window }));
|
||||
await run(target, "and now?");
|
||||
await target.close();
|
||||
assert.deepEqual(read.sort(), [await nameOf(pictures[1]), await nameOf(pictures[2])].sort());
|
||||
assert.deepEqual(requestImages(requests.at(-1)), [base64Of(pictures[1]), base64Of(pictures[2])]);
|
||||
assert.match(JSON.stringify(requests.at(-1)), droppedNote);
|
||||
});
|
||||
}
|
||||
|
||||
test("a turn that fails before it enters the history drops no image", async () => {
|
||||
const store = createImagePersistence();
|
||||
const pictures = [pngOf(40_000, 21), pngOf(40_000, 22), pngOf(40_000, 23)];
|
||||
const agent = await createFxEngine(options(sourceBackend, undefined, { ...visionCatalog, persistence: store, [imageWindow]: { images: 2 } }));
|
||||
await run(agent, [{ type: "text", text: "look 0" }, imageBlock(pictures[0])]);
|
||||
await run(agent, [{ type: "text", text: "look 1" }, imageBlock(pictures[1])]);
|
||||
const failed = await run(agent, [{ type: "text", text: "fail please" }, imageBlock(pictures[2])]);
|
||||
assert.notEqual(failed.result.stopReason, "end_turn");
|
||||
// The failed turn is not in the history, so the window still holds both.
|
||||
await run(agent, "and then?");
|
||||
assert.deepEqual(requestImages(requests.at(-1)), [base64Of(pictures[0]), base64Of(pictures[1])]);
|
||||
assert.doesNotMatch(JSON.stringify(requests.at(-1)), /no longer in this session/);
|
||||
await agent.close();
|
||||
});
|
||||
|
||||
test("a turn that crashed with a full image window reopens with all of its images", async () => {
|
||||
// An open stages the whole window and the open turn's images, which the
|
||||
// window leaves out: one prompt's, up to 8 of them.
|
||||
const tiny = (n) => {
|
||||
const bytes = pngOf(80, 0);
|
||||
new DataView(bytes.buffer).setUint32(70, n);
|
||||
return bytes;
|
||||
};
|
||||
let made = 0;
|
||||
const prompt = (text) => [{ type: "text", text }, ...Array.from({ length: 8 }, () => imageBlock(tiny(made++)))];
|
||||
const images = new Map();
|
||||
const journal = eventJournal();
|
||||
const source = await createFxEngine(options(sourceBackend, undefined, { ...visionCatalog, persistence: withImageStore(persisted(journal), images) }));
|
||||
for (let turn = 0; turn < 4096 / 8; turn += 1) await run(source, prompt(`look ${turn}`));
|
||||
const open = prompt("use the tool");
|
||||
assert.equal((await run(source, open)).text, "tool said beta");
|
||||
await source.close();
|
||||
assert.equal(images.size, 4096 + 8);
|
||||
// A crash after the last turn's tool step leaves it open, with the window
|
||||
// full of the turns before it.
|
||||
// The last progress before the saved final reply, which a resume would
|
||||
// deliver without asking the model.
|
||||
const stepped = journal.events.findLast((event) => event.type === "turn_progress" && event.steps === 1 && event.data.action !== "delivering_response");
|
||||
const survived = journal.events.slice(0, journal.events.indexOf(stepped) + 1);
|
||||
const read = new Set();
|
||||
const reading = withImageStore(persisted(eventJournal(survived)), images);
|
||||
const { get } = reading[persistenceImages];
|
||||
reading[persistenceImages].get = async (name) => { read.add(name); return get(name); };
|
||||
const resumed = await createFxEngine(options(targetBackend, undefined, { ...visionCatalog, persistence: reading }));
|
||||
requests.length = 0;
|
||||
const turn = resumed.resume();
|
||||
for await (const _ of turn) {}
|
||||
assert.equal((await turn.result).stopReason, "end_turn");
|
||||
await resumed.close();
|
||||
assert.equal(read.size, 4096 + 8, "the open read the window and the open turn's images");
|
||||
const sent = requestImages(requests[0]);
|
||||
for (const block of open.slice(1)) assert.ok(sent.includes(base64Of(block.data)), "the open turn kept its images");
|
||||
});
|
||||
|
||||
test("a failed image write fails the prompt before it is sent", async () => {
|
||||
const store = createImagePersistence();
|
||||
store[persistenceImages].put = async () => { throw new Error("disk full"); };
|
||||
const agent = await createFxEngine(options(sourceBackend, undefined, { ...visionCatalog, persistence: store }));
|
||||
requests.length = 0;
|
||||
const turn = agent.prompt([{ type: "text", text: "look" }, imageBlock(pngOf(1_000, 7))]);
|
||||
// The store's own error.
|
||||
await assert.rejects(turn.result, (error) => error.message === "disk full");
|
||||
assert.equal(requests.length, 0, "the prompt never reached the model");
|
||||
assert.equal(storedEvents(store).length, 0, "nothing was recorded");
|
||||
const { result } = await run(agent, "text only");
|
||||
assert.equal(result.stopReason, "end_turn");
|
||||
await agent.close();
|
||||
});
|
||||
|
||||
test("a session saved inline opens behind an image store, and only new images are stored", async () => {
|
||||
const old = pngOf(40_000, 8);
|
||||
const fresh = pngOf(30_000, 9);
|
||||
const journal = eventJournal();
|
||||
const before = await createFxEngine(options(sourceBackend, journal, visionCatalog));
|
||||
await run(before, [{ type: "text", text: "old" }, imageBlock(old)]);
|
||||
await before.close();
|
||||
|
||||
const images = new Map();
|
||||
const upgraded = withImageStore(persisted(journal), images);
|
||||
const after = await createFxEngine(options(targetBackend, undefined, { ...visionCatalog, persistence: upgraded }));
|
||||
requests.length = 0;
|
||||
await run(after, [{ type: "text", text: "new" }, imageBlock(fresh)]);
|
||||
await after.close();
|
||||
assert.deepEqual(requestImages(requests.at(-1)), [base64Of(old), base64Of(fresh)]);
|
||||
assert.deepEqual([...images.keys()], [await nameOf(fresh)]);
|
||||
|
||||
const again = await createFxEngine(options(sourceBackend, undefined, { ...visionCatalog, persistence: upgraded }));
|
||||
requests.length = 0;
|
||||
await run(again, "again");
|
||||
await again.close();
|
||||
assert.deepEqual(requestImages(requests.at(-1)), [base64Of(old), base64Of(fresh)]);
|
||||
});
|
||||
|
||||
test("images too large for a checkpoint are named instead", async () => {
|
||||
// Two 2.1 MiB images: more than one checkpoint holds raw.
|
||||
const images = [pngOf(2_200_000, 10), pngOf(2_200_000, 11)];
|
||||
const prompt = [{ type: "text", text: "large" }, ...images.map(imageBlock)];
|
||||
|
||||
// Without an image store, the core refuses the checkpoint.
|
||||
const inline = createMemoryPersistence();
|
||||
const without = await createFxEngine(options(sourceBackend, undefined, { ...visionCatalog, persistence: inline, checkpointAfterBytes: 0 }));
|
||||
await run(without, prompt);
|
||||
await without.close();
|
||||
assert.equal(inline.checkpoint, null);
|
||||
|
||||
const store = createImagePersistence();
|
||||
const saved = [];
|
||||
const named = await createFxEngine(options(sourceBackend, undefined, {
|
||||
...visionCatalog, persistence: store, checkpointAfterBytes: 0,
|
||||
onEvent: (event) => { if (event.type === "checkpoint.save") saved.push(event.type); },
|
||||
}));
|
||||
await run(named, prompt);
|
||||
await named.close();
|
||||
assert.deepEqual(saved, ["checkpoint.save"]);
|
||||
assert.ok(store.checkpoint.data.byteLength < 64 * 1024, `checkpoint is ${store.checkpoint.data.byteLength} bytes`);
|
||||
|
||||
const reopened = await createFxEngine(options(targetBackend, undefined, { ...visionCatalog, persistence: store }));
|
||||
requests.length = 0;
|
||||
const { result } = await run(reopened, "and now");
|
||||
await reopened.close();
|
||||
assert.equal(result.stopReason, "end_turn");
|
||||
assert.deepEqual(requestImages(requests.at(-1)), images.map(base64Of));
|
||||
});
|
||||
|
||||
test("a session holds more images than a checkpoint could, turn after turn", async () => {
|
||||
const store = createImagePersistence();
|
||||
const images = Array.from({ length: 5 }, (_, index) => pngOf(1024 * 1024, 20 + index));
|
||||
let saves = 0;
|
||||
const countSaves = { onEvent: (event) => { if (event.type === "checkpoint.save") saves += 1; } };
|
||||
for (const [index, image] of images.entries()) {
|
||||
// Each turn reopens the session, as a new process would.
|
||||
const agent = await createFxEngine(options(index % 2 ? targetBackend : sourceBackend, undefined, { ...visionCatalog, ...countSaves, persistence: store, checkpointAfterBytes: 0 }));
|
||||
const { result } = await run(agent, [{ type: "text", text: `turn ${index}` }, imageBlock(image)]);
|
||||
await agent.close();
|
||||
assert.equal(result.stopReason, "end_turn");
|
||||
}
|
||||
assert.equal(saves, images.length, "every turn saved a checkpoint");
|
||||
const last = await createFxEngine(options(targetBackend, undefined, { ...visionCatalog, persistence: store }));
|
||||
requests.length = 0;
|
||||
await run(last, "all of them");
|
||||
await last.close();
|
||||
assert.deepEqual(requestImages(requests.at(-1)), images.map(base64Of));
|
||||
});
|
||||
|
||||
const screenshotOf = (shot) => pngOf(300_000, 100 + shot);
|
||||
const withScreenshots = { ...visionCatalog, tools: [lookup, screenshot] };
|
||||
const toolDroppedNote = /\[1 image from this result is no longer in this session, which keeps only its newest images\.\]/;
|
||||
|
||||
test("a store keeps each tool image once, and records and checkpoints only name it", async () => {
|
||||
const store = createImagePersistence();
|
||||
const source = await createFxEngine(options(sourceBackend, undefined, { ...withScreenshots, persistence: store, checkpointAfterBytes: 0 }));
|
||||
for (const shot of [1, 2, 3]) assert.equal((await run(source, `screenshot ${shot}`)).text, `saw ${shot}`);
|
||||
await source.close();
|
||||
const shots = [1, 2, 3].map(screenshotOf);
|
||||
assert.deepEqual([...store.images.keys()].sort(), (await Promise.all(shots.map(nameOf))).sort());
|
||||
assert.ok(store.checkpoint, "a checkpoint was saved");
|
||||
for (const data of savedData(store)) assert.ok(shots.every((bytes) => !holdsImage(data, bytes)), "saved data holds no tool image bytes");
|
||||
// An event that names an image is version 3, a progress that extends
|
||||
// another and names a new tool image too.
|
||||
const events = storedEvents(store);
|
||||
assert.ok(events.some((event) => event.base !== undefined && JSON.stringify(event).includes("inline_ref")), "a delta names its tool image");
|
||||
for (const event of events) {
|
||||
const named = JSON.stringify(event).includes("inline_ref");
|
||||
assert.equal(event.v, named ? 3 : event.base !== undefined || event.type === "tool_result" ? 2 : 1);
|
||||
}
|
||||
|
||||
const target = await createFxEngine(options(targetBackend, undefined, { ...withScreenshots, persistence: store }));
|
||||
requests.length = 0;
|
||||
const { result } = await run(target, "after the shots");
|
||||
await target.close();
|
||||
assert.equal(result.stopReason, "end_turn");
|
||||
assert.deepEqual(requestImages(requests.at(-1)), shots.map(base64Of));
|
||||
});
|
||||
|
||||
test("a tool image leaves the window oldest first, as a note, and stays out after a reopen", async () => {
|
||||
const store = createImagePersistence();
|
||||
const window = { [imageWindow]: { images: 2 } };
|
||||
const shots = [1, 2, 3].map(screenshotOf);
|
||||
const source = await createFxEngine(options(sourceBackend, undefined, { ...withScreenshots, ...window, persistence: store, checkpointAfterBytes: 0 }));
|
||||
for (const shot of [1, 2, 3]) await run(source, `screenshot ${shot}`);
|
||||
requests.length = 0;
|
||||
await run(source, "what is left");
|
||||
await source.close();
|
||||
assert.deepEqual(requestImages(requests.at(-1)), [shots[1], shots[2]].map(base64Of));
|
||||
assert.match(JSON.stringify(requests.at(-1)), toolDroppedNote);
|
||||
assert.ok(savedData(store).some((data) => Buffer.from(data).includes("\"dropped\":true")), "the first shot keeps its name alone");
|
||||
|
||||
const target = await createFxEngine(options(targetBackend, undefined, { ...withScreenshots, ...window, persistence: store }));
|
||||
requests.length = 0;
|
||||
await run(target, "and after reopening");
|
||||
await target.close();
|
||||
assert.deepEqual(requestImages(requests.at(-1)), [shots[1], shots[2]].map(base64Of));
|
||||
assert.match(JSON.stringify(requests.at(-1)), toolDroppedNote);
|
||||
});
|
||||
|
||||
test("a tool image the store refuses stays in the session inline", async () => {
|
||||
const store = createImagePersistence();
|
||||
store[persistenceImages].put = async () => { throw new Error("store is full"); };
|
||||
const agent = await createFxEngine(options(sourceBackend, undefined, { ...withScreenshots, persistence: store }));
|
||||
requests.length = 0;
|
||||
const { text, result } = await run(agent, "screenshot 4");
|
||||
await agent.close();
|
||||
assert.equal(result.stopReason, "end_turn");
|
||||
assert.equal(text, "saw 4");
|
||||
// The tool ran, so its image is not lost: the model saw it, and the
|
||||
// records hold it inline.
|
||||
assert.deepEqual(requestImages(requests.at(-1)), [base64Of(screenshotOf(4))]);
|
||||
assert.ok(savedData(store).some((data) => holdsImage(data, screenshotOf(4))), "the records hold the image inline");
|
||||
assert.equal(store.images.size, 0);
|
||||
|
||||
const reopened = await createFxEngine(options(targetBackend, undefined, { ...withScreenshots, persistence: store }));
|
||||
requests.length = 0;
|
||||
await run(reopened, "after the refusal");
|
||||
await reopened.close();
|
||||
assert.deepEqual(requestImages(requests.at(-1)), [base64Of(screenshotOf(4))]);
|
||||
});
|
||||
|
||||
test("a turn that crashed after its screenshot's progress reopens with the screenshot", async () => {
|
||||
const journal = eventJournal();
|
||||
const images = new Map();
|
||||
const source = await createFxEngine(options(sourceBackend, undefined, { ...withScreenshots, persistence: withImageStore(persisted(journal), images) }));
|
||||
assert.equal((await run(source, "screenshot 5")).text, "saw 5");
|
||||
await source.close();
|
||||
// The progress after the tool step extends the first, and names the shot.
|
||||
const stepped = journal.events.find((event) => event.type === "turn_progress" && event.steps === 1);
|
||||
assert.ok(stepped?.base !== undefined && stepped.v === 3 && JSON.stringify(stepped).includes("inline_ref"), "the delta names the screenshot");
|
||||
assert.ok(!JSON.stringify(stepped).includes(base64Of(screenshotOf(5)).slice(0, 400)), "and does not hold it");
|
||||
const survived = journal.events.slice(0, journal.events.indexOf(stepped) + 1);
|
||||
const resumed = await createFxEngine(options(targetBackend, undefined, { ...withScreenshots, persistence: withImageStore(persisted(eventJournal(survived)), images) }));
|
||||
requests.length = 0;
|
||||
const turn = resumed.resume();
|
||||
for await (const _ of turn) {}
|
||||
assert.equal((await turn.result).stopReason, "end_turn");
|
||||
await resumed.close();
|
||||
assert.deepEqual(requestImages(requests[0]), [base64Of(screenshotOf(5))], "the screenshot came back from the store");
|
||||
});
|
||||
|
||||
test("a turn that crashed right after its screenshot's result reopens with the screenshot, without running the tool again", async () => {
|
||||
const journal = eventJournal();
|
||||
const images = new Map();
|
||||
let shots = 0;
|
||||
const counting = { ...screenshot, execute: async (input) => { shots += 1; return screenshot.execute(input); } };
|
||||
const withCounting = { ...visionCatalog, tools: [lookup, counting] };
|
||||
const source = await createFxEngine(options(sourceBackend, undefined, { ...withCounting, persistence: withImageStore(persisted(journal), images) }));
|
||||
assert.equal((await run(source, "screenshot 9")).text, "saw 9");
|
||||
await source.close();
|
||||
// The result names the screenshot as the call finished, before any
|
||||
// progress holds the step.
|
||||
const result = journal.events.find((event) => event.type === "tool_result");
|
||||
assert.ok(result?.v === 3 && result.data.images?.[0]?.inline_ref === await nameOf(screenshotOf(9)), "the result names the screenshot");
|
||||
assert.ok(!JSON.stringify(result).includes(base64Of(screenshotOf(9)).slice(0, 400)), "and does not hold it");
|
||||
const survived = journal.events.slice(0, journal.events.indexOf(result) + 1);
|
||||
assert.ok(!survived.some((event) => event.type === "turn_progress" && event.steps === 1), "no progress holds the step yet");
|
||||
const resumed = await createFxEngine(options(targetBackend, undefined, { ...withCounting, persistence: withImageStore(persisted(eventJournal(survived)), images) }));
|
||||
requests.length = 0;
|
||||
const turn = resumed.resume();
|
||||
for await (const _ of turn) {}
|
||||
assert.equal((await turn.result).stopReason, "end_turn");
|
||||
await resumed.close();
|
||||
assert.equal(shots, 1, "the tool ran once");
|
||||
assert.deepEqual(requestImages(requests[0]), [base64Of(screenshotOf(9))], "the screenshot came back from the store");
|
||||
});
|
||||
|
||||
test("a tool image the store refused leaves the window like any other, as a note", async () => {
|
||||
const store = createImagePersistence();
|
||||
const refusedName = await nameOf(screenshotOf(6));
|
||||
const { put } = store[persistenceImages];
|
||||
store[persistenceImages].put = async (name, bytes) => {
|
||||
if (name === refusedName) throw new Error("store is full");
|
||||
return put(name, bytes);
|
||||
};
|
||||
const window = { [imageWindow]: { images: 2 } };
|
||||
const source = await createFxEngine(options(sourceBackend, undefined, { ...withScreenshots, ...window, persistence: store, checkpointAfterBytes: 0 }));
|
||||
for (const shot of [6, 7, 8]) assert.equal((await run(source, `screenshot ${shot}`)).text, `saw ${shot}`);
|
||||
requests.length = 0;
|
||||
await run(source, "what is left");
|
||||
await source.close();
|
||||
// The refused shot, held inline, is the oldest image: it leaves first.
|
||||
assert.deepEqual(requestImages(requests.at(-1)), [7, 8].map((shot) => base64Of(screenshotOf(shot))));
|
||||
assert.match(JSON.stringify(requests.at(-1)), toolDroppedNote);
|
||||
assert.ok(!holdsImage(store.checkpoint.data, screenshotOf(6)), "the checkpoint no longer holds it");
|
||||
|
||||
const target = await createFxEngine(options(targetBackend, undefined, { ...withScreenshots, ...window, persistence: store }));
|
||||
requests.length = 0;
|
||||
await run(target, "and after reopening");
|
||||
await target.close();
|
||||
assert.deepEqual(requestImages(requests.at(-1)), [7, 8].map((shot) => base64Of(screenshotOf(shot))));
|
||||
assert.match(JSON.stringify(requests.at(-1)), toolDroppedNote);
|
||||
});
|
||||
|
||||
test("a turn whose tools returned more images than a window and a prompt hold reopens with all of them", async () => {
|
||||
// 514 pages of 8: 4,112 images, more than the 4,096 a session keeps and
|
||||
// the 8 of one prompt, all an open stages unless it reports more missing.
|
||||
const pages = 514;
|
||||
const journal = eventJournal();
|
||||
const images = new Map();
|
||||
let staged = 0;
|
||||
const withImages = (persistence) => {
|
||||
const counted = withImageStore(persistence, images);
|
||||
const { get } = counted[persistenceImages];
|
||||
counted[persistenceImages].get = async (name) => { staged += 1; return get(name); };
|
||||
return counted;
|
||||
};
|
||||
const withAlbum = { ...visionCatalog, tools: [album] };
|
||||
const source = await createFxEngine(options(sourceBackend, undefined, { ...withAlbum, persistence: withImages(persisted(journal)) }));
|
||||
assert.equal((await run(source, `album ${pages}`)).text, "saw the album");
|
||||
await source.close();
|
||||
assert.equal(images.size, pages * 8);
|
||||
const stepped = journal.events.find((event) => event.type === "turn_progress" && event.steps === 1);
|
||||
assert.ok(stepped, "a progress holds the album's step");
|
||||
const survived = journal.events.slice(0, journal.events.indexOf(stepped) + 1);
|
||||
const resumed = await createFxEngine(options(targetBackend, undefined, { ...withAlbum, persistence: withImages(persisted(eventJournal(survived))) }));
|
||||
assert.equal(staged, pages * 8, "the open staged every image of the open turn");
|
||||
const turn = resumed.resume();
|
||||
for await (const _ of turn) {}
|
||||
assert.equal((await turn.result).stopReason, "end_turn");
|
||||
await resumed.close();
|
||||
});
|
||||
|
||||
test("a journal past the turn limit is refused by name", async () => {
|
||||
const journal = eventJournal();
|
||||
const agent = await createFxEngine(options(sourceBackend, journal));
|
||||
@@ -1418,8 +2109,8 @@ test("a retried prompt with the same turn id continues its open turn instead of
|
||||
await turn.result;
|
||||
await agent.close();
|
||||
assert.ok(journal.events.some((event) => event.type === "turn_progress" && event.turnId === "turn-7"), "the turn's first progress records its id");
|
||||
// The crash: the turn's commit was lost.
|
||||
const crashed = eventJournal(journal.events.slice(0, -1));
|
||||
// The crash: the process died during the turn's model request.
|
||||
const crashed = eventJournal(crashDuringRequest(journal.events));
|
||||
const retried = await createFxEngine(options(targetBackend, crashed));
|
||||
requests.length = 0;
|
||||
const again = retried.prompt("remember plums", { turnId: "turn-7" });
|
||||
@@ -1477,6 +2168,44 @@ test("a tool receives the model's call id as its execution id, and its session's
|
||||
await agent.close();
|
||||
assert.deepEqual(seen, [{ keys: ["executionId", "sessionId", "signal"], executionId: "call-1", sessionId, signal: true }]);
|
||||
});
|
||||
|
||||
test("a yielded turn resumed in place after its checkpoint reopens from any cut", async () => {
|
||||
// The store keeps a copy of itself after each append, as a crash there
|
||||
// would leave it.
|
||||
const store = createMemoryPersistence();
|
||||
const states = [];
|
||||
const append = store.append.bind(store);
|
||||
store.append = async (input) => {
|
||||
const out = await append(input);
|
||||
states.push({
|
||||
records: store.records.map((record) => ({ ...record, data: record.data.slice() })),
|
||||
checkpoint: store.checkpoint && { through: store.checkpoint.through, data: store.checkpoint.data.slice() },
|
||||
});
|
||||
return out;
|
||||
};
|
||||
const agent = await createFxEngine(options(sourceBackend, undefined, { persistence: store, checkpointAfterBytes: 0 }));
|
||||
const first = agent.prompt("use the tool", { yieldAt: Date.now() });
|
||||
for await (const _ of first) {}
|
||||
assert.equal((await first.result).stopReason, "yielded");
|
||||
for (let wait = 0; wait < 100 && !store.checkpoint; wait += 1) await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.ok(store.checkpoint, "a checkpoint holding the open turn was saved at the yield");
|
||||
const before = states.length;
|
||||
const turn = agent.resume();
|
||||
for await (const _ of turn) {}
|
||||
assert.equal((await turn.result).stopReason, "end_turn");
|
||||
await agent.close();
|
||||
const cuts = states.slice(before).filter((state) => state.checkpoint);
|
||||
assert.ok(cuts.length > 0, "the resumed turn appended after the checkpoint");
|
||||
for (const [index, state] of cuts.entries()) {
|
||||
const copy = createMemoryPersistence();
|
||||
copy.records.push(...state.records);
|
||||
await copy.saveCheckpoint(state.checkpoint);
|
||||
const reopened = await createFxEngine(options(targetBackend, undefined, { persistence: copy })).catch((error) => {
|
||||
throw new Error(`the cut after resumed append ${index + 1} (checkpoint through ${state.checkpoint.through}, ${state.records.length} records) did not reopen: ${error.code}`);
|
||||
});
|
||||
await reopened.close();
|
||||
}
|
||||
});
|
||||
try {
|
||||
for (const { name, body } of cases) {
|
||||
await body();
|
||||
|
||||
@@ -51,6 +51,18 @@ try {
|
||||
{ type: "tool-call", toolCallId: "images1", toolName: "screenshots", input: {} },
|
||||
{ type: "finish", finishReason: { unified: "tool-calls", raw: "tool-calls" } },
|
||||
);
|
||||
if (requests === 3) {
|
||||
// The bridge carries at most 8 MiB, so this request left out the
|
||||
// older image instead of failing, with a note, and kept the newer.
|
||||
assert.ok(init.body.length <= 8 * 1024 * 1024 - 144 * 1024);
|
||||
const body = Buffer.from(init.body).toString("utf8");
|
||||
assert.equal(body.split(data).length - 1, 1, "only the newer image stays");
|
||||
assert.match(body, /not sent to keep the request under the provider's size limit/);
|
||||
return sse(
|
||||
{ type: "text-delta", delta: "read the text" },
|
||||
{ type: "finish", finishReason: { unified: "stop", raw: "stop" } },
|
||||
);
|
||||
}
|
||||
assert.equal(requests, 2);
|
||||
assert.ok(init.body.length < 8 * 1024 * 1024);
|
||||
assert.ok(Buffer.from(init.body).toString("base64").length > 8 * 1024 * 1024);
|
||||
@@ -76,10 +88,14 @@ try {
|
||||
assert.equal(toolCalls, 1);
|
||||
assert.equal(requests, 2);
|
||||
|
||||
// The retained images plus this prompt exceed the unchanged raw request budget.
|
||||
await assert.rejects(agent.prompt("x".repeat(2 * 1024 * 1024)).result, /HostStreamBackpressure/);
|
||||
assert.equal(requests, 2, "an oversized raw request must not reach host fetch");
|
||||
console.log("native image framing passed: accepted images survive base64 framing; oversized raw requests stay bounded");
|
||||
// The retained images plus this prompt exceed what the bridge carries: the
|
||||
// oldest images leave the request, which reaches host fetch within it.
|
||||
assert.equal((await agent.prompt("x".repeat(2 * 1024 * 1024)).result).stopReason, "end_turn");
|
||||
assert.equal(requests, 3);
|
||||
// A request past the bridge even without its images stays bounded.
|
||||
await assert.rejects(agent.prompt("z".repeat(6.5 * 1024 * 1024)).result, /HostStreamBackpressure/);
|
||||
assert.equal(requests, 3, "an oversized request must not reach host fetch");
|
||||
console.log("native image framing passed: accepted images survive base64 framing; requests past the bridge leave out their oldest images");
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
await agent?.close();
|
||||
|
||||
@@ -27,7 +27,7 @@ const sdkDir = fileURLToPath(new URL("..", import.meta.url));
|
||||
const fixturesDir = fileURLToPath(new URL("./types/", import.meta.url));
|
||||
|
||||
// fx-sdk.js exports these for libfx's own modules; they are not public API.
|
||||
const wasmInternals = new Set(["coreAnswered", "engineInternals", "journalMarksWanted", "normalizeAgentOptions", "normalizeInstructions", "normalizeModelChoice"]);
|
||||
const wasmInternals = new Set(["checkPromptInput", "coreAnswered", "engineInternals", "journalMarksWanted", "normalizeAgentOptions", "normalizeInstructions", "normalizeModelChoice", "persistenceImages", "resumeOutputShown"]);
|
||||
|
||||
// Each specifier as a consumer resolves it, the declaration file it must
|
||||
// reach, and the module whose runtime exports that file must match. node.cjs
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env node
|
||||
// The remote test fixture must reject incomplete setup before a suite starts
|
||||
// creating sessions. These checks use fake credentials and make no requests.
|
||||
import { strict as assert } from "node:assert";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const fixture = new URL("./vercel-storage.mjs", import.meta.url).href;
|
||||
const repoRoot = fileURLToPath(new URL("../..", import.meta.url));
|
||||
const required = {
|
||||
WORKFLOW_VERCEL_AUTH_TOKEN: "fake-access-token-not-a-real-credential",
|
||||
VERCEL_PROJECT_ID: "prj_test",
|
||||
VERCEL_PROJECT_NAME: "libfx-test",
|
||||
VERCEL_TEAM_ID: "team_test",
|
||||
VERCEL_DEPLOYMENT_ID: "dpl_test",
|
||||
};
|
||||
const clean = { ...process.env };
|
||||
for (const key of Object.keys(clean)) {
|
||||
if (key.startsWith("VERCEL_") || key.startsWith("WORKFLOW_") || key.startsWith("LIBFX_TEST_")) delete clean[key];
|
||||
}
|
||||
const noNetwork = 'globalThis.fetch = () => { throw new Error("unexpected network request"); };';
|
||||
const run = (args, env) => {
|
||||
const result = spawnSync(process.execPath, args, {
|
||||
cwd: repoRoot,
|
||||
env: { ...clean, ...env },
|
||||
encoding: "utf8",
|
||||
timeout: 5000,
|
||||
});
|
||||
assert.equal(result.error, undefined, result.error?.message);
|
||||
assert.equal(result.signal, null);
|
||||
return result;
|
||||
};
|
||||
|
||||
let checks = 0;
|
||||
for (const key of Object.keys(required)) {
|
||||
for (const value of [undefined, " "]) {
|
||||
const env = { ...required, [key]: value };
|
||||
if (value === undefined) delete env[key];
|
||||
const result = run(["--input-type=module", "-e", `${noNetwork} await import(${JSON.stringify(fixture)});`], env);
|
||||
assert.equal(result.status, 1, `missing ${key} must fail before the suite starts`);
|
||||
assert.ok(result.stderr.includes(key), `the error must name ${key}`);
|
||||
assert.ok(!result.stderr.includes(required.WORKFLOW_VERCEL_AUTH_TOKEN), "credentials must not appear in the error");
|
||||
checks += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// A development OIDC token cannot authenticate the local api-workflow proxy.
|
||||
const oidcOnly = { ...required, VERCEL_OIDC_TOKEN: "fake-oidc-not-an-access-token" };
|
||||
delete oidcOnly.WORKFLOW_VERCEL_AUTH_TOKEN;
|
||||
const oidc = run(["--input-type=module", "-e", `${noNetwork} await import(${JSON.stringify(fixture)});`], oidcOnly);
|
||||
assert.equal(oidc.status, 1);
|
||||
assert.ok(oidc.stderr.includes("WORKFLOW_VERCEL_AUTH_TOKEN"));
|
||||
assert.ok(!oidc.stderr.includes(oidcOnly.VERCEL_OIDC_TOKEN));
|
||||
checks += 1;
|
||||
|
||||
const valid = run(["--input-type=module", "-e", `${noNetwork} const { vercelStorage } = await import(${JSON.stringify(fixture)}); if (typeof vercelStorage !== "function") throw new Error("missing fixture");`], required);
|
||||
assert.equal(valid.status, 0, valid.stderr);
|
||||
checks += 1;
|
||||
|
||||
// Exercise the real suite entry point too, not only the fixture's import.
|
||||
const suite = run(["--experimental-wasm-jspi", "sdk/tests/test-durable.mjs", "vercel", "native"], oidcOnly);
|
||||
assert.equal(suite.status, 1);
|
||||
assert.ok(suite.stderr.includes("WORKFLOW_VERCEL_AUTH_TOKEN"));
|
||||
assert.ok(!suite.stdout.includes("FAIL "), "a setup error must not be reported as product failures");
|
||||
checks += 1;
|
||||
|
||||
console.log(`vercel storage preflight passed: ${checks} checks, no network requests`);
|
||||
@@ -205,6 +205,7 @@ export async function worlds(): Promise<void> {
|
||||
async write() {},
|
||||
async writeMulti() {},
|
||||
async get() { return new ReadableStream<Uint8Array>(); },
|
||||
async getChunks() { return { data: [], cursor: null, hasMore: false }; },
|
||||
async getInfo() { return { tailIndex: -1 }; },
|
||||
},
|
||||
async queue() { return { messageId: null }; },
|
||||
|
||||
@@ -4,11 +4,18 @@
|
||||
// and stream line goes through Vercel. Delivery through Vercel Queues is
|
||||
// covered by the deployed demo.
|
||||
//
|
||||
// Needs a Vercel access token in WORKFLOW_VERCEL_AUTH_TOKEN (or, inside a
|
||||
// deployment, its OIDC token), and VERCEL_PROJECT_ID, VERCEL_PROJECT_NAME,
|
||||
// VERCEL_TEAM_ID and a VERCEL_DEPLOYMENT_ID of that project.
|
||||
// Needs a Vercel access token in WORKFLOW_VERCEL_AUTH_TOKEN, and
|
||||
// VERCEL_PROJECT_ID, VERCEL_PROJECT_NAME, VERCEL_TEAM_ID and a
|
||||
// VERCEL_DEPLOYMENT_ID of that project. This local fixture uses the
|
||||
// api-workflow proxy, which does not accept a deployment's OIDC token.
|
||||
import { local } from "../durable/local.mjs";
|
||||
|
||||
const required = ["WORKFLOW_VERCEL_AUTH_TOKEN", "VERCEL_PROJECT_ID", "VERCEL_PROJECT_NAME", "VERCEL_TEAM_ID", "VERCEL_DEPLOYMENT_ID"];
|
||||
const missing = required.filter((key) => !process.env[key]?.trim());
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`remote Vercel tests require ${missing.join(", ")}; the local api-workflow proxy needs a Vercel access token, not VERCEL_OIDC_TOKEN`);
|
||||
}
|
||||
|
||||
const { createWorld } = await import(new URL("../durable/node_modules/@workflow/world-vercel/dist/index.js", import.meta.url).href);
|
||||
|
||||
/** `local()` with Vercel's World holding what `local()` keeps in files. */
|
||||
|
||||
Vendored
+4
-1
@@ -15,9 +15,12 @@ export interface World {
|
||||
list(params: unknown): unknown;
|
||||
};
|
||||
readonly streams: {
|
||||
write(runId: string, name: string, chunk: string): unknown;
|
||||
/** A session's view is written as text; each prompt image as one chunk of bytes. */
|
||||
write(runId: string, name: string, chunk: string | Uint8Array): unknown;
|
||||
writeMulti?(runId: string, name: string, chunks: string[]): unknown;
|
||||
get(runId: string, name: string, startIndex?: number): unknown;
|
||||
/** Read for a session's images: a page of chunks, `{ data: { data }[] }`. */
|
||||
getChunks(runId: string, name: string, options?: { limit?: number; cursor?: string }): unknown;
|
||||
getInfo(runId: string, name: string): unknown;
|
||||
};
|
||||
queue(queueName: string, message: unknown, options?: unknown): unknown;
|
||||
|
||||
@@ -754,7 +754,7 @@ export type FxAgentOptions = Omit<FxSharedOptions<FxAgentEvent>, "tools"> & FxMo
|
||||
|
||||
/** Options for `agent.session()`. Each applies to the turns this session object starts. */
|
||||
export interface FxSessionOptions {
|
||||
/** JSON every tool call of those turns receives as `context`. */
|
||||
/** JSON every tool call of those turns receives as `context`, copied when `session()` is called. */
|
||||
context?: JsonValue;
|
||||
/** The model for those turns instead of the agent's whole model choice: a model ID, or a model object. */
|
||||
model?: string | FxModelOptions;
|
||||
@@ -863,7 +863,11 @@ export type FxStreamLine = (
|
||||
export interface FxSession {
|
||||
/** The session's id; for a new session, null until its first prompt is accepted. */
|
||||
readonly id: string | null;
|
||||
/** Queues `input` as the session's next turn and returns a view of that turn. */
|
||||
/**
|
||||
* Queues `input` as the session's next turn and returns a view of that turn. Throws a
|
||||
* TypeError or RangeError for an input libfx refuses, and stores nothing. A prompt whose
|
||||
* `signal` aborts before it is stored is never stored, and ends `cancelled`.
|
||||
*/
|
||||
prompt(input: FxSessionPromptInput, options?: FxPromptOptions): FxTurn;
|
||||
/** Continues a turn a stopped process left open; its result has the stop reason `idle` when there was none. */
|
||||
resume(): FxResumeTurn;
|
||||
|
||||
+61
-3
@@ -28,6 +28,23 @@ pub fn writeJsonStr(s: []const u8, w: *std.Io.Writer) !void {
|
||||
try w.writeByte('"');
|
||||
}
|
||||
|
||||
/// Writes JSON a caller built into a frame. Frames are newline-delimited, and
|
||||
/// valid JSON holds a raw CR or LF only as whitespace between tokens, so each
|
||||
/// one is written as a space. A host tool call's input, for example, is the
|
||||
/// model's own text and may be pretty-printed.
|
||||
fn writeOneLine(w: *std.Io.Writer, json: []const u8) !void {
|
||||
if (std.mem.findScalar(u8, json, '\n') == null and std.mem.findScalar(u8, json, '\r') == null) {
|
||||
return w.writeAll(json);
|
||||
}
|
||||
var rest = json;
|
||||
while (std.mem.findAny(u8, rest, "\r\n")) |index| {
|
||||
try w.writeAll(rest[0..index]);
|
||||
try w.writeByte(' ');
|
||||
rest = rest[index + 1 ..];
|
||||
}
|
||||
try w.writeAll(rest);
|
||||
}
|
||||
|
||||
pub const ErrorCode = struct {
|
||||
pub const request_frame_too_large: i64 = -32000;
|
||||
pub const parse_error: i64 = -32700;
|
||||
@@ -168,7 +185,7 @@ pub const Writer = struct {
|
||||
try w.writeAll("{\"jsonrpc\":\"2.0\",\"id\":");
|
||||
try writeId(w, response.id);
|
||||
try w.writeAll(",\"result\":");
|
||||
try w.writeAll(response.result_json);
|
||||
try writeOneLine(w, response.result_json);
|
||||
try w.writeAll("}\n");
|
||||
},
|
||||
.error_response => |response| {
|
||||
@@ -193,7 +210,7 @@ pub const Writer = struct {
|
||||
try w.writeAll("{\"jsonrpc\":\"2.0\",\"method\":");
|
||||
try writeJsonStr(notification.method, w);
|
||||
try w.writeAll(",\"params\":");
|
||||
try w.writeAll(notification.params_json);
|
||||
try writeOneLine(w, notification.params_json);
|
||||
try w.writeAll("}\n");
|
||||
},
|
||||
.request => |request| {
|
||||
@@ -202,7 +219,7 @@ pub const Writer = struct {
|
||||
try w.writeAll(",\"method\":");
|
||||
try writeJsonStr(request.method, w);
|
||||
try w.writeAll(",\"params\":");
|
||||
try w.writeAll(request.params_json);
|
||||
try writeOneLine(w, request.params_json);
|
||||
try w.writeAll("}\n");
|
||||
},
|
||||
}
|
||||
@@ -679,3 +696,44 @@ test "Writer writeId handles all id types" {
|
||||
try std.testing.expectEqualStrings("null", out.writer.buffered());
|
||||
}
|
||||
}
|
||||
|
||||
test "Writer keeps each frame on one line when caller JSON has line breaks" {
|
||||
const alloc = std.testing.allocator;
|
||||
const Capture = struct {
|
||||
bytes: std.ArrayList(u8) = .empty,
|
||||
|
||||
fn write(raw: ?*anyopaque, frame: []const u8) !void {
|
||||
const self: *@This() = @ptrCast(@alignCast(raw.?));
|
||||
try self.bytes.appendSlice(std.testing.allocator, frame);
|
||||
}
|
||||
};
|
||||
// A model's tool input as some models send it: pretty-printed with LF and
|
||||
// CRLF line breaks, holding an escaped newline that must survive.
|
||||
const input = "{\n \"items\": [\r\n\t{\"text\": \"a\\nb\"}\n ]\n}";
|
||||
var capture: Capture = .{};
|
||||
defer capture.bytes.deinit(alloc);
|
||||
var writer = Writer.initCallback(&capture, Capture.write);
|
||||
try writer.writeRequest(alloc, .{ .integer = 1 }, "libfx/tool_call", "{\"name\":\"todo\",\"input\":" ++ input ++ "}");
|
||||
try writer.writeNotification(alloc, "session/update", input);
|
||||
try writer.writeResponse(alloc, .{ .integer = 2 }, input);
|
||||
|
||||
const frames = capture.bytes.items;
|
||||
try std.testing.expectEqual(@as(usize, 3), std.mem.count(u8, frames, "\n"));
|
||||
try std.testing.expect(std.mem.endsWith(u8, frames, "\n"));
|
||||
try std.testing.expect(std.mem.findScalar(u8, frames, '\r') == null);
|
||||
|
||||
const expected = try std.json.parseFromSlice(std.json.Value, alloc, input, .{});
|
||||
defer expected.deinit();
|
||||
const expected_json = try std.json.Stringify.valueAlloc(alloc, expected.value, .{});
|
||||
defer alloc.free(expected_json);
|
||||
var lines = std.mem.splitScalar(u8, frames[0 .. frames.len - 1], '\n');
|
||||
for ([_][]const u8{ "params", "params", "result" }, 0..) |field, index| {
|
||||
const frame = try std.json.parseFromSlice(std.json.Value, alloc, lines.next().?, .{});
|
||||
defer frame.deinit();
|
||||
var value = frame.value.object.get(field).?;
|
||||
if (index == 0) value = value.object.get("input").?;
|
||||
const actual_json = try std.json.Stringify.valueAlloc(alloc, value, .{});
|
||||
defer alloc.free(actual_json);
|
||||
try std.testing.expectEqualStrings(expected_json, actual_json);
|
||||
}
|
||||
}
|
||||
+516
-6
@@ -13,6 +13,7 @@ const debug_trace = @import("../core/shared/debug_trace.zig");
|
||||
const session_adapter = @import("../core/session/session_adapter.zig");
|
||||
const checkpoint_codec = @import("../core/agent/runtime/checkpoint.zig");
|
||||
const execution_memory = @import("../core/agent/execution_memory.zig");
|
||||
const runtime_execution_memory = @import("../core/agent/runtime/execution_memory.zig");
|
||||
const tool_dispatch = @import("../core/tooling/tool_dispatch.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
@@ -20,7 +21,7 @@ const Allocator = std.mem.Allocator;
|
||||
pub const Event = journal.Event;
|
||||
pub const max_load_bytes = journal.max_load_bytes;
|
||||
/// What the model is told when libfx continues a turn a crash left open.
|
||||
const resume_notice = "Resuming from unexpected session interruption.";
|
||||
const resume_notice = runtime_execution_memory.resume_notice;
|
||||
|
||||
/// A host's id for a turn, kept in place so copying a journal copies it.
|
||||
pub const TurnId = struct {
|
||||
@@ -98,13 +99,23 @@ pub const Journal = struct {
|
||||
/// The pending resume is a turn that yielded: it continues without being
|
||||
/// told it was interrupted.
|
||||
pending_resume_yielded: bool = false,
|
||||
/// The host keeps an image store: events and snapshots name the images
|
||||
/// in `stored_images` instead of holding their bytes.
|
||||
image_store: bool = false,
|
||||
/// The newest images the session keeps when the host keeps a store;
|
||||
/// older ones keep only their names (`dropOutsideWindow`).
|
||||
image_window: session_codec.ImageWindow = .{},
|
||||
/// The images the host's store holds: those it said it stored before it
|
||||
/// sent their prompt, and those an open resolved from it. Owned by the
|
||||
/// session's allocator.
|
||||
stored_images: session_codec.StoredImages = .{},
|
||||
/// The calls of the pending resume's answering step that no result was
|
||||
/// recorded for: the ones answered as possibly run. Owned by the
|
||||
/// session's allocator.
|
||||
pending_resume_ambiguous: []types.ToolCall = &.{},
|
||||
/// The last progress this journal sent in the open turn, so the next can
|
||||
/// carry only what is new. Its hashes are owned by the session's
|
||||
/// allocator; `stale` once its turn ended.
|
||||
/// allocator; `stale` once its turn ended or a snapshot took the turn in.
|
||||
last_progress: ?LastProgress = null,
|
||||
|
||||
const LastProgress = struct {
|
||||
@@ -114,6 +125,7 @@ pub const Journal = struct {
|
||||
};
|
||||
|
||||
pub fn deinit(self: *Journal, alloc: Allocator) void {
|
||||
self.stored_images.deinit(alloc);
|
||||
if (self.last_progress) |*last| last.hashes.deinit(alloc);
|
||||
self.last_progress = null;
|
||||
self.dropPendingResume(alloc);
|
||||
@@ -308,7 +320,10 @@ pub const Journal = struct {
|
||||
try params.writer.writeAll("{\"sessionId\":");
|
||||
try jsonrpc.writeJsonStr(session_id, ¶ms.writer);
|
||||
try params.writer.writeAll(",\"events\":[");
|
||||
const next = try self.cursor.write(¶ms.writer, event);
|
||||
const next = if (self.storedImages()) |stored|
|
||||
try self.cursor.writeNamingImages(¶ms.writer, event, stored)
|
||||
else
|
||||
try self.cursor.write(¶ms.writer, event);
|
||||
try params.writer.writeByte(']');
|
||||
if (barrier) try params.writer.writeAll(",\"barrier\":true");
|
||||
try params.writer.writeByte('}');
|
||||
@@ -335,6 +350,80 @@ pub const Journal = struct {
|
||||
}
|
||||
}
|
||||
|
||||
/// The images events and snapshots name, when the host keeps a store.
|
||||
pub fn storedImages(self: *const Journal) ?*const session_codec.StoredImages {
|
||||
return if (self.image_store) &self.stored_images else null;
|
||||
}
|
||||
|
||||
/// Records a prompt image the host stored before it sent it, under
|
||||
/// `name`, which counts only when it is the image's digest. A name that
|
||||
/// does not match leaves the image inline.
|
||||
pub fn noteStoredImage(
|
||||
self: *Journal,
|
||||
alloc: Allocator,
|
||||
image: types.ImageAttachment,
|
||||
name: session_codec.ImageName,
|
||||
) Allocator.Error!void {
|
||||
if (!self.image_store) return;
|
||||
const digest = image.snapshot_sha256 orelse return;
|
||||
if (image.inline_data == null or !std.mem.eql(u8, digest, &name)) {
|
||||
debug_trace.logf("session", "event=libfx_image_not_stored id={d} reason=name_mismatch", .{image.id});
|
||||
return;
|
||||
}
|
||||
try self.stored_images.add(alloc, name);
|
||||
}
|
||||
|
||||
/// Keeps the session within its image window when the host keeps a
|
||||
/// store: each image of `history` (owned by `history_alloc`) older than
|
||||
/// the window loses its bytes and keeps its name. A turn's images join the
|
||||
/// window once the turn enters the history; until then they stay outside
|
||||
/// it, as an open keeps the open turn's (`keepNeededImages`), so a session
|
||||
/// opened at any point holds the images the running one held.
|
||||
pub fn dropOutsideWindow(
|
||||
self: *Journal,
|
||||
alloc: Allocator,
|
||||
history_alloc: Allocator,
|
||||
history: []types.HistoryTurn,
|
||||
) Allocator.Error!void {
|
||||
if (!self.image_store) return;
|
||||
var held: std.ArrayList(HeldImage) = .empty;
|
||||
defer held.deinit(alloc);
|
||||
var sizes: std.ArrayList(?usize) = .empty;
|
||||
defer sizes.deinit(alloc);
|
||||
for (history) |*turn| {
|
||||
if (userOf(turn)) |user| for (user.images) |*image| {
|
||||
if (try countSize(alloc, &sizes, windowSize(image.*))) try held.append(alloc, .{ .prompt = image });
|
||||
};
|
||||
if (executionOf(turn)) |execution| for (execution.tool_steps) |step| for (step.tool_results) |result| for (result.tool_images) |*image| {
|
||||
if (try countSize(alloc, &sizes, toolWindowSize(image.*))) try held.append(alloc, .{ .tool = image });
|
||||
};
|
||||
}
|
||||
const outside = self.image_window.oldestOutside(sizes.items);
|
||||
var dropped: usize = 0;
|
||||
for (held.items[0..outside]) |image| switch (image) {
|
||||
.prompt => |prompt| {
|
||||
const bytes = prompt.inline_data orelse continue;
|
||||
history_alloc.free(bytes);
|
||||
prompt.inline_data = null;
|
||||
dropped += 1;
|
||||
},
|
||||
.tool => |tool| {
|
||||
if (tool.droppedFromSession()) continue;
|
||||
if (tool.name == null) {
|
||||
tool.name = try inlineToolImageName(alloc, tool.data) orelse {
|
||||
debug_trace.logf("session", "event=libfx_image_kept reason=inline_tool_image_not_base64 bytes={d}", .{tool.data.len});
|
||||
continue;
|
||||
};
|
||||
}
|
||||
const empty = try history_alloc.alloc(u8, 0);
|
||||
history_alloc.free(tool.data);
|
||||
tool.data = empty;
|
||||
dropped += 1;
|
||||
},
|
||||
};
|
||||
if (dropped > 0) debug_trace.logf("session", "event=libfx_images_dropped count={d} reason=image_window max_images={d} max_bytes={d}", .{ dropped, self.image_window.max_images, self.image_window.max_bytes });
|
||||
}
|
||||
|
||||
/// Records that a call of the running batch finished, with what the
|
||||
/// model sees of it. Nothing waits for it to be stored: a crash before
|
||||
/// the next progress answers the call with it instead of with "may have
|
||||
@@ -369,6 +458,14 @@ pub const Journal = struct {
|
||||
self.steers_waiting == 0 and self.follow_ups_waiting == 0;
|
||||
}
|
||||
|
||||
/// Records that a snapshot holding the open turn went to the host. A load
|
||||
/// from it starts the turn from the snapshot, without the progress record
|
||||
/// the snapshot folded, so the turn's next progress carries all of it
|
||||
/// instead of extending that record.
|
||||
pub fn noteOpenTurnSnapshot(self: *Journal) void {
|
||||
if (self.last_progress) |*last| last.stale = true;
|
||||
}
|
||||
|
||||
/// Records that the open turn ended without a history entry. A turn
|
||||
/// with no progress on record needs no event.
|
||||
pub fn clearProgress(self: *Journal, alloc: Allocator, session_id: []const u8) !void {
|
||||
@@ -410,12 +507,189 @@ fn dupePendingInputs(alloc: Allocator, inputs: []const journal.PendingInput) All
|
||||
return owned;
|
||||
}
|
||||
|
||||
fn userOf(turn: *types.HistoryTurn) ?*types.UserTurn {
|
||||
return switch (turn.*) {
|
||||
.assistant => |*entry| &entry.user,
|
||||
.interrupted => |*entry| &entry.user,
|
||||
.compacted_summary => null,
|
||||
};
|
||||
}
|
||||
|
||||
fn executionOf(turn: *types.HistoryTurn) ?*types.ExecutionMemory {
|
||||
return switch (turn.*) {
|
||||
.assistant => |*entry| &entry.execution,
|
||||
.interrupted => |*entry| &entry.execution,
|
||||
.compacted_summary => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// An image the window counts: a prompt's, or one a tool returned, counted
|
||||
/// together in the order they entered the session.
|
||||
const HeldImage = union(enum) {
|
||||
prompt: *types.ImageAttachment,
|
||||
tool: *types.ToolImage,
|
||||
};
|
||||
|
||||
const MissingImages = std.array_hash_map.Auto(session_codec.ImageName, usize);
|
||||
|
||||
/// What the image window counts for one image.
|
||||
const WindowSize = union(enum) {
|
||||
/// Its bytes: those it holds, or for an image an open has yet to stage,
|
||||
/// the size its record gives (`unstaged`).
|
||||
bytes: usize,
|
||||
/// One the session dropped, which keeps every older image out.
|
||||
dropped,
|
||||
/// One with neither bytes nor a name, which only a host reference
|
||||
/// describes, and so holds nothing.
|
||||
none,
|
||||
};
|
||||
|
||||
/// What the window counts for `image`, as the session holds it.
|
||||
fn windowSize(image: types.ImageAttachment) WindowSize {
|
||||
if (image.inline_data) |bytes| return .{ .bytes = bytes.len };
|
||||
return if (image.droppedFromSession()) .dropped else .none;
|
||||
}
|
||||
|
||||
/// What the window counts for a tool image: its decoded bytes, whether the
|
||||
/// host stored it or the session holds it inline, as one the store refused
|
||||
/// or an older libfx saved. One with only a host reference holds nothing.
|
||||
fn toolWindowSize(image: types.ToolImage) WindowSize {
|
||||
if (image.droppedFromSession()) return .dropped;
|
||||
if (image.data.len == 0) return .none;
|
||||
return .{ .bytes = std.base64.standard.Decoder.calcSizeForSlice(image.data) catch image.data.len };
|
||||
}
|
||||
|
||||
/// The name of a tool image's bytes, which one held inline takes when it
|
||||
/// leaves the window, so it is saved dropped like a stored one; null for
|
||||
/// bytes that are not base64.
|
||||
fn inlineToolImageName(alloc: Allocator, data: []const u8) Allocator.Error!?session_codec.ImageName {
|
||||
const size = std.base64.standard.Decoder.calcSizeForSlice(data) catch return null;
|
||||
const bytes = try alloc.alloc(u8, size);
|
||||
defer alloc.free(bytes);
|
||||
std.base64.standard.Decoder.decode(bytes, data) catch return null;
|
||||
return session_codec.imageNameOf(bytes);
|
||||
}
|
||||
|
||||
/// An image an open has yet to stage, with the size its record gives.
|
||||
const Unstaged = struct { name: session_codec.ImageName, size: usize };
|
||||
|
||||
/// The name and size of `image` when it is one an open has yet to stage: it
|
||||
/// reads such an image as one without bytes, which `missing` names.
|
||||
fn unstaged(image: types.ImageAttachment, missing: *const MissingImages) ?Unstaged {
|
||||
if (!image.droppedFromSession()) return null;
|
||||
const name = session_codec.imageName(image.snapshot_sha256.?) orelse return null;
|
||||
return .{ .name = name, .size = missing.get(name) orelse return null };
|
||||
}
|
||||
|
||||
/// `unstaged` for a tool image, which an open reads by name without bytes.
|
||||
fn unstagedTool(image: types.ToolImage, missing: *const MissingImages) ?Unstaged {
|
||||
if (!image.droppedFromSession()) return null;
|
||||
const name = image.name.?;
|
||||
return .{ .name = name, .size = missing.get(name) orelse return null };
|
||||
}
|
||||
|
||||
/// Appends what the window counts for one image to `sizes`, oldest first,
|
||||
/// and returns whether it counted.
|
||||
fn countSize(alloc: Allocator, sizes: *std.ArrayList(?usize), size: WindowSize) Allocator.Error!bool {
|
||||
switch (size) {
|
||||
.bytes => |bytes| try sizes.append(alloc, bytes),
|
||||
.dropped => try sizes.append(alloc, null),
|
||||
.none => return false,
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Leaves in `images.missing` only the names of images the window keeps, so
|
||||
/// the host stages none the session would drop. The window counts the
|
||||
/// history alone, as it did when the last turn entered it
|
||||
/// (`Journal.dropOutsideWindow`); the open turn's images, its prompt's and
|
||||
/// its tools', are always needed.
|
||||
fn keepNeededImages(
|
||||
alloc: Allocator,
|
||||
images: *session_codec.ImageResolver,
|
||||
history: []const types.HistoryTurn,
|
||||
open_user: ?types.UserTurn,
|
||||
open_execution: ?types.ExecutionMemory,
|
||||
open_finished: []const journal.Finished,
|
||||
window: session_codec.ImageWindow,
|
||||
) Allocator.Error!void {
|
||||
var sizes: std.ArrayList(?usize) = .empty;
|
||||
defer sizes.deinit(alloc);
|
||||
var names: std.ArrayList(?session_codec.ImageName) = .empty;
|
||||
defer names.deinit(alloc);
|
||||
for (history) |turn| {
|
||||
const user, const execution = switch (turn) {
|
||||
.assistant => |entry| .{ entry.user, entry.execution },
|
||||
.interrupted => |entry| .{ entry.user, entry.execution },
|
||||
.compacted_summary => continue,
|
||||
};
|
||||
try countOpenedImages(alloc, &sizes, &names, user.images, &images.missing);
|
||||
try countOpenedToolImages(alloc, &sizes, &names, execution, &images.missing);
|
||||
}
|
||||
const outside = window.oldestOutside(sizes.items);
|
||||
var needed: MissingImages = .empty;
|
||||
errdefer needed.deinit(images.alloc);
|
||||
for (names.items[outside..], sizes.items[outside..]) |name, size| {
|
||||
if (name) |value| try needed.put(images.alloc, value, size.?);
|
||||
}
|
||||
if (open_user) |user| for (user.images) |image| {
|
||||
if (unstaged(image, &images.missing)) |found| try needed.put(images.alloc, found.name, found.size);
|
||||
};
|
||||
if (open_execution) |execution| for (execution.tool_steps) |step| for (step.tool_results) |result| for (result.tool_images) |image| {
|
||||
if (unstagedTool(image, &images.missing)) |found| try needed.put(images.alloc, found.name, found.size);
|
||||
};
|
||||
// A result the open turn recorded before a crash answers its call again.
|
||||
for (open_finished) |finished| for (finished.images) |image| {
|
||||
if (unstagedTool(image, &images.missing)) |found| try needed.put(images.alloc, found.name, found.size);
|
||||
};
|
||||
if (outside > 0) debug_trace.logf("session", "event=libfx_image_window_open outside={d} needed={d}", .{ outside, needed.count() });
|
||||
images.missing.deinit(images.alloc);
|
||||
images.missing = needed;
|
||||
}
|
||||
|
||||
/// Counts the images of `user_images` an open read, each with its name when
|
||||
/// it is one the host has yet to stage.
|
||||
fn countOpenedImages(
|
||||
alloc: Allocator,
|
||||
sizes: *std.ArrayList(?usize),
|
||||
names: *std.ArrayList(?session_codec.ImageName),
|
||||
user_images: []const types.ImageAttachment,
|
||||
missing: *const MissingImages,
|
||||
) Allocator.Error!void {
|
||||
for (user_images) |image| {
|
||||
const pending = unstaged(image, missing);
|
||||
if (!try countSize(alloc, sizes, if (pending) |found| .{ .bytes = found.size } else windowSize(image))) continue;
|
||||
try names.append(alloc, if (pending) |found| found.name else null);
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts the tool images of `execution` an open read, as `countOpenedImages`
|
||||
/// counts a prompt's.
|
||||
fn countOpenedToolImages(
|
||||
alloc: Allocator,
|
||||
sizes: *std.ArrayList(?usize),
|
||||
names: *std.ArrayList(?session_codec.ImageName),
|
||||
execution: types.ExecutionMemory,
|
||||
missing: *const MissingImages,
|
||||
) Allocator.Error!void {
|
||||
for (execution.tool_steps) |step| for (step.tool_results) |result| for (result.tool_images) |image| {
|
||||
const pending = unstagedTool(image, missing);
|
||||
if (!try countSize(alloc, sizes, if (pending) |found| .{ .bytes = found.size } else toolWindowSize(image))) continue;
|
||||
try names.append(alloc, if (pending) |found| found.name else null);
|
||||
};
|
||||
}
|
||||
|
||||
/// Rebuilds a fresh session from the host's events, after `snapshot` when
|
||||
/// the host stored one, and starts its journal after them. A turn a crash
|
||||
/// left open becomes the pending resume, with every call it left running
|
||||
/// answered as possibly run, for the model to decide about unless the host
|
||||
/// has the resume run it again (`rerunCalls`); `session_alloc` owns it.
|
||||
/// With `inputs_durable`, the host stores every input before handing it in.
|
||||
/// Named images resolve through `images`, which holds what the host staged;
|
||||
/// when one is missing the session stays unopened and `error.ImagesMissing`
|
||||
/// leaves every missing name in `images.missing`. With `image_store` the
|
||||
/// journal names stored images from then on, and the session keeps only the
|
||||
/// newest images `window` holds: an older one is neither needed nor kept.
|
||||
pub fn open(
|
||||
alloc: Allocator,
|
||||
session_alloc: Allocator,
|
||||
@@ -424,16 +698,19 @@ pub fn open(
|
||||
events_json: []const u8,
|
||||
snapshot: ?[]const u8,
|
||||
inputs_durable: bool,
|
||||
images: *session_codec.ImageResolver,
|
||||
image_store: bool,
|
||||
window: session_codec.ImageWindow,
|
||||
) !struct { Journal, Opened } {
|
||||
if (!runtime.agent.fresh or runtime.agent.history.items.len != 0) return error.AgentNotFresh;
|
||||
var base: journal.Base = .{};
|
||||
var base: journal.Base = .{ .images = images };
|
||||
var decoded: ?checkpoint_codec.Decoded = null;
|
||||
defer if (decoded) |*value| value.deinit(alloc);
|
||||
var saved_meta: checkpoint_codec.Meta = .{};
|
||||
errdefer saved_meta.free(alloc);
|
||||
if (snapshot) |bytes| {
|
||||
const parsed = try journal.parseSnapshot(bytes);
|
||||
decoded = checkpoint_codec.decode(alloc, parsed.checkpoint) catch |err| switch (err) {
|
||||
decoded = checkpoint_codec.decodeWithImages(alloc, parsed.checkpoint, images) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
error.UnsupportedCheckpointVersion => return error.UnsupportedJournalVersion,
|
||||
else => return error.InvalidJournal,
|
||||
@@ -444,10 +721,19 @@ pub fn open(
|
||||
.cursor = .{ .next_seq = parsed.seq + 1, .turn = parsed.turn },
|
||||
.last_turn_id = parsed.last_turn_id,
|
||||
.open_turn = parsed.open_turn,
|
||||
.images = images,
|
||||
};
|
||||
}
|
||||
var folded = try journal.foldFrom(alloc, events_json, base);
|
||||
defer folded.deinit(alloc);
|
||||
if (image_store and images.missing.count() > 0) {
|
||||
try keepNeededImages(alloc, images, folded.history, if (folded.open_turn) |turn| turn.user else null, if (folded.open_turn) |turn| turn.execution else null, folded.finished, window);
|
||||
}
|
||||
// A history missing an image it keeps is not the one saved: open none.
|
||||
if (images.missing.count() > 0) {
|
||||
debug_trace.logf("session", "event=libfx_journal_images_missing count={d}", .{images.missing.count()});
|
||||
return error.ImagesMissing;
|
||||
}
|
||||
if (folded.history.len > 0) try runtime.agent.restoreHistory(alloc, folded.history);
|
||||
if (decoded) |value| runtime.agent.turn_usage = value.usage;
|
||||
var state: Journal = .{
|
||||
@@ -460,10 +746,16 @@ pub fn open(
|
||||
.last_turn_id = TurnId.of(folded.last_turn_id),
|
||||
.inputs_durable = inputs_durable,
|
||||
.steers_waiting = folded.pending_inputs.len,
|
||||
.image_store = image_store,
|
||||
.image_window = window,
|
||||
};
|
||||
errdefer state.deinit(session_alloc);
|
||||
const checkpoint = folded.open_turn orelse
|
||||
// An image the open resolved is in the store; one it read inline is not.
|
||||
if (image_store) for (images.resolved.keys()) |name| try state.stored_images.add(session_alloc, name);
|
||||
try state.dropOutsideWindow(alloc, alloc, runtime.agent.history.items);
|
||||
const checkpoint = folded.open_turn orelse {
|
||||
return .{ state, .{ .turns = folded.history.len, .resumable = false, .last_turn_id = state.last_turn_id, .checkpoint_meta = saved_meta } };
|
||||
};
|
||||
|
||||
debug_trace.logf("session", "event=libfx_journal_open_turn turn={d} running_calls={d} finished_calls={d} resolved=pending_resume", .{ folded.cursor.turn, folded.running_calls.len, folded.finished.len });
|
||||
var scratch = std.heap.ArenaAllocator.init(alloc);
|
||||
@@ -571,6 +863,7 @@ fn answerRunning(scratch: Allocator, turn: *session_codec.RecoveryCheckpoint, ca
|
||||
.tool_name = @constCast(call.name),
|
||||
.status = if (recorded) |entry| (if (entry.failed) .failure else .success) else .failure,
|
||||
.output = @constCast(output),
|
||||
.tool_images = if (recorded) |entry| entry.images else &.{},
|
||||
.output_bytes = output.len,
|
||||
.stored_output_bytes = output.len,
|
||||
};
|
||||
@@ -835,6 +1128,29 @@ test "a progress after its turn's first carries only what is new, and the next t
|
||||
try std.testing.expect(std.mem.find(u8, capture.frames.items[next..], "\"user\"") != null);
|
||||
}
|
||||
|
||||
test "a progress after a snapshot that holds its turn carries all of it" {
|
||||
const alloc = std.testing.allocator;
|
||||
var capture: TestCapture = .{};
|
||||
defer capture.frames.deinit(alloc);
|
||||
var writer = jsonrpc.Writer.initCallback(&capture, TestCapture.write);
|
||||
var session: Journal = .{ .writer = &writer };
|
||||
defer session.deinit(alloc);
|
||||
var progress = try testProgress(alloc);
|
||||
defer progress.deinit(alloc);
|
||||
|
||||
try session.appendProgress(alloc, alloc, "session", progress, "fake/model");
|
||||
// A load from the snapshot has no record for the next progress to extend.
|
||||
session.noteOpenTurnSnapshot();
|
||||
const next = capture.frames.items.len;
|
||||
try session.appendProgress(alloc, alloc, "session", progress, "fake/model");
|
||||
try std.testing.expect(std.mem.find(u8, capture.frames.items[next..], "\"base\"") == null);
|
||||
try std.testing.expect(std.mem.find(u8, capture.frames.items[next..], "\"user\"") != null);
|
||||
// The progress after that one extends it again.
|
||||
const after = capture.frames.items.len;
|
||||
try session.appendProgress(alloc, alloc, "session", progress, "fake/model");
|
||||
try std.testing.expect(std.mem.find(u8, capture.frames.items[after..], "\"base\":2,") != null);
|
||||
}
|
||||
|
||||
test "a resumed turn runs again the calls the host asks for and leaves the rest to the model" {
|
||||
const alloc = std.testing.allocator;
|
||||
var calls = [_]types.ToolCall{
|
||||
@@ -883,3 +1199,197 @@ test "a resume cancelled during a rerun keeps that call and the later ones answe
|
||||
// A step index past the turn's steps runs nothing.
|
||||
try std.testing.expectEqual(@as(usize, 0), try rerunCalls(alloc, &answered, 5, TestRerunner{ .ids = &ids, .ran = &ran }, null));
|
||||
}
|
||||
|
||||
/// An inline image of `size` bytes for the window tests, every field owned by
|
||||
/// `alloc`; `types.freeImageAttachment` frees it.
|
||||
fn windowTestImage(alloc: Allocator, id: usize, size: usize) !types.ImageAttachment {
|
||||
const bytes = try alloc.alloc(u8, size);
|
||||
errdefer alloc.free(bytes);
|
||||
@memset(bytes, 'x');
|
||||
const path = try alloc.dupe(u8, "inline://image");
|
||||
errdefer alloc.free(path);
|
||||
const media_type = try alloc.dupe(u8, "image/png");
|
||||
errdefer alloc.free(media_type);
|
||||
const digest = session_codec.imageNameOf(bytes);
|
||||
return .{ .id = id, .path = path, .media_type = media_type, .snapshot_sha256 = try alloc.dupe(u8, &digest), .inline_data = bytes };
|
||||
}
|
||||
|
||||
test "the image window drops the oldest history images, and not for an open turn's" {
|
||||
const alloc = std.testing.allocator;
|
||||
var capture: TestCapture = .{};
|
||||
defer capture.frames.deinit(alloc);
|
||||
var writer = jsonrpc.Writer.initCallback(&capture, TestCapture.write);
|
||||
var session: Journal = .{ .writer = &writer, .image_store = true, .image_window = .{ .max_images = 2 } };
|
||||
defer session.deinit(alloc);
|
||||
var images: [3]types.ImageAttachment = undefined;
|
||||
for (&images, 0..) |*image, index| image.* = try windowTestImage(alloc, index + 1, 10);
|
||||
defer for (images) |image| types.freeImageAttachment(alloc, image);
|
||||
var history: [3]types.HistoryTurn = undefined;
|
||||
for (&history, &images) |*turn, *image| {
|
||||
turn.* = .{ .assistant = .{ .user = .{ .text = @constCast("look"), .images = image[0..1] }, .assistant = @constCast("ok") } };
|
||||
}
|
||||
|
||||
// Two images at most: the two newest of history.
|
||||
try session.dropOutsideWindow(alloc, alloc, &history);
|
||||
try std.testing.expect(images[0].droppedFromSession());
|
||||
try std.testing.expect(images[1].inline_data != null and images[2].inline_data != null);
|
||||
|
||||
// An open turn has not entered the history, so its image moves nothing.
|
||||
var pending = try testProgress(alloc);
|
||||
pending.user.images = try alloc.alloc(types.ImageAttachment, 1);
|
||||
pending.user.images[0] = try windowTestImage(alloc, 4, 10);
|
||||
session.pending_resume = pending;
|
||||
try session.dropOutsideWindow(alloc, alloc, &history);
|
||||
try std.testing.expect(images[1].inline_data != null and images[2].inline_data != null);
|
||||
try std.testing.expect(session.pending_resume.?.user.images[0].inline_data != null);
|
||||
}
|
||||
|
||||
/// A one-image slice a tool returned, `size` bytes as base64, named as the
|
||||
/// host stored it unless `named` is false; `types.freeToolImages` frees it.
|
||||
fn windowTestToolImages(alloc: Allocator, size: usize, named: bool) ![]types.ToolImage {
|
||||
const bytes = try alloc.alloc(u8, size);
|
||||
defer alloc.free(bytes);
|
||||
@memset(bytes, 't');
|
||||
const encoded = try alloc.alloc(u8, std.base64.standard.Encoder.calcSize(size));
|
||||
errdefer alloc.free(encoded);
|
||||
_ = std.base64.standard.Encoder.encode(encoded, bytes);
|
||||
const mime_type = try alloc.dupe(u8, "image/png");
|
||||
errdefer alloc.free(mime_type);
|
||||
const images = try alloc.alloc(types.ToolImage, 1);
|
||||
images[0] = .{ .data = encoded, .mime_type = mime_type, .name = if (named) session_codec.imageNameOf(bytes) else null };
|
||||
return images;
|
||||
}
|
||||
|
||||
fn windowTestToolStep(results: *[1]types.PersistedToolResult, steps: *[1]types.ToolExecutionStep, images: []types.ToolImage) types.ExecutionMemory {
|
||||
results.* = .{.{
|
||||
.tool_images = images,
|
||||
.tool_call_id = @constCast("call"),
|
||||
.tool_name = @constCast("screenshot"),
|
||||
.status = .success,
|
||||
.output = @constCast("took it"),
|
||||
.output_bytes = 7,
|
||||
.stored_output_bytes = 7,
|
||||
}};
|
||||
steps.* = .{.{ .tool_results = results }};
|
||||
return .{ .tool_steps = steps };
|
||||
}
|
||||
|
||||
test "the image window counts a turn's tool images after its prompt's, inline ones too, and drops them by name" {
|
||||
const alloc = std.testing.allocator;
|
||||
var capture: TestCapture = .{};
|
||||
defer capture.frames.deinit(alloc);
|
||||
var writer = jsonrpc.Writer.initCallback(&capture, TestCapture.write);
|
||||
var session: Journal = .{ .writer = &writer, .image_store = true, .image_window = .{ .max_images = 2 } };
|
||||
defer session.deinit(alloc);
|
||||
var prompts: [2]types.ImageAttachment = undefined;
|
||||
for (&prompts, 0..) |*image, index| image.* = try windowTestImage(alloc, index + 1, 10);
|
||||
defer for (prompts) |image| types.freeImageAttachment(alloc, image);
|
||||
var tools: [2][]types.ToolImage = undefined;
|
||||
for (&tools) |*images| images.* = try windowTestToolImages(alloc, 10, true);
|
||||
defer for (tools) |images| types.freeToolImages(alloc, images);
|
||||
// A tool image the session holds inline, as one the store refused, the
|
||||
// oldest tool image here.
|
||||
const unstored = try windowTestToolImages(alloc, 12, false);
|
||||
defer types.freeToolImages(alloc, unstored);
|
||||
var unstored_bytes: [12]u8 = undefined;
|
||||
try std.base64.standard.Decoder.decode(&unstored_bytes, unstored[0].data);
|
||||
var results: [3][1]types.PersistedToolResult = undefined;
|
||||
var steps: [3][1]types.ToolExecutionStep = undefined;
|
||||
var history: [3]types.HistoryTurn = undefined;
|
||||
history[0] = .{ .assistant = .{ .user = .{ .text = @constCast("one"), .images = prompts[0..1] }, .assistant = @constCast("ok"), .execution = windowTestToolStep(&results[0], &steps[0], unstored) } };
|
||||
history[1] = .{ .assistant = .{ .user = .{ .text = @constCast("two"), .images = prompts[1..2] }, .assistant = @constCast("ok"), .execution = windowTestToolStep(&results[1], &steps[1], tools[0]) } };
|
||||
history[2] = .{ .assistant = .{ .user = .{ .text = @constCast("three") }, .assistant = @constCast("ok"), .execution = windowTestToolStep(&results[2], &steps[2], tools[1]) } };
|
||||
|
||||
// Two images at most: the newest two tool images, in the order they
|
||||
// entered; every older one leaves, keeping its name. The inline one
|
||||
// counts as any image does, and takes the name of its bytes.
|
||||
try session.dropOutsideWindow(alloc, alloc, &history);
|
||||
try std.testing.expect(prompts[0].droppedFromSession());
|
||||
try std.testing.expect(unstored[0].droppedFromSession());
|
||||
try std.testing.expectEqualSlices(u8, &session_codec.imageNameOf(&unstored_bytes), &unstored[0].name.?);
|
||||
try std.testing.expect(prompts[1].droppedFromSession());
|
||||
try std.testing.expect(!tools[0][0].droppedFromSession() and tools[0][0].data.len > 0);
|
||||
try std.testing.expect(!tools[1][0].droppedFromSession() and tools[1][0].data.len > 0);
|
||||
}
|
||||
|
||||
test "an open needs the newest tool images its window keeps, and every one of the open turn" {
|
||||
const alloc = std.testing.allocator;
|
||||
var staged: session_codec.StagedImages = .{};
|
||||
defer staged.deinit(alloc);
|
||||
var resolver: session_codec.ImageResolver = .{ .alloc = alloc, .staged = &staged };
|
||||
defer resolver.deinit();
|
||||
// Three tool images the host has not staged yet, read as names alone.
|
||||
var names: [3]session_codec.ImageName = undefined;
|
||||
var images: [3][1]types.ToolImage = undefined;
|
||||
for (&names, &images, 0..) |*name, *image, index| {
|
||||
name.* = session_codec.imageNameOf(&.{@as(u8, @intCast(index))});
|
||||
image.* = .{.{ .data = @constCast(&[_]u8{}), .mime_type = @constCast("image/png"), .name = name.* }};
|
||||
try resolver.missing.put(alloc, name.*, 10);
|
||||
}
|
||||
var results: [3][1]types.PersistedToolResult = undefined;
|
||||
var steps: [3][1]types.ToolExecutionStep = undefined;
|
||||
var history: [2]types.HistoryTurn = undefined;
|
||||
for (&history, 0..) |*turn, index| {
|
||||
turn.* = .{ .assistant = .{ .user = .{ .text = @constCast("look") }, .assistant = @constCast("ok"), .execution = windowTestToolStep(&results[index], &steps[index], &images[index]) } };
|
||||
}
|
||||
const open_execution = windowTestToolStep(&results[2], &steps[2], &images[2]);
|
||||
|
||||
try keepNeededImages(alloc, &resolver, &history, null, open_execution, &.{}, .{ .max_images = 1 });
|
||||
try std.testing.expectEqual(@as(usize, 2), resolver.missing.count());
|
||||
try std.testing.expect(!resolver.missing.contains(names[0]));
|
||||
try std.testing.expect(resolver.missing.contains(names[1]) and resolver.missing.contains(names[2]));
|
||||
}
|
||||
|
||||
test "an open needs the images of a result its open turn recorded before a crash" {
|
||||
const alloc = std.testing.allocator;
|
||||
var staged: session_codec.StagedImages = .{};
|
||||
defer staged.deinit(alloc);
|
||||
var resolver: session_codec.ImageResolver = .{ .alloc = alloc, .staged = &staged };
|
||||
defer resolver.deinit();
|
||||
const name = session_codec.imageNameOf("recorded shot");
|
||||
try resolver.missing.put(alloc, name, 13);
|
||||
var images = [_]types.ToolImage{.{ .data = @constCast(&[_]u8{}), .mime_type = @constCast("image/png"), .name = name }};
|
||||
const finished = [_]journal.Finished{.{ .id = @constCast("call-1"), .failed = false, .output = @constCast("took it"), .images = &images }};
|
||||
|
||||
try keepNeededImages(alloc, &resolver, &.{}, null, null, &finished, .{ .max_images = 1 });
|
||||
try std.testing.expectEqual(@as(usize, 1), resolver.missing.count());
|
||||
try std.testing.expectEqual(@as(?usize, 13), resolver.missing.get(name));
|
||||
}
|
||||
|
||||
test "an open needs only the named images its window keeps, and every image of the open turn" {
|
||||
const alloc = std.testing.allocator;
|
||||
var staged: session_codec.StagedImages = .{};
|
||||
defer staged.deinit(alloc);
|
||||
var resolver: session_codec.ImageResolver = .{ .alloc = alloc, .staged = &staged };
|
||||
defer resolver.deinit();
|
||||
// Four named images the host has not staged yet, oldest first.
|
||||
var names: [4]session_codec.ImageName = undefined;
|
||||
var images: [4]types.ImageAttachment = undefined;
|
||||
for (&names, &images, 0..) |*name, *image, index| {
|
||||
name.* = session_codec.imageNameOf(&.{@as(u8, @intCast(index))});
|
||||
image.* = .{ .id = index + 1, .path = @constCast("inline://image"), .media_type = @constCast("image/png"), .snapshot_sha256 = name };
|
||||
}
|
||||
const reset = struct {
|
||||
fn run(target: *session_codec.ImageResolver, all: []const session_codec.ImageName) !void {
|
||||
target.missing.clearRetainingCapacity();
|
||||
for (all) |name| try target.missing.put(target.alloc, name, 10);
|
||||
}
|
||||
}.run;
|
||||
var history: [3]types.HistoryTurn = undefined;
|
||||
for (&history, images[0..3]) |*turn, *image| {
|
||||
turn.* = .{ .assistant = .{ .user = .{ .text = @constCast("look"), .images = image[0..1] }, .assistant = @constCast("ok") } };
|
||||
}
|
||||
|
||||
// The window counts the history alone: its two newest, and the open
|
||||
// turn's image as well.
|
||||
try reset(&resolver, &names);
|
||||
try keepNeededImages(alloc, &resolver, &history, .{ .text = @constCast("now"), .images = images[3..4] }, null, &.{}, .{ .max_images = 2 });
|
||||
try std.testing.expectEqual(@as(usize, 3), resolver.missing.count());
|
||||
try std.testing.expect(!resolver.missing.contains(names[0]));
|
||||
|
||||
// An open turn larger than the window keeps all of its images.
|
||||
try reset(&resolver, &names);
|
||||
try keepNeededImages(alloc, &resolver, history[0..2], .{ .text = @constCast("now"), .images = images[2..4] }, null, &.{}, .{ .max_images = 1 });
|
||||
try std.testing.expectEqual(@as(usize, 3), resolver.missing.count());
|
||||
try std.testing.expect(!resolver.missing.contains(names[0]));
|
||||
}
|
||||
@@ -141,7 +141,7 @@ fn prepareHost(
|
||||
defer inherited.deinit();
|
||||
const h = mcp_host.Host.create(alloc, io_mod.getIo(), configs, &inherited, .{
|
||||
.client_version = build_options.app_version,
|
||||
.credentials = .detect(alloc, home),
|
||||
.credentials = .{ .detect = home },
|
||||
.workspace_root = workspace_root,
|
||||
.questions = questions,
|
||||
.url_completions = url_completions,
|
||||
|
||||
+95
-6
@@ -134,10 +134,48 @@ fn promptInputFailure(err: anyerror) anyerror!TerminalOutcome {
|
||||
.code = ErrorCode.invalid_params,
|
||||
.message = "Image prompt exceeds size limit",
|
||||
} },
|
||||
error.PromptImagesTooLarge => .{ .rpc_error = .{
|
||||
.code = ErrorCode.invalid_params,
|
||||
.message = "Prompt images exceed the libfx limit",
|
||||
} },
|
||||
else => err,
|
||||
};
|
||||
}
|
||||
|
||||
/// Whether `images` fit one libfx prompt: at most
|
||||
/// `image_attachments.max_libfx_prompt_images` of them, holding at most
|
||||
/// `max_libfx_prompt_images_bytes` together. An open stages its open turn's
|
||||
/// images on top of the session's image window, and counts on this.
|
||||
fn fitsLibfxPrompt(images: []const PendingPromptImage) bool {
|
||||
if (images.len > image_attachments.max_libfx_prompt_images) return false;
|
||||
var bytes: usize = 0;
|
||||
for (images) |image| bytes += image.bytes.len;
|
||||
return bytes <= image_attachments.max_libfx_prompt_images_bytes;
|
||||
}
|
||||
|
||||
test "a libfx prompt holds at most its limit of images and image bytes" {
|
||||
const alloc = std.testing.allocator;
|
||||
var media_type = "image/png".*;
|
||||
var byte: [1]u8 = .{0x89};
|
||||
var images: [image_attachments.max_libfx_prompt_images + 1]PendingPromptImage = @splat(.{ .id = 1, .bytes = &byte, .media_type = &media_type });
|
||||
try std.testing.expect(fitsLibfxPrompt(images[0..image_attachments.max_libfx_prompt_images]));
|
||||
try std.testing.expect(!fitsLibfxPrompt(&images));
|
||||
// Reference-only images count toward the number too.
|
||||
var references = images;
|
||||
for (&references) |*image| image.bytes = &.{};
|
||||
try std.testing.expect(!fitsLibfxPrompt(&references));
|
||||
|
||||
const bytes = try alloc.alloc(u8, image_attachments.max_libfx_prompt_images_bytes + 1);
|
||||
defer alloc.free(bytes);
|
||||
const exact = [_]PendingPromptImage{.{ .id = 1, .bytes = bytes[1..], .media_type = &media_type }};
|
||||
try std.testing.expect(fitsLibfxPrompt(&exact));
|
||||
const over = [_]PendingPromptImage{
|
||||
.{ .id = 1, .bytes = bytes[0..1024], .media_type = &media_type },
|
||||
.{ .id = 2, .bytes = bytes[1024..], .media_type = &media_type },
|
||||
};
|
||||
try std.testing.expect(!fitsLibfxPrompt(&over));
|
||||
}
|
||||
|
||||
fn promptExecutionFailure(err: anyerror) anyerror!TerminalOutcome {
|
||||
return switch (err) {
|
||||
error.ModelImageCapabilityUnavailable,
|
||||
@@ -880,8 +918,10 @@ pub fn handlePrompt(
|
||||
if (session.store == null and session.wasm_state == null and session.v2 == null) {
|
||||
// libfx kernel session: images stay in memory and ride the kernel
|
||||
// checkpoint, so no filesystem snapshot backend is needed.
|
||||
if (!fitsLibfxPrompt(prompt_input.pending_images)) return promptInputFailure(error.PromptImagesTooLarge);
|
||||
prompt_input.captureImagesInline(alloc) catch |err|
|
||||
return promptInputFailure(err);
|
||||
if (session.journal) |*journal| try prompt_input.noteStoredImages(state.alloc, journal);
|
||||
} else {
|
||||
if (comptime host_target.is_wasm) return promptInputFailure(error.UnsupportedPromptImage);
|
||||
var temporary_snapshot_dir: ?[]u8 = null;
|
||||
@@ -1111,7 +1151,9 @@ pub fn handlePrompt(
|
||||
.grants = session.session_grants,
|
||||
.context_snapshot = context_snapshot,
|
||||
.recovery_checkpoint = recovery_checkpoint,
|
||||
.recovery_source_already_presented = recovery_checkpoint != null,
|
||||
// An ACP client keeps what the turn sent before it stopped; an engine
|
||||
// in a new process says it holds none of it.
|
||||
.recovery_source_already_presented = recovery_checkpoint != null and !prompt_input.fresh_output,
|
||||
};
|
||||
|
||||
// A turn whose completions report no tokens sends no `usage_update`.
|
||||
@@ -1486,6 +1528,8 @@ const PendingPromptImage = struct {
|
||||
bytes: []u8,
|
||||
media_type: []u8,
|
||||
source_ref: ?[]u8 = null,
|
||||
/// The name the host stored the bytes under before it sent them.
|
||||
stored_name: ?session_codec.ImageName = null,
|
||||
|
||||
fn deinit(self: *PendingPromptImage, alloc: Allocator) void {
|
||||
alloc.free(self.bytes);
|
||||
@@ -1502,6 +1546,9 @@ const max_host_time_ms: i64 = (1 << 53) - 1;
|
||||
const ParsedPromptInput = struct {
|
||||
text: []u8,
|
||||
continue_recovery: bool = false,
|
||||
/// A resume whose host holds none of the turn's earlier output, such as
|
||||
/// an engine in a new process: the turn sends its saved reply again.
|
||||
fresh_output: bool = false,
|
||||
/// The follow-up this prompt runs, which its first progress places.
|
||||
input_id: ?[]u8 = null,
|
||||
/// The host's id for the turn this prompt starts.
|
||||
@@ -1582,6 +1629,16 @@ const ParsedPromptInput = struct {
|
||||
self.images = images;
|
||||
}
|
||||
|
||||
/// Tells `journal` which captured images the host stored before it sent
|
||||
/// them, so its events name them. `captureImagesInline` keeps the order.
|
||||
fn noteStoredImages(self: *const ParsedPromptInput, session_alloc: Allocator, journal: *libfx_journal.Journal) Allocator.Error!void {
|
||||
std.debug.assert(self.images.len == self.pending_images.len);
|
||||
for (self.images, self.pending_images) |image, pending| {
|
||||
const name = pending.stored_name orelse continue;
|
||||
try journal.noteStoredImage(session_alloc, image, name);
|
||||
}
|
||||
}
|
||||
|
||||
fn retainImageSnapshots(self: *ParsedPromptInput) void {
|
||||
self.retain_image_snapshots = true;
|
||||
}
|
||||
@@ -1694,6 +1751,7 @@ fn parsePromptInputWithFirstImageId(
|
||||
if (parsed.value != .object) return .{ .text = try alloc.dupe(u8, "") };
|
||||
|
||||
const continue_recovery = acp_types.fxMetaBool(parsed.value.object, "continueRecovery") orelse false;
|
||||
const fresh_output = acp_types.fxMetaBool(parsed.value.object, "freshOutput") orelse false;
|
||||
const input_id: ?[]const u8 = if (acp_types.fxMetaField(parsed.value.object, "inputId")) |value|
|
||||
if (value == .string and libfx_steering.validInputId(value.string)) value.string else null
|
||||
else
|
||||
@@ -1711,8 +1769,8 @@ fn parsePromptInputWithFirstImageId(
|
||||
null;
|
||||
|
||||
const prompt_arr = parsed.value.object.get("prompt") orelse
|
||||
return .{ .text = try alloc.dupe(u8, ""), .continue_recovery = continue_recovery };
|
||||
if (prompt_arr != .array) return .{ .text = try alloc.dupe(u8, ""), .continue_recovery = continue_recovery };
|
||||
return .{ .text = try alloc.dupe(u8, ""), .continue_recovery = continue_recovery, .fresh_output = fresh_output };
|
||||
if (prompt_arr != .array) return .{ .text = try alloc.dupe(u8, ""), .continue_recovery = continue_recovery, .fresh_output = fresh_output };
|
||||
|
||||
var text_buf: std.ArrayList(u8) = .empty;
|
||||
defer text_buf.deinit(alloc);
|
||||
@@ -1764,6 +1822,11 @@ fn parsePromptInputWithFirstImageId(
|
||||
break :bytes try alloc.alloc(u8, 0);
|
||||
};
|
||||
errdefer alloc.free(decoded);
|
||||
// A host with an image store names the bytes it stored first.
|
||||
const stored_name: ?session_codec.ImageName = if (acp_types.fxMetaField(block.object, "stored")) |value| name: {
|
||||
if (value != .string or decoded.len == 0) return error.InvalidPromptImage;
|
||||
break :name session_codec.imageName(value.string) orelse return error.InvalidPromptImage;
|
||||
} else null;
|
||||
|
||||
const image_id = std.math.add(usize, first_image_id, pending_images.items.len) catch
|
||||
return error.ImageIdOverflow;
|
||||
@@ -1782,6 +1845,7 @@ fn parsePromptInputWithFirstImageId(
|
||||
.bytes = decoded,
|
||||
.media_type = media_type,
|
||||
.source_ref = source_ref,
|
||||
.stored_name = stored_name,
|
||||
});
|
||||
} else if (std.mem.eql(u8, block_type.string, "resource")) {
|
||||
if (block.object.get("resource")) |resource| {
|
||||
@@ -1835,6 +1899,7 @@ fn parsePromptInputWithFirstImageId(
|
||||
var result = ParsedPromptInput{
|
||||
.text = try alloc.dupe(u8, text_buf.items),
|
||||
.continue_recovery = continue_recovery,
|
||||
.fresh_output = fresh_output,
|
||||
.input_accepted = input_accepted,
|
||||
.yield_at_ms = yield_at_ms,
|
||||
};
|
||||
@@ -2602,9 +2667,9 @@ fn completeAuthorizedToolCallTransport(
|
||||
}
|
||||
|
||||
/// Records a finished call's result in a journaled libfx session, so a
|
||||
/// crash before the next progress answers it with what the model sees
|
||||
/// instead of with "may have partly run". Nothing waits for it. A cancelled
|
||||
/// call did not finish and stays unanswered; a result's images are not kept.
|
||||
/// crash before the next progress answers it with what the model sees,
|
||||
/// its images too, instead of with "may have partly run". Nothing waits for
|
||||
/// it. A cancelled call did not finish and stays unanswered.
|
||||
fn recordJournalToolResult(ctx: *AcpContext, call: ToolCall, result: ToolExecutionResult) void {
|
||||
if (result.cancelled) return;
|
||||
const session = if (ctx.state.active_session) |*value| value else return;
|
||||
@@ -2616,6 +2681,7 @@ fn recordJournalToolResult(ctx: *AcpContext, call: ToolCall, result: ToolExecuti
|
||||
.name = call.name,
|
||||
.failed = result.status == .failure,
|
||||
.output = result.model_output,
|
||||
.images = if (result.tool_result_memory) |memory| memory.tool_images else &.{},
|
||||
}) catch |err| {
|
||||
debug_trace.logf("session", "event=libfx_journal_tool_result_dropped call={s} err={s} answered_by=next_progress", .{ call.id, @errorName(err) });
|
||||
};
|
||||
@@ -2939,6 +3005,7 @@ fn persistAcpHistoryTurn(
|
||||
session.session_rt.commitPreparedHistoryEntry(alloc, prepared);
|
||||
prepared_owned = false;
|
||||
if (current_prompt_input) |prompt_input| prompt_input.retainImageSnapshots();
|
||||
try keepImageWindow(alloc, session);
|
||||
if (session.wasm_state != null) try sessions.commitWasmSessionLocked(alloc, session);
|
||||
return;
|
||||
}
|
||||
@@ -2958,6 +3025,7 @@ fn persistAcpHistoryTurn(
|
||||
session.session_rt.commitPreparedHistoryEntry(alloc, prepared);
|
||||
prepared_owned = false;
|
||||
if (current_prompt_input) |prompt_input| prompt_input.retainImageSnapshots();
|
||||
try keepImageWindow(alloc, session);
|
||||
return;
|
||||
};
|
||||
try writable.prepareHistoryTurnForCommit(alloc, &prepared);
|
||||
@@ -2981,6 +3049,15 @@ fn persistAcpHistoryTurn(
|
||||
if (current_prompt_input) |prompt_input| prompt_input.retainImageSnapshots();
|
||||
}
|
||||
|
||||
/// Keeps a libfx session within its image window once a turn enters its
|
||||
/// history (owned by `alloc`): its images are the newest, and older ones past
|
||||
/// the window give up their bytes. A turn that fails before it is recorded
|
||||
/// drops nothing, so a reopen keeps the same images.
|
||||
fn keepImageWindow(alloc: Allocator, session: *server.ActiveSessionState) Allocator.Error!void {
|
||||
const journal = if (session.journal) |*value| value else return;
|
||||
try journal.dropOutsideWindow(alloc, alloc, session.session_rt.agent.history.items);
|
||||
}
|
||||
|
||||
fn commitContextCompaction(
|
||||
raw_ctx: *anyopaque,
|
||||
summary: types.CompactedSummaryHistoryTurn,
|
||||
@@ -3247,6 +3324,7 @@ fn pushToolLifecycle(raw_ctx: *anyopaque, event: types.ToolLifecycleEvent) !void
|
||||
.id = started.id.call_id,
|
||||
.name = started.tool_name,
|
||||
.arguments_json = started.arguments_json orelse "{}",
|
||||
.resolved_display_target = started.resolved_display_target,
|
||||
});
|
||||
},
|
||||
.terminal => |terminal| {
|
||||
@@ -4051,9 +4129,20 @@ test "parsePromptInput accepts explicit recovery continuation metadata" {
|
||||
defer result.deinit(alloc);
|
||||
try std.testing.expectEqual(@as(usize, 0), result.text.len);
|
||||
try std.testing.expect(result.continue_recovery);
|
||||
try std.testing.expect(!result.fresh_output);
|
||||
try std.testing.expectEqual(@as(usize, 0), result.rerun_call_ids.len);
|
||||
}
|
||||
|
||||
test "parsePromptInput reads whether a resume's host holds the turn's earlier output" {
|
||||
const alloc = std.testing.allocator;
|
||||
const params =
|
||||
"{\"sessionId\":\"s1\",\"prompt\":[],\"_meta\":{\"fx\":{\"continueRecovery\":true,\"freshOutput\":true}}}";
|
||||
var result = try parsePromptInput(alloc, params);
|
||||
defer result.deinit(alloc);
|
||||
try std.testing.expect(result.continue_recovery);
|
||||
try std.testing.expect(result.fresh_output);
|
||||
}
|
||||
|
||||
test "parsePromptInput keeps the call ids a resume runs again and drops the rest" {
|
||||
const alloc = std.testing.allocator;
|
||||
const params =
|
||||
|
||||
+188
-5
@@ -51,6 +51,7 @@ const host_tool_runtime = @import("../core/tooling/host_tool_runtime.zig");
|
||||
const tool_dispatch = @import("../core/tooling/tool_dispatch.zig");
|
||||
const agent_checkpoint = @import("../core/agent/runtime/checkpoint.zig");
|
||||
const host_attachments = @import("../core/hosts/host_attachments.zig");
|
||||
const image_attachments = @import("../core/images/image_attachments.zig");
|
||||
const libfx_steering = @import("libfx_steering.zig");
|
||||
const journal_events = @import("../core/agent/runtime/journal.zig");
|
||||
const libfx_journal = @import("libfx_journal.zig");
|
||||
@@ -87,6 +88,7 @@ const AcpMethod = enum {
|
||||
libfx_follow_up,
|
||||
libfx_snapshot,
|
||||
libfx_journal_open,
|
||||
libfx_journal_images,
|
||||
mcp_message,
|
||||
unknown,
|
||||
|
||||
@@ -112,6 +114,7 @@ const AcpMethod = enum {
|
||||
if (std.mem.eql(u8, method, "libfx/follow_up")) return .libfx_follow_up;
|
||||
if (std.mem.eql(u8, method, "libfx/snapshot")) return .libfx_snapshot;
|
||||
if (std.mem.eql(u8, method, "libfx/journal_open")) return .libfx_journal_open;
|
||||
if (std.mem.eql(u8, method, "libfx/journal_images")) return .libfx_journal_images;
|
||||
if (std.mem.eql(u8, method, "mcp/message")) return .mcp_message;
|
||||
return .unknown;
|
||||
}
|
||||
@@ -140,6 +143,7 @@ const AcpMethod = enum {
|
||||
.libfx_restore,
|
||||
.libfx_snapshot,
|
||||
.libfx_journal_open,
|
||||
.libfx_journal_images,
|
||||
.mcp_message,
|
||||
.unknown,
|
||||
=> true,
|
||||
@@ -148,7 +152,7 @@ const AcpMethod = enum {
|
||||
|
||||
fn isLibfx(self: AcpMethod) bool {
|
||||
return switch (self) {
|
||||
.libfx_checkpoint, .libfx_restore, .libfx_new, .libfx_steer, .libfx_withdraw, .libfx_handoff, .libfx_follow_up, .libfx_snapshot, .libfx_journal_open => true,
|
||||
.libfx_checkpoint, .libfx_restore, .libfx_new, .libfx_steer, .libfx_withdraw, .libfx_handoff, .libfx_follow_up, .libfx_snapshot, .libfx_journal_open, .libfx_journal_images => true,
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
@@ -268,6 +272,13 @@ pub const ActiveSessionState = struct {
|
||||
steering: libfx_steering.Runtime = .{},
|
||||
/// Set when the host passed a journal; every state change is sent to it.
|
||||
journal: ?libfx_journal.Journal = null,
|
||||
/// Images the host staged from its store for the journal it opens next.
|
||||
/// Owned by the server allocator; cleared once the journal opens.
|
||||
staged_images: session_codec.StagedImages = .{},
|
||||
/// What the last open that found images missing needs staged, all told:
|
||||
/// an open turn's tool images can take a session past one window and one
|
||||
/// prompt, and what a running session held must reopen.
|
||||
staging_needed: StagedTotals = .{},
|
||||
|
||||
pub fn retainGrant(self: *ActiveSessionState, alloc: Allocator, tool_name: []const u8, target_path: []const u8) !void {
|
||||
for (self.session_grants) |grant| {
|
||||
@@ -756,6 +767,7 @@ fn destroyActiveSession(state: *ServerState) void {
|
||||
active.tool_identities.deinit(state.alloc);
|
||||
active.steering.deinit(state.alloc);
|
||||
if (active.journal) |*journal| journal.deinit(state.alloc);
|
||||
active.staged_images.deinit(state.alloc);
|
||||
types.freePermissionGrantSlice(state.alloc, active.session_grants);
|
||||
if (comptime !host_target.is_wasm) {
|
||||
if (active.mcp) |runtime| {
|
||||
@@ -1451,6 +1463,7 @@ fn dispatch(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Message) !void
|
||||
.libfx_follow_up => handleKernelFollowUp(state, alloc, msg),
|
||||
.libfx_snapshot => handleKernelSnapshot(state, alloc, msg),
|
||||
.libfx_journal_open => handleKernelJournalOpen(state, alloc, msg),
|
||||
.libfx_journal_images => handleKernelJournalImages(state, alloc, msg),
|
||||
else => state.writer.writeError(alloc, msg.id, .{
|
||||
.code = ErrorCode.method_not_found,
|
||||
.message = "Method not available in the web core yet",
|
||||
@@ -1476,6 +1489,7 @@ fn dispatch(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Message) !void
|
||||
.libfx_follow_up => handleKernelFollowUp(state, alloc, msg),
|
||||
.libfx_snapshot => handleKernelSnapshot(state, alloc, msg),
|
||||
.libfx_journal_open => handleKernelJournalOpen(state, alloc, msg),
|
||||
.libfx_journal_images => handleKernelJournalImages(state, alloc, msg),
|
||||
.initialize,
|
||||
.request_cancel,
|
||||
.session_cancel,
|
||||
@@ -1894,6 +1908,21 @@ fn handleKernelJournalOpen(
|
||||
.message = "Invalid libfx journal params",
|
||||
}),
|
||||
} else false;
|
||||
// A host with an image store stores each prompt image before it sends
|
||||
// it, so the journal names stored images instead of holding their bytes.
|
||||
const image_store = if (parsed.value.object.get("imageStore")) |value| switch (value) {
|
||||
.bool => |enabled| enabled,
|
||||
else => return state.writer.writeError(alloc, msg.id, .{
|
||||
.code = ErrorCode.invalid_params,
|
||||
.message = "Invalid libfx journal params",
|
||||
}),
|
||||
} else false;
|
||||
const window = imageWindowParam(parsed.value.object.get("imageWindow")) orelse return state.writer.writeError(alloc, msg.id, .{
|
||||
.code = ErrorCode.invalid_params,
|
||||
.message = "Invalid libfx journal params",
|
||||
});
|
||||
var images: session_codec.ImageResolver = .{ .alloc = alloc, .staged = &active.staged_images };
|
||||
defer images.deinit();
|
||||
const journal, const opened = libfx_journal.open(
|
||||
alloc,
|
||||
state.alloc,
|
||||
@@ -1902,8 +1931,21 @@ fn handleKernelJournalOpen(
|
||||
events orelse "[]",
|
||||
snapshot,
|
||||
inputs_durable,
|
||||
&images,
|
||||
image_store,
|
||||
window,
|
||||
) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
// The host stages them from its store, then opens again.
|
||||
error.ImagesMissing => {
|
||||
var needed: StagedTotals = .{ .images = active.staged_images.images.count(), .bytes = active.staged_images.bytes };
|
||||
for (images.missing.values()) |size| {
|
||||
needed.images +|= 1;
|
||||
needed.bytes +|= size;
|
||||
}
|
||||
active.staging_needed = needed;
|
||||
return writeImagesNeeded(state, alloc, msg.id, images.missing.keys());
|
||||
},
|
||||
error.AgentNotFresh => return state.writer.writeError(alloc, msg.id, .{
|
||||
.code = ErrorCode.invalid_request,
|
||||
.message = "A libfx journal opens only on a new session",
|
||||
@@ -1925,6 +1967,9 @@ fn handleKernelJournalOpen(
|
||||
};
|
||||
defer opened.checkpoint_meta.free(alloc);
|
||||
active.journal = journal;
|
||||
// The history holds its own copies of every staged image it named.
|
||||
active.staged_images.clear(state.alloc);
|
||||
active.staging_needed = .{};
|
||||
// The host runs the follow-ups the journal held, after any resume.
|
||||
const follow_ups = active.journal.?.takeFollowUps();
|
||||
defer journal_events.freePendingInputs(state.alloc, follow_ups);
|
||||
@@ -1980,6 +2025,124 @@ fn handleKernelJournalOpen(
|
||||
try state.writer.writeResponse(alloc, msg.id, response.written());
|
||||
}
|
||||
|
||||
/// Answers an open whose saved history names images the host has not
|
||||
/// staged: every such name, so one round of staging serves the next open.
|
||||
fn writeImagesNeeded(state: *ServerState, alloc: Allocator, id: ?jsonrpc.RequestId, names: []const session_codec.ImageName) !void {
|
||||
var response: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer response.deinit();
|
||||
try response.writer.writeAll("{\"imagesNeeded\":[");
|
||||
for (names, 0..) |name, index| {
|
||||
if (index > 0) try response.writer.writeByte(',');
|
||||
try response.writer.print("\"{s}\"", .{&name});
|
||||
}
|
||||
try response.writer.writeAll("]}");
|
||||
try state.writer.writeResponse(alloc, id, response.written());
|
||||
}
|
||||
|
||||
/// The most images, and image bytes, a host may stage for one open: those of
|
||||
/// the largest image window, the newest images a session keeps, and those of
|
||||
/// the open turn, which the window leaves out and which holds one prompt's
|
||||
/// images at most.
|
||||
/// An open that needs more, because its open turn's tools returned images
|
||||
/// beyond one prompt's, may stage what it reported missing
|
||||
/// (`ActiveSessionState.staging_needed`).
|
||||
const max_staged_images = (session_codec.ImageWindow{}).max_images + image_attachments.max_libfx_prompt_images;
|
||||
const max_staged_image_bytes = (session_codec.ImageWindow{}).max_bytes + image_attachments.max_libfx_prompt_images_bytes;
|
||||
|
||||
const StagedTotals = struct { images: usize = 0, bytes: usize = 0 };
|
||||
|
||||
/// The image window an open asks for, `{images, bytes}`, each at most the
|
||||
/// default: a host may keep fewer images, never more than an open stages.
|
||||
/// Null when the value is not one.
|
||||
fn imageWindowParam(value: ?std.json.Value) ?session_codec.ImageWindow {
|
||||
const defaults: session_codec.ImageWindow = .{};
|
||||
const object = switch (value orelse return defaults) {
|
||||
.object => |object| object,
|
||||
else => return null,
|
||||
};
|
||||
var window = defaults;
|
||||
var iterator = object.iterator();
|
||||
while (iterator.next()) |entry| {
|
||||
const limit = switch (entry.value_ptr.*) {
|
||||
.integer => |integer| std.math.cast(usize, integer) orelse return null,
|
||||
else => return null,
|
||||
};
|
||||
if (limit == 0) return null;
|
||||
if (std.mem.eql(u8, entry.key_ptr.*, "images")) {
|
||||
window.max_images = @min(limit, defaults.max_images);
|
||||
} else if (std.mem.eql(u8, entry.key_ptr.*, "bytes")) {
|
||||
window.max_bytes = @min(limit, defaults.max_bytes);
|
||||
} else return null;
|
||||
}
|
||||
return window;
|
||||
}
|
||||
|
||||
/// Stages images from the host's store for the journal the session opens
|
||||
/// next. `images` is `[{name, attachment}]`; each attachment holds the bytes
|
||||
/// whose SHA-256 is the name.
|
||||
fn handleKernelJournalImages(
|
||||
state: *ServerState,
|
||||
alloc: Allocator,
|
||||
msg: *const jsonrpc.Message,
|
||||
) !void {
|
||||
const invalid: jsonrpc.RpcError = .{
|
||||
.code = ErrorCode.invalid_params,
|
||||
.message = "Invalid libfx journal images",
|
||||
};
|
||||
const too_large: jsonrpc.RpcError = .{
|
||||
.code = ErrorCode.invalid_params,
|
||||
.message = "libfx journal images are too large",
|
||||
};
|
||||
var parsed = libfxSessionId(alloc, msg) catch return state.writer.writeError(alloc, msg.id, invalid);
|
||||
defer parsed.deinit();
|
||||
const active = activeLibfxSession(state, parsed.value) orelse
|
||||
return state.writer.writeError(alloc, msg.id, .{
|
||||
.code = ErrorCode.invalid_params,
|
||||
.message = "Unknown libfx session",
|
||||
});
|
||||
const store = state.cfg.host_attachments orelse return state.writer.writeError(alloc, msg.id, invalid);
|
||||
const list = parsed.value.object.get("images") orelse return state.writer.writeError(alloc, msg.id, invalid);
|
||||
if (list != .array or list.array.items.len == 0) return state.writer.writeError(alloc, msg.id, invalid);
|
||||
|
||||
active.session_write_mutex.lockUncancelable(io_mod.getIo());
|
||||
defer active.session_write_mutex.unlock(io_mod.getIo());
|
||||
if (active.journal != null) return state.writer.writeError(alloc, msg.id, .{
|
||||
.code = ErrorCode.invalid_request,
|
||||
.message = "libfx journal is already open",
|
||||
});
|
||||
for (list.array.items) |item| {
|
||||
if (item != .object) return state.writer.writeError(alloc, msg.id, invalid);
|
||||
const name_value = item.object.get("name") orelse return state.writer.writeError(alloc, msg.id, invalid);
|
||||
if (name_value != .string) return state.writer.writeError(alloc, msg.id, invalid);
|
||||
const name = session_codec.imageName(name_value.string) orelse return state.writer.writeError(alloc, msg.id, invalid);
|
||||
const reference = item.object.get("attachment") orelse return state.writer.writeError(alloc, msg.id, invalid);
|
||||
const attachment = host_attachments.idFromJson(reference) orelse return state.writer.writeError(alloc, msg.id, invalid);
|
||||
if (active.staged_images.images.count() >= @max(max_staged_images, active.staging_needed.images)) return state.writer.writeError(alloc, msg.id, too_large);
|
||||
const bytes = store.take(state.alloc, attachment, image_attachments.max_image_bytes) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
error.AttachmentUnavailable => return state.writer.writeError(alloc, msg.id, invalid),
|
||||
error.AttachmentTooLarge => return state.writer.writeError(alloc, msg.id, too_large),
|
||||
};
|
||||
if (bytes.len > @max(max_staged_image_bytes, active.staging_needed.bytes) -| active.staged_images.bytes) {
|
||||
state.alloc.free(bytes);
|
||||
return state.writer.writeError(alloc, msg.id, too_large);
|
||||
}
|
||||
active.staged_images.add(state.alloc, name, bytes) catch |err| {
|
||||
state.alloc.free(bytes);
|
||||
return switch (err) {
|
||||
error.OutOfMemory => error.OutOfMemory,
|
||||
error.ImageNameMismatch => state.writer.writeError(alloc, msg.id, .{
|
||||
.code = ErrorCode.invalid_params,
|
||||
.message = "libfx image does not match its name",
|
||||
}),
|
||||
};
|
||||
};
|
||||
}
|
||||
const written = try alloc.print("{{\"staged\":{d}}}", .{active.staged_images.images.count()});
|
||||
defer alloc.free(written);
|
||||
try state.writer.writeResponse(alloc, msg.id, written);
|
||||
}
|
||||
|
||||
/// A snapshot of the session for its journal to store, when it is quiet (no
|
||||
/// turn open and no follow-up waiting) or at a turn that yielded with nothing
|
||||
/// waiting for it, which the snapshot then carries. `null` otherwise; the
|
||||
@@ -2012,8 +2175,14 @@ fn handleKernelSnapshot(
|
||||
const yielded = journal.yieldedQuiet();
|
||||
if (!journal.quiet() and !yielded) return state.writer.writeResponse(alloc, msg.id, none);
|
||||
// A session past a snapshot's bounds gets none until compaction brings it
|
||||
// back within them; the host learns why.
|
||||
const checkpoint = active.session_rt.agent.checkpoint(alloc, meta) catch |err| switch (err) {
|
||||
// back within them; the host learns why. Images the host stored are
|
||||
// named, not held.
|
||||
const stored = journal.storedImages();
|
||||
const encoded = if (stored) |names|
|
||||
active.session_rt.agent.checkpointNamingImages(alloc, meta, names)
|
||||
else
|
||||
active.session_rt.agent.checkpoint(alloc, meta);
|
||||
const checkpoint = encoded catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
error.CheckpointTooLarge => return writeSnapshotSkipped(state, alloc, msg.id, if (active.session_rt.agent.history.items.len > journal_events.max_history_turns) .too_many_turns else .too_large),
|
||||
else => return writeSnapshotSkipped(state, alloc, msg.id, .invalid),
|
||||
@@ -2022,8 +2191,21 @@ fn handleKernelSnapshot(
|
||||
const at_seq = journal.cursor.next_seq - 1;
|
||||
var open_turn: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer open_turn.deinit();
|
||||
if (yielded) try journal_events.writeOpenTurn(&open_turn.writer, journal.open_turn_id.slice(), journal.pending_resume.?);
|
||||
const bytes = journal_events.encodeSnapshot(alloc, at_seq, journal.cursor.turn, journal.last_turn_id.slice(), if (yielded) open_turn.written() else null, checkpoint) catch |err| switch (err) {
|
||||
var open_turn_names_images = false;
|
||||
if (yielded) {
|
||||
if (stored) |names| {
|
||||
open_turn_names_images = try journal_events.writeOpenTurnNamingImages(&open_turn.writer, journal.open_turn_id.slice(), journal.pending_resume.?, names);
|
||||
} else {
|
||||
try journal_events.writeOpenTurn(&open_turn.writer, journal.open_turn_id.slice(), journal.pending_resume.?);
|
||||
// An image the session dropped is written by name even here.
|
||||
open_turn_names_images = session_codec.checkpointNamesImages(journal.pending_resume.?, null, null);
|
||||
}
|
||||
}
|
||||
const snapshot_bytes = if (open_turn_names_images)
|
||||
journal_events.encodeSnapshotNamingImages(alloc, at_seq, journal.cursor.turn, journal.last_turn_id.slice(), open_turn.written(), checkpoint)
|
||||
else
|
||||
journal_events.encodeSnapshot(alloc, at_seq, journal.cursor.turn, journal.last_turn_id.slice(), if (yielded) open_turn.written() else null, checkpoint);
|
||||
const bytes = snapshot_bytes catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
error.SnapshotTooLarge => return writeSnapshotSkipped(state, alloc, msg.id, .too_large),
|
||||
};
|
||||
@@ -2034,6 +2216,7 @@ fn handleKernelSnapshot(
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
error.AttachmentStoreFull => return state.writer.writeResponse(alloc, msg.id, none),
|
||||
};
|
||||
if (yielded) journal.noteOpenTurnSnapshot();
|
||||
const written = try alloc.print("{{\"snapshotAttachment\":{d},\"atSeq\":{d}}}", .{ attachment, at_seq });
|
||||
defer alloc.free(written);
|
||||
try state.writer.writeResponse(alloc, msg.id, written);
|
||||
|
||||
+200
-23
@@ -26,6 +26,7 @@ const session_runtime = @import("../core/session/session.zig");
|
||||
const agent_execution_memory = @import("../core/agent/execution_memory.zig");
|
||||
const tool_dispatch = @import("../core/tooling/tool_dispatch.zig");
|
||||
const tool_call_presentation = @import("tool_call_presentation.zig");
|
||||
const runtime_tool_presentation = @import("../core/agent/runtime/tool_presentation.zig");
|
||||
const mcp_runtime = @import("../core/mcp/mcp_runtime.zig");
|
||||
const mcp_host = @import("../core/mcp_host/host.zig");
|
||||
const mcp_contract = @import("../core/mcp/mcp_contract.zig");
|
||||
@@ -730,7 +731,9 @@ pub fn handleLoadWasmSession(state: *server.ServerState, alloc: Allocator, msg:
|
||||
|
||||
if (state.active_session) |*active| {
|
||||
if (sameSessionId(active.session_id, session_id)) {
|
||||
for (active.session_rt.agent.history.items) |turn| try sendHistoryTurnAsUpdates(state, alloc, session_id, turn);
|
||||
var sources: runtime_tool_presentation.ReplayedSavedOutputSources = .{};
|
||||
defer sources.deinit(alloc);
|
||||
for (active.session_rt.agent.history.items) |turn| try sendHistoryTurnAsUpdates(state, alloc, session_id, &sources, turn);
|
||||
try sendActiveSessionInfoUpdate(state, alloc);
|
||||
return writeLoadSessionResponse(state, alloc, msg, active.model, observer_hooks);
|
||||
}
|
||||
@@ -793,7 +796,9 @@ pub fn handleLoadWasmSession(state: *server.ServerState, alloc: Allocator, msg:
|
||||
sid_owned = false;
|
||||
model_owned = false;
|
||||
session_rt_owned = false;
|
||||
for (state.active_session.?.session_rt.agent.history.items) |turn| try sendHistoryTurnAsUpdates(state, alloc, session_id, turn);
|
||||
var sources: runtime_tool_presentation.ReplayedSavedOutputSources = .{};
|
||||
defer sources.deinit(alloc);
|
||||
for (state.active_session.?.session_rt.agent.history.items) |turn| try sendHistoryTurnAsUpdates(state, alloc, session_id, &sources, turn);
|
||||
try sendActiveSessionInfoUpdate(state, alloc);
|
||||
try writeLoadSessionResponse(state, alloc, msg, state.active_session.?.model, observer_hooks);
|
||||
}
|
||||
@@ -974,13 +979,17 @@ fn handleRestoreSession(
|
||||
}
|
||||
if (previous_host) |h| h.stop();
|
||||
server.enableSubagentHost(state);
|
||||
// One map, so a recovered read can name a call from an earlier turn.
|
||||
var sources: runtime_tool_presentation.ReplayedSavedOutputSources = .{};
|
||||
defer sources.deinit(alloc);
|
||||
if (kind.replaysHistory()) {
|
||||
try sendActiveHistoryUpdates(state, alloc, session_id);
|
||||
try sendActiveHistoryUpdates(state, alloc, session_id, &sources);
|
||||
}
|
||||
try sendPendingRecoveryUpdate(
|
||||
state,
|
||||
alloc,
|
||||
session_id,
|
||||
&sources,
|
||||
if (active.writable) |*writable| writable.state.recovery_checkpoint else null,
|
||||
);
|
||||
try sendActiveSessionInfoUpdate(state, alloc);
|
||||
@@ -1140,13 +1149,17 @@ fn handleRestoreSession(
|
||||
session_rt_owned = false;
|
||||
session_mcp_owned = false;
|
||||
session_host_owned = false;
|
||||
// One map, so a recovered read can name a call from an earlier turn.
|
||||
var sources: runtime_tool_presentation.ReplayedSavedOutputSources = .{};
|
||||
defer sources.deinit(alloc);
|
||||
if (kind.replaysHistory()) {
|
||||
try sendActiveHistoryUpdates(state, alloc, session_id);
|
||||
try sendActiveHistoryUpdates(state, alloc, session_id, &sources);
|
||||
}
|
||||
try sendPendingRecoveryUpdate(
|
||||
state,
|
||||
alloc,
|
||||
session_id,
|
||||
&sources,
|
||||
if (state.active_session.?.writable) |*value| value.state.recovery_checkpoint else null,
|
||||
);
|
||||
try sendActiveSessionInfoUpdate(state, alloc);
|
||||
@@ -1293,11 +1306,12 @@ fn sendPendingRecoveryUpdate(
|
||||
state: *server.ServerState,
|
||||
alloc: Allocator,
|
||||
session_id: []const u8,
|
||||
sources: *runtime_tool_presentation.ReplayedSavedOutputSources,
|
||||
checkpoint: ?session_codec.RecoveryCheckpoint,
|
||||
) !void {
|
||||
const recovery = checkpoint orelse return;
|
||||
try sendUserHistoryTurn(state, alloc, session_id, recovery.user);
|
||||
try sendExecutionHistory(state, alloc, session_id, recovery.execution);
|
||||
try sendExecutionHistory(state, alloc, session_id, sources, recovery.execution);
|
||||
if (recovery.assistant_source.len > 0) {
|
||||
try sendAgentHistoryChunk(state, alloc, session_id, recovery.assistant_source);
|
||||
}
|
||||
@@ -1882,19 +1896,25 @@ fn parseListCursor(raw: []const u8) !session_store.ResumableSessionContinuation
|
||||
return .{ .updated_at_ms = updated_at_ms, .id = id };
|
||||
}
|
||||
|
||||
fn sendActiveHistoryUpdates(state: *server.ServerState, alloc: Allocator, session_id: []const u8) !void {
|
||||
fn sendActiveHistoryUpdates(
|
||||
state: *server.ServerState,
|
||||
alloc: Allocator,
|
||||
session_id: []const u8,
|
||||
sources: *runtime_tool_presentation.ReplayedSavedOutputSources,
|
||||
) !void {
|
||||
const active = &state.active_session.?;
|
||||
if (active.store) |store| {
|
||||
const Visitor = struct {
|
||||
state: *server.ServerState,
|
||||
alloc: Allocator,
|
||||
session_id: []const u8,
|
||||
sources: *runtime_tool_presentation.ReplayedSavedOutputSources,
|
||||
|
||||
pub fn append(self: *@This(), turn: types.HistoryTurn) !void {
|
||||
try sendHistoryTurnAsUpdates(self.state, self.alloc, self.session_id, turn);
|
||||
try sendHistoryTurnAsUpdates(self.state, self.alloc, self.session_id, self.sources, turn);
|
||||
}
|
||||
};
|
||||
var visitor = Visitor{ .state = state, .alloc = alloc, .session_id = session_id };
|
||||
var visitor = Visitor{ .state = state, .alloc = alloc, .session_id = session_id, .sources = sources };
|
||||
return store.visitConversationHistory(alloc, session_id, &visitor);
|
||||
}
|
||||
// Every saved turn, as v1 replays it, not only those in memory.
|
||||
@@ -1903,20 +1923,27 @@ fn sendActiveHistoryUpdates(state: *server.ServerState, alloc: Allocator, sessio
|
||||
state: *server.ServerState,
|
||||
alloc: Allocator,
|
||||
session_id: []const u8,
|
||||
sources: *runtime_tool_presentation.ReplayedSavedOutputSources,
|
||||
|
||||
pub fn append(self: *@This(), turn: types.HistoryTurn) !void {
|
||||
try sendHistoryTurnAsUpdates(self.state, self.alloc, self.session_id, turn);
|
||||
try sendHistoryTurnAsUpdates(self.state, self.alloc, self.session_id, self.sources, turn);
|
||||
}
|
||||
};
|
||||
var visitor = Visitor{ .state = state, .alloc = alloc, .session_id = session_id };
|
||||
var visitor = Visitor{ .state = state, .alloc = alloc, .session_id = session_id, .sources = sources };
|
||||
return v2.visitHistory(alloc, &visitor);
|
||||
}
|
||||
for (active.session_rt.agent.history.items) |turn| {
|
||||
try sendHistoryTurnAsUpdates(state, alloc, session_id, turn);
|
||||
try sendHistoryTurnAsUpdates(state, alloc, session_id, sources, turn);
|
||||
}
|
||||
}
|
||||
|
||||
fn sendHistoryTurnAsUpdates(state: *server.ServerState, alloc: Allocator, session_id: []const u8, turn: types.HistoryTurn) !void {
|
||||
fn sendHistoryTurnAsUpdates(
|
||||
state: *server.ServerState,
|
||||
alloc: Allocator,
|
||||
session_id: []const u8,
|
||||
sources: *runtime_tool_presentation.ReplayedSavedOutputSources,
|
||||
turn: types.HistoryTurn,
|
||||
) !void {
|
||||
switch (turn) {
|
||||
.assistant => |assistant| try sendUserHistoryTurn(state, alloc, session_id, assistant.user),
|
||||
.interrupted => |interrupted| try sendUserHistoryTurn(state, alloc, session_id, interrupted.user),
|
||||
@@ -1925,11 +1952,11 @@ fn sendHistoryTurnAsUpdates(state: *server.ServerState, alloc: Allocator, sessio
|
||||
|
||||
switch (turn) {
|
||||
.assistant => |assistant| {
|
||||
try sendExecutionHistory(state, alloc, session_id, assistant.execution);
|
||||
try sendExecutionHistory(state, alloc, session_id, sources, assistant.execution);
|
||||
try sendAgentHistoryChunk(state, alloc, session_id, assistant.assistant);
|
||||
},
|
||||
.interrupted => |i| {
|
||||
try sendExecutionHistory(state, alloc, session_id, i.execution);
|
||||
try sendExecutionHistory(state, alloc, session_id, sources, i.execution);
|
||||
if (i.assistant) |assistant| {
|
||||
if (assistant.len > 0) try sendAgentHistoryChunk(state, alloc, session_id, assistant);
|
||||
}
|
||||
@@ -2014,6 +2041,7 @@ fn sendExecutionHistory(
|
||||
state: *server.ServerState,
|
||||
alloc: Allocator,
|
||||
session_id: []const u8,
|
||||
sources: *runtime_tool_presentation.ReplayedSavedOutputSources,
|
||||
execution: types.ExecutionMemory,
|
||||
) !void {
|
||||
if (execution.isEmpty()) return;
|
||||
@@ -2024,6 +2052,11 @@ fn sendExecutionHistory(
|
||||
tool_call_presentation.activeToolRegistry(state),
|
||||
activeSessionMcp(state),
|
||||
if (state.active_session) |*active| &active.tool_identities else null,
|
||||
.{
|
||||
.alloc = alloc,
|
||||
.workspace_root = if (state.active_session) |*active| active.workspace_root else state.workspace_root,
|
||||
.sources = sources,
|
||||
},
|
||||
execution,
|
||||
);
|
||||
for (frames) |frame| {
|
||||
@@ -2068,14 +2101,37 @@ fn findToolResultIndex(results: []const types.PersistedToolResult, call_id: []co
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Calls whose saved output later replayed reads name, kept across the turns
|
||||
/// of one replay.
|
||||
const ReplaySources = struct {
|
||||
alloc: Allocator,
|
||||
workspace_root: []const u8,
|
||||
sources: *runtime_tool_presentation.ReplayedSavedOutputSources,
|
||||
};
|
||||
|
||||
fn planToolCallFrame(
|
||||
arena: Allocator,
|
||||
registry: tool_dispatch.Registry,
|
||||
mcp: ?*mcp_runtime.McpRuntime,
|
||||
identities: ?*tool_call_identities.Record,
|
||||
call: types.ToolCall,
|
||||
replay_sources: ReplaySources,
|
||||
replayed_call: types.ToolCall,
|
||||
result: ?types.PersistedToolResult,
|
||||
) !ReplayToolCall {
|
||||
var call = replayed_call;
|
||||
call.resolved_display_target = try runtime_tool_presentation.readToolResultDisplayTarget(
|
||||
arena,
|
||||
registry,
|
||||
call,
|
||||
replay_sources.sources,
|
||||
);
|
||||
if (result) |saved| try replay_sources.sources.record(
|
||||
replay_sources.alloc,
|
||||
registry,
|
||||
replay_sources.workspace_root,
|
||||
call,
|
||||
saved,
|
||||
);
|
||||
const name = tool_call_presentation.acpToolName(call.name);
|
||||
// MCP identity comes from the live catalog, or from the identity recorded
|
||||
// when the session made the call; it is never guessed from the exposed
|
||||
@@ -2138,6 +2194,7 @@ fn planExecutionReplay(
|
||||
registry: tool_dispatch.Registry,
|
||||
mcp: ?*mcp_runtime.McpRuntime,
|
||||
identities: ?*tool_call_identities.Record,
|
||||
replay_sources: ReplaySources,
|
||||
execution: types.ExecutionMemory,
|
||||
) ![]ReplayFrame {
|
||||
var frames: std.ArrayList(ReplayFrame) = .empty;
|
||||
@@ -2153,7 +2210,7 @@ fn planExecutionReplay(
|
||||
matched[i] = true;
|
||||
break :blk step.tool_results[i];
|
||||
} else null;
|
||||
try frames.append(arena, .{ .tool_call = try planToolCallFrame(arena, registry, mcp, identities, call, result) });
|
||||
try frames.append(arena, .{ .tool_call = try planToolCallFrame(arena, registry, mcp, identities, replay_sources, call, result) });
|
||||
}
|
||||
// Results without a surviving call record (e.g. trimmed history) still
|
||||
// replay as completed frames so the transcript stays faithful.
|
||||
@@ -2164,7 +2221,7 @@ fn planExecutionReplay(
|
||||
.name = result.tool_name,
|
||||
.arguments_json = "{}",
|
||||
};
|
||||
try frames.append(arena, .{ .tool_call = try planToolCallFrame(arena, registry, mcp, identities, call, result) });
|
||||
try frames.append(arena, .{ .tool_call = try planToolCallFrame(arena, registry, mcp, identities, replay_sources, call, result) });
|
||||
}
|
||||
steering_index = try appendSteeringReplay(arena, &frames, execution.steering, steering_index, step_index + 1);
|
||||
}
|
||||
@@ -2703,7 +2760,7 @@ test "ACP history excludes typed summaries without filtering original user text"
|
||||
var state = try initAcpSessionTestState(arena, workspace, capture);
|
||||
defer state.deinit();
|
||||
|
||||
try sendHistoryTurnAsUpdates(&state, arena, "session-1", .{ .compacted_summary = .{
|
||||
try sendTestHistoryTurn(&state, arena, "session-1", .{ .compacted_summary = .{
|
||||
.summary = @constCast("internal summary"),
|
||||
.removed_turn_count = 1,
|
||||
.compaction_count = 1,
|
||||
@@ -2711,7 +2768,7 @@ test "ACP history excludes typed summaries without filtering original user text"
|
||||
try std.testing.expectEqual(@as(u64, 0), try capture.length(io_mod.getIo()));
|
||||
|
||||
const original = "Explain <context_handoff> without hiding my question.";
|
||||
try sendHistoryTurnAsUpdates(&state, arena, "session-1", .{ .assistant = .{
|
||||
try sendTestHistoryTurn(&state, arena, "session-1", .{ .assistant = .{
|
||||
.user = .{ .text = @constCast(original) },
|
||||
.assistant = @constCast("original reply"),
|
||||
} });
|
||||
@@ -2745,7 +2802,7 @@ test "ACP interrupted history replay hides model-only abort context" {
|
||||
var state = try initAcpSessionTestState(arena, workspace, capture);
|
||||
defer state.deinit();
|
||||
var completed_tool_names = [_][]u8{@constCast("read_file")};
|
||||
try sendHistoryTurnAsUpdates(&state, arena, "session-1", .{ .interrupted = .{
|
||||
try sendTestHistoryTurn(&state, arena, "session-1", .{ .interrupted = .{
|
||||
.user = .{ .text = @constCast("inspect the project") },
|
||||
.completed_tool_names = completed_tool_names[0..],
|
||||
} });
|
||||
@@ -2811,7 +2868,7 @@ test "ACP history replay emits structured tool call frames" {
|
||||
.tool_calls = calls[0..],
|
||||
.tool_results = results[0..],
|
||||
}};
|
||||
try sendHistoryTurnAsUpdates(&state, arena, "session-1", .{ .assistant = .{
|
||||
try sendTestHistoryTurn(&state, arena, "session-1", .{ .assistant = .{
|
||||
.user = .{ .text = @constCast("read and write") },
|
||||
.assistant = @constCast("All done."),
|
||||
.execution = .{ .tool_steps = steps[0..] },
|
||||
@@ -2859,7 +2916,7 @@ test "ACP history replay leaves resultless tool calls pending" {
|
||||
.{ .id = "call_orphan", .name = "read_file", .arguments_json = "{\"path\":\"a.txt\"}" },
|
||||
};
|
||||
var steps = [_]types.ToolExecutionStep{.{ .tool_calls = calls[0..] }};
|
||||
try sendHistoryTurnAsUpdates(&state, arena, "session-1", .{ .interrupted = .{
|
||||
try sendTestHistoryTurn(&state, arena, "session-1", .{ .interrupted = .{
|
||||
.user = .{ .text = @constCast("inspect") },
|
||||
.execution = .{ .tool_steps = steps[0..] },
|
||||
} });
|
||||
@@ -2897,7 +2954,7 @@ test "ACP history replay redacts sensitive tool arguments" {
|
||||
.stored_output_bytes = 2,
|
||||
}};
|
||||
var steps = [_]types.ToolExecutionStep{.{ .tool_calls = calls[0..], .tool_results = results[0..] }};
|
||||
try sendHistoryTurnAsUpdates(&state, arena, "session-1", .{ .assistant = .{
|
||||
try sendTestHistoryTurn(&state, arena, "session-1", .{ .assistant = .{
|
||||
.user = .{ .text = @constCast("run it") },
|
||||
.assistant = @constCast("done"),
|
||||
.execution = .{ .tool_steps = steps[0..] },
|
||||
@@ -2942,11 +2999,14 @@ test "execution replay plan interleaves assistant text and orphan results" {
|
||||
.tool_results = results[0..],
|
||||
}};
|
||||
|
||||
var sources: runtime_tool_presentation.ReplayedSavedOutputSources = .{};
|
||||
defer sources.deinit(arena);
|
||||
const frames = try planExecutionReplay(
|
||||
arena,
|
||||
@import("../builtins/tools.zig").registry,
|
||||
null,
|
||||
null,
|
||||
.{ .alloc = arena, .workspace_root = "/workspace", .sources = &sources },
|
||||
.{ .tool_steps = steps[0..] },
|
||||
);
|
||||
try std.testing.expectEqual(@as(usize, 3), frames.len);
|
||||
@@ -2960,6 +3020,123 @@ test "execution replay plan interleaves assistant text and orphan results" {
|
||||
try std.testing.expect(frames[2].tool_call.raw_input != null);
|
||||
}
|
||||
|
||||
fn sendTestHistoryTurn(state: *server.ServerState, alloc: Allocator, session_id: []const u8, turn: types.HistoryTurn) !void {
|
||||
var sources: runtime_tool_presentation.ReplayedSavedOutputSources = .{};
|
||||
defer sources.deinit(alloc);
|
||||
return sendHistoryTurnAsUpdates(state, alloc, session_id, &sources, turn);
|
||||
}
|
||||
|
||||
test "execution replay titles reads with the call whose saved output they read" {
|
||||
const alloc = std.testing.allocator;
|
||||
var arena_state = std.heap.ArenaAllocator.init(alloc);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
const replay = "fx-command-replay-0123.bin";
|
||||
var run_calls = [_]types.ToolCall{
|
||||
.{ .id = "run", .name = "shell", .arguments_json = "{\"action\":\"run\",\"command\":\"zig build test\"}" },
|
||||
};
|
||||
var run_results = [_]types.PersistedToolResult{.{
|
||||
.tool_call_id = @constCast("run"),
|
||||
.tool_name = @constCast("shell"),
|
||||
.status = .success,
|
||||
.output = @constCast("{\"exit_code\":0}"),
|
||||
.output_bytes = 15,
|
||||
.stored_output_bytes = 15,
|
||||
.command_output_replay = .{ .available = .{ .handle = replay, .framed_bytes = 64 } },
|
||||
}};
|
||||
var read_calls = [_]types.ToolCall{
|
||||
.{ .id = "read", .name = "read_tool_result", .arguments_json = "{\"request\":{\"handle\":\"" ++ replay ++ "\",\"query\":\"error:\"}}" },
|
||||
};
|
||||
var read_results = [_]types.PersistedToolResult{.{
|
||||
.tool_call_id = @constCast("read"),
|
||||
.tool_name = @constCast("read_tool_result"),
|
||||
.status = .success,
|
||||
.output = @constCast("error: x"),
|
||||
.output_bytes = 8,
|
||||
.stored_output_bytes = 8,
|
||||
}};
|
||||
var steps = [_]types.ToolExecutionStep{
|
||||
.{ .tool_calls = run_calls[0..], .tool_results = run_results[0..] },
|
||||
.{ .tool_calls = read_calls[0..], .tool_results = read_results[0..] },
|
||||
};
|
||||
|
||||
var sources: runtime_tool_presentation.ReplayedSavedOutputSources = .{};
|
||||
defer sources.deinit(alloc);
|
||||
const registry = @import("../builtins/tools.zig").registry;
|
||||
const replay_sources: ReplaySources = .{ .alloc = alloc, .workspace_root = "/workspace", .sources = &sources };
|
||||
const frames = try planExecutionReplay(arena, registry, null, null, replay_sources, .{ .tool_steps = steps[0..] });
|
||||
try std.testing.expectEqual(@as(usize, 2), frames.len);
|
||||
try std.testing.expectEqualStrings("Searching zig build test output for \"error:\"", frames[1].tool_call.title);
|
||||
|
||||
// A later turn replayed with the same sources still names the command.
|
||||
var later_calls = [_]types.ToolCall{
|
||||
.{ .id = "later", .name = "read_tool_result", .arguments_json = "{\"request\":{\"handle\":\"" ++ replay ++ "\"}}" },
|
||||
};
|
||||
var later_steps = [_]types.ToolExecutionStep{.{ .tool_calls = later_calls[0..] }};
|
||||
const later = try planExecutionReplay(arena, registry, null, null, replay_sources, .{ .tool_steps = later_steps[0..] });
|
||||
try std.testing.expectEqualStrings("Reading more output from zig build test", later[0].tool_call.title);
|
||||
}
|
||||
|
||||
test "ACP load titles a recovered read with the call from an earlier turn" {
|
||||
const alloc = std.testing.allocator;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
var arena_state = std.heap.ArenaAllocator.init(alloc);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
const workspace = try io_mod.dirRealpathAlloc(alloc, tmp.dir, ".");
|
||||
defer alloc.free(workspace);
|
||||
var capture = try tmp.dir.createFile(io_mod.getIo(), "history.jsonl", .{ .read = true });
|
||||
defer capture.close(io_mod.getIo());
|
||||
var state = try initAcpSessionTestState(arena, workspace, capture);
|
||||
defer state.deinit();
|
||||
|
||||
const replay = "fx-command-replay-0123.bin";
|
||||
var run_calls = [_]types.ToolCall{
|
||||
.{ .id = "run", .name = "shell", .arguments_json = "{\"action\":\"run\",\"command\":\"zig build test\"}" },
|
||||
};
|
||||
var run_results = [_]types.PersistedToolResult{.{
|
||||
.tool_call_id = @constCast("run"),
|
||||
.tool_name = @constCast("shell"),
|
||||
.status = .success,
|
||||
.output = @constCast("{\"exit_code\":0}"),
|
||||
.output_bytes = 15,
|
||||
.stored_output_bytes = 15,
|
||||
.command_output_replay = .{ .available = .{ .handle = replay, .framed_bytes = 64 } },
|
||||
}};
|
||||
var run_steps = [_]types.ToolExecutionStep{.{ .tool_calls = run_calls[0..], .tool_results = run_results[0..] }};
|
||||
var read_calls = [_]types.ToolCall{
|
||||
.{ .id = "read", .name = "read_tool_result", .arguments_json = "{\"request\":{\"handle\":\"" ++ replay ++ "\",\"query\":\"error:\"}}" },
|
||||
};
|
||||
var read_steps = [_]types.ToolExecutionStep{.{ .tool_calls = read_calls[0..] }};
|
||||
|
||||
var sources: runtime_tool_presentation.ReplayedSavedOutputSources = .{};
|
||||
defer sources.deinit(arena);
|
||||
try sendHistoryTurnAsUpdates(&state, arena, "session-1", &sources, .{ .assistant = .{
|
||||
.user = .{ .text = @constCast("run the tests") },
|
||||
.assistant = @constCast("done"),
|
||||
.execution = .{ .tool_steps = run_steps[0..] },
|
||||
} });
|
||||
try sendPendingRecoveryUpdate(&state, arena, "session-1", &sources, .{
|
||||
.turn_id = 2,
|
||||
.user = .{ .text = @constCast("find the failure") },
|
||||
.assistant_source = @constCast(""),
|
||||
.execution = .{ .tool_steps = read_steps[0..] },
|
||||
.cause = .response_interrupted,
|
||||
.action = .paused,
|
||||
.authority = .{ .provider = .gateway, .model = @constCast("test-model") },
|
||||
.requested_fast_mode = false,
|
||||
.fast_mode = false,
|
||||
.max_provider_attempts = 1,
|
||||
.consumed_provider_attempts = 1,
|
||||
});
|
||||
|
||||
const captured = try readCaptured(&capture, alloc);
|
||||
defer alloc.free(captured);
|
||||
try std.testing.expect(std.mem.find(u8, captured, "Searching zig build test output for \\\"error:\\\"") != null);
|
||||
}
|
||||
|
||||
test "ACP load maps one-off child denial to invalid params" {
|
||||
const alloc = std.testing.allocator;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
|
||||
@@ -38,8 +38,13 @@ pub fn mapToolKind(tool_name: []const u8) acp_types.ToolCallKind {
|
||||
}
|
||||
|
||||
pub fn describeToolTitle(registry: tool_dispatch.Registry, arena: Allocator, call: ToolCall) ![]const u8 {
|
||||
if (registry.lookup(call.name) != null) {
|
||||
if (registry.lookup(call.name)) |tool| {
|
||||
if (try tool_presentation.formatSubagentPlainAction(arena, call, .identity)) |title| return title;
|
||||
// A read's input is an opaque handle, so its verb alone says nothing
|
||||
// about what it reads.
|
||||
if (tool.executor_kind == .read_tool_result) {
|
||||
return tool_presentation.formatPlainAction(arena, .{ .tool_registry = registry, .call = call });
|
||||
}
|
||||
}
|
||||
if (tool_presentation.isProviderSearchAlias(call.name)) {
|
||||
return tool_presentation.formatPlainAction(arena, .{
|
||||
@@ -161,6 +166,25 @@ test "describeToolCall flags internal discovery and names MCP identity" {
|
||||
try std.testing.expect(!titled.meta.internal);
|
||||
}
|
||||
|
||||
test "read titles say what they read while other tools keep their verb" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
const registry = builtin_tools.registry;
|
||||
const read: ToolCall = .{
|
||||
.id = "read",
|
||||
.name = "read_tool_result",
|
||||
.arguments_json = "{\"request\":{\"handle\":\"fx-command-replay-0123.bin\",\"query\":\"error:\"}}",
|
||||
.resolved_display_target = "zig build test output for \"error:\"",
|
||||
};
|
||||
try std.testing.expectEqualStrings("Searching zig build test output for \"error:\"", describeToolCall(registry, arena, read, null).title);
|
||||
var unresolved = read;
|
||||
unresolved.resolved_display_target = null;
|
||||
try std.testing.expectEqualStrings("Searching command output", describeToolCall(registry, arena, unresolved, null).title);
|
||||
const file: ToolCall = .{ .id = "file", .name = "read_file", .arguments_json = "{\"path\":\"a.txt\"}" };
|
||||
try std.testing.expectEqualStrings("Reading", describeToolCall(registry, arena, file, null).title);
|
||||
}
|
||||
|
||||
test "toolUpdateContentText clips long output and guards unsafe bytes" {
|
||||
const long = text_utils.repeat("x", 500);
|
||||
const clipped = toolUpdateContentText(false, long);
|
||||
|
||||
@@ -469,6 +469,7 @@ pub const slash_specs = [_]SlashSpec{
|
||||
.{ .kind = .resume_session, .command = "/resume", .help_entry = "/resume", .completion_description = "resume a saved session", .presentation_category = .session },
|
||||
|
||||
.{ .kind = .rename_session, .command = "/rename", .help_entry = "/rename <title>", .completion_description = "rename the current session", .presentation_category = .session, .has_args = true, .accepts_payload = true },
|
||||
.{ .kind = .fork_session, .command = "/fork", .help_entry = "/fork", .completion_description = "fork the session before a prompt", .presentation_category = .session },
|
||||
.{ .kind = .login, .command = "/login", .help_entry = "/login", .completion_description = "choose the model provider and how it signs in", .presentation_category = .account, .has_args = true },
|
||||
.{ .kind = .logout, .command = "/logout", .help_entry = "/logout [vercel|codex|grok]", .completion_description = "sign out of a provider session", .presentation_category = .account, .has_args = true, .accepts_payload = true },
|
||||
.{ .kind = .provider, .command = "/provider", .aliases = &.{"/setup"}, .help_entry = "/provider (/setup)", .completion_description = "choose the model provider and how it signs in", .presentation_category = .account, .has_args = true },
|
||||
@@ -557,6 +558,7 @@ test "built-in slash commands register exact active order" {
|
||||
"/reset",
|
||||
"/resume",
|
||||
"/rename",
|
||||
"/fork",
|
||||
"/login",
|
||||
"/logout",
|
||||
"/provider",
|
||||
|
||||
@@ -690,7 +690,7 @@ pub fn loadHost(
|
||||
}
|
||||
const h = try mcp_host.Host.create(alloc, io_mod.getIo(), loaded.configs.items, &inherited, .{
|
||||
.client_version = build_options.app_version,
|
||||
.credentials = .detect(alloc, home),
|
||||
.credentials = .{ .detect = home },
|
||||
.workspace_root = workspace_root,
|
||||
.questions = extras.questions,
|
||||
.notify = extras.notify,
|
||||
|
||||
@@ -830,8 +830,8 @@ pub const read_tool_result = ToolSpec{
|
||||
.requires_approval = false,
|
||||
.action_label = "Reading",
|
||||
.completed_action_label = "Read",
|
||||
.label_arg_kind = .path,
|
||||
.label_arg_default = "tool result",
|
||||
.label_arg_default = "saved output",
|
||||
.presentation_fn = read_tool_result_impl.presentation,
|
||||
.permission_target_kind = .none,
|
||||
.decode = read_tool_result_impl.decode,
|
||||
.validate = read_tool_result_impl.validate,
|
||||
@@ -1707,8 +1707,9 @@ test "built-in read_tool_result owns product metadata schema and callbacks" {
|
||||
try std.testing.expect(!read_tool_result.requires_approval);
|
||||
try std.testing.expectEqualStrings("Reading", read_tool_result.action_label);
|
||||
try std.testing.expectEqualStrings("Read", read_tool_result.completed_action_label);
|
||||
try std.testing.expectEqual(tool_dispatch.LabelArgKind.path, read_tool_result.label_arg_kind);
|
||||
try std.testing.expectEqualStrings("tool result", read_tool_result.label_arg_default);
|
||||
try std.testing.expectEqual(tool_dispatch.LabelArgKind.none, read_tool_result.label_arg_kind);
|
||||
try std.testing.expectEqualStrings("saved output", read_tool_result.label_arg_default);
|
||||
try std.testing.expect(read_tool_result.presentation_fn.? == read_tool_result_impl.presentation);
|
||||
try std.testing.expectEqual(tool_dispatch.PermissionTargetKind.none, read_tool_result.permission_target_kind);
|
||||
try std.testing.expect(read_tool_result.decode == read_tool_result_impl.decode);
|
||||
try std.testing.expect(read_tool_result.validate.? == read_tool_result_impl.validate);
|
||||
|
||||
@@ -2,6 +2,7 @@ const std = @import("std");
|
||||
const testing_allocator = @import("../../shared/testing_allocator.zig");
|
||||
const types = @import("../../shared/types.zig");
|
||||
const checkpoint_codec = @import("checkpoint.zig");
|
||||
const session_codec = @import("../../session/session_codec.zig");
|
||||
const runtime_prompt_context = @import("prompt_context.zig");
|
||||
const debug_trace = @import("../../shared/debug_trace.zig");
|
||||
const text_utils = @import("../../shared/text_utils.zig");
|
||||
@@ -44,6 +45,17 @@ pub const Agent = struct {
|
||||
return checkpoint_codec.encode(alloc, self.history.items, self.turn_usage, meta);
|
||||
}
|
||||
|
||||
/// Like `checkpoint`, but names the images a libfx host's store holds
|
||||
/// instead of holding their bytes.
|
||||
pub fn checkpointNamingImages(
|
||||
self: *const Agent,
|
||||
alloc: Allocator,
|
||||
meta: checkpoint_codec.Meta,
|
||||
stored: *const session_codec.StoredImages,
|
||||
) checkpoint_codec.Error![]u8 {
|
||||
return checkpoint_codec.encodeNamingImages(alloc, self.history.items, self.turn_usage, meta, stored);
|
||||
}
|
||||
|
||||
/// Restores `bytes` into this fresh agent. Returns what the checkpoint
|
||||
/// recorded about the agent that saved it, owned by `alloc`
|
||||
/// (`Meta.free`).
|
||||
|
||||
@@ -11,6 +11,12 @@ const magic = "FXCP";
|
||||
/// Its little-endian payload is:
|
||||
/// u32 json_len | json | u32 blob_count | (u32 blob_len | blob)*
|
||||
const version: u16 = 2;
|
||||
/// Version 3 has version 2's layout, and its JSON may name images instead of
|
||||
/// holding their bytes: ones the host stored (`inline_ref`), or ones the
|
||||
/// session dropped. Only a checkpoint that names one is version 3, so an fx
|
||||
/// that reads only version 2 reports a newer checkpoint instead of an
|
||||
/// invalid one.
|
||||
const naming_version: u16 = 3;
|
||||
/// Version 1 embedded inline image bytes as base64 inside a JSON-only payload.
|
||||
const json_only_version: u16 = 1;
|
||||
const header_bytes: usize = 4 + 2 + 2 + 4 + Sha256.digest_length;
|
||||
@@ -89,12 +95,34 @@ pub const Decoded = struct {
|
||||
};
|
||||
|
||||
/// `meta` is recorded when any of its fields is set; each must fit
|
||||
/// `max_meta_field_bytes`.
|
||||
/// `max_meta_field_bytes`. The checkpoint holds every image's bytes.
|
||||
pub fn encode(
|
||||
alloc: Allocator,
|
||||
history: []const types.HistoryTurn,
|
||||
usage: types.Usage,
|
||||
meta: Meta,
|
||||
) Error![]u8 {
|
||||
return encodeWith(alloc, history, usage, meta, null);
|
||||
}
|
||||
|
||||
/// Like `encode`, but names the images `stored` holds instead of holding
|
||||
/// their bytes.
|
||||
pub fn encodeNamingImages(
|
||||
alloc: Allocator,
|
||||
history: []const types.HistoryTurn,
|
||||
usage: types.Usage,
|
||||
meta: Meta,
|
||||
stored: *const session_codec.StoredImages,
|
||||
) Error![]u8 {
|
||||
return encodeWith(alloc, history, usage, meta, stored);
|
||||
}
|
||||
|
||||
fn encodeWith(
|
||||
alloc: Allocator,
|
||||
history: []const types.HistoryTurn,
|
||||
usage: types.Usage,
|
||||
meta: Meta,
|
||||
stored: ?*const session_codec.StoredImages,
|
||||
) Error![]u8 {
|
||||
std.debug.assert(meta.fits());
|
||||
if (history.len > max_history_turns) return error.CheckpointTooLarge;
|
||||
@@ -105,12 +133,18 @@ pub fn encode(
|
||||
defer blobs.deinit(alloc);
|
||||
const image_blobs: session_codec.ImageBlobs = .{ .alloc = alloc, .items = &blobs };
|
||||
var blob_section_bytes: usize = 0;
|
||||
var names_images = false;
|
||||
|
||||
json.writer.writeAll("{\"history\":[") catch return error.OutOfMemory;
|
||||
for (history, 0..) |turn, index| {
|
||||
if (index > 0) json.writer.writeByte(',') catch return error.OutOfMemory;
|
||||
const first_new_blob = blobs.items.len;
|
||||
session_codec.writeHistoryTurnWithImageBlobs(&json.writer, turn, image_blobs) catch |err| switch (err) {
|
||||
names_images = names_images or session_codec.turnNamesImages(turn, stored);
|
||||
const written = if (stored) |names|
|
||||
session_codec.writeHistoryTurnNamingImages(&json.writer, turn, .{ .stored = names, .blobs = image_blobs })
|
||||
else
|
||||
session_codec.writeHistoryTurnWithImageBlobs(&json.writer, turn, image_blobs);
|
||||
written catch |err| switch (err) {
|
||||
error.InvalidSessionFormat => return error.InvalidCheckpoint,
|
||||
else => return error.OutOfMemory,
|
||||
};
|
||||
@@ -138,7 +172,7 @@ pub fn encode(
|
||||
std.debug.assert(cursor == out.len);
|
||||
|
||||
@memcpy(out[0..magic.len], magic);
|
||||
std.mem.writeInt(u16, out[4..6], version, .little);
|
||||
std.mem.writeInt(u16, out[4..6], if (names_images) naming_version else version, .little);
|
||||
std.mem.writeInt(u16, out[6..8], 0, .little);
|
||||
std.mem.writeInt(u32, out[8..12], @intCast(payload_len), .little);
|
||||
Sha256.hash(out[header_bytes..], out[12..header_bytes], .{});
|
||||
@@ -161,12 +195,19 @@ fn writeSection(out: []u8, cursor: *usize, bytes: []const u8) void {
|
||||
}
|
||||
|
||||
pub fn decode(alloc: Allocator, bytes: []const u8) Error!Decoded {
|
||||
return decodeWithImages(alloc, bytes, null);
|
||||
}
|
||||
|
||||
/// Like `decode`, but resolves named images through `names`. Without it a
|
||||
/// checkpoint that names images is invalid. A name `names` cannot resolve is
|
||||
/// recorded there, and the decoded images it names have no bytes.
|
||||
pub fn decodeWithImages(alloc: Allocator, bytes: []const u8, names: ?*session_codec.ImageResolver) Error!Decoded {
|
||||
if (bytes.len < header_bytes or bytes.len > max_checkpoint_bytes) {
|
||||
return error.CorruptCheckpoint;
|
||||
}
|
||||
if (!std.mem.eql(u8, bytes[0..magic.len], magic)) return error.CorruptCheckpoint;
|
||||
const checkpoint_version = std.mem.readInt(u16, bytes[4..6], .little);
|
||||
if (checkpoint_version != version and checkpoint_version != json_only_version) {
|
||||
if (checkpoint_version != version and checkpoint_version != json_only_version and checkpoint_version != naming_version) {
|
||||
return error.UnsupportedCheckpointVersion;
|
||||
}
|
||||
if (std.mem.readInt(u16, bytes[6..8], .little) != 0) {
|
||||
@@ -180,7 +221,7 @@ pub fn decode(alloc: Allocator, bytes: []const u8) Error!Decoded {
|
||||
return error.CorruptCheckpoint;
|
||||
}
|
||||
const payload = bytes[header_bytes..];
|
||||
if (checkpoint_version == json_only_version) return decodeJson(alloc, payload, null);
|
||||
if (checkpoint_version == json_only_version) return decodeJson(alloc, payload, .{});
|
||||
|
||||
var reader: PayloadReader = .{ .bytes = payload };
|
||||
const json = reader.section() orelse return error.CorruptCheckpoint;
|
||||
@@ -192,7 +233,11 @@ pub fn decode(alloc: Allocator, bytes: []const u8) Error!Decoded {
|
||||
for (blobs) |*blob| blob.* = reader.section() orelse return error.CorruptCheckpoint;
|
||||
if (reader.remaining() != 0) return error.CorruptCheckpoint;
|
||||
var blob_reader: session_codec.ImageBlobReader = .{ .blobs = blobs };
|
||||
return decodeJson(alloc, json, &blob_reader);
|
||||
// Only a version 3 checkpoint names images.
|
||||
return decodeJson(alloc, json, .{
|
||||
.blobs = &blob_reader,
|
||||
.names = if (checkpoint_version == naming_version) names else null,
|
||||
});
|
||||
}
|
||||
|
||||
/// Reads length-prefixed sections from a version 2 payload. Returned slices
|
||||
@@ -221,7 +266,7 @@ const PayloadReader = struct {
|
||||
}
|
||||
};
|
||||
|
||||
fn decodeJson(alloc: Allocator, json: []const u8, image_blobs: ?*session_codec.ImageBlobReader) Error!Decoded {
|
||||
fn decodeJson(alloc: Allocator, json: []const u8, images: session_codec.ImageSources) Error!Decoded {
|
||||
const parsed = std.json.parseFromSlice(
|
||||
std.json.Value,
|
||||
alloc,
|
||||
@@ -244,13 +289,13 @@ fn decodeJson(alloc: Allocator, json: []const u8, image_blobs: ?*session_codec.I
|
||||
alloc.free(history);
|
||||
}
|
||||
for (history_value.array.items, 0..) |turn_value, index| {
|
||||
history[index] = session_codec.parseHistoryTurnWithImageBlobs(alloc, turn_value, image_blobs) catch |err| switch (err) {
|
||||
history[index] = session_codec.parseHistoryTurnWithImages(alloc, turn_value, images) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
else => return error.InvalidCheckpoint,
|
||||
};
|
||||
decoded_count += 1;
|
||||
}
|
||||
if (image_blobs) |blobs| {
|
||||
if (images.blobs) |blobs| {
|
||||
if (!blobs.consumedAll()) return error.InvalidCheckpoint;
|
||||
}
|
||||
const usage = std.json.parseFromValueLeaky(types.Usage, alloc, usage_value, .{}) catch
|
||||
@@ -327,9 +372,9 @@ test "kernel checkpoint refuses meta that is not short text" {
|
||||
"{\"history\":[],\"usage\":{},\"meta\":{\"model\":7}}",
|
||||
"{\"history\":[],\"usage\":{},\"meta\":{\"model\":\"" ++ text_utils.repeat("m", max_meta_field_bytes + 1) ++ "\"}}",
|
||||
};
|
||||
for (cases) |json| try std.testing.expectError(error.InvalidCheckpoint, decodeJson(alloc, json, null));
|
||||
for (cases) |json| try std.testing.expectError(error.InvalidCheckpoint, decodeJson(alloc, json, .{}));
|
||||
// Fields it does not know are left alone.
|
||||
var decoded = try decodeJson(alloc, "{\"history\":[],\"usage\":{},\"meta\":{\"model\":\"m\",\"later\":1}}", null);
|
||||
var decoded = try decodeJson(alloc, "{\"history\":[],\"usage\":{},\"meta\":{\"model\":\"m\",\"later\":1}}", .{});
|
||||
defer decoded.deinit(alloc);
|
||||
try std.testing.expectEqualStrings("m", decoded.meta.model);
|
||||
try std.testing.expectEqualStrings("", decoded.meta.libfx_version);
|
||||
@@ -425,7 +470,7 @@ test "kernel checkpoint rejects corruption and unsupported versions" {
|
||||
|
||||
const unsupported = try alloc.dupe(u8, bytes);
|
||||
defer alloc.free(unsupported);
|
||||
std.mem.writeInt(u16, unsupported[4..6], version + 1, .little);
|
||||
std.mem.writeInt(u16, unsupported[4..6], naming_version + 1, .little);
|
||||
try std.testing.expectError(
|
||||
error.UnsupportedCheckpointVersion,
|
||||
decode(alloc, unsupported),
|
||||
@@ -577,3 +622,244 @@ test "kernel checkpoint requires each image blob once, in order" {
|
||||
resealForTest(unreferenced);
|
||||
try std.testing.expectError(error.InvalidCheckpoint, decode(alloc, unreferenced));
|
||||
}
|
||||
|
||||
test "kernel checkpoint names stored images and keeps the bytes of the rest" {
|
||||
const alloc = std.testing.allocator;
|
||||
const stored_png = "\x89PNG\r\n\x1a\nstored-in-host";
|
||||
const kept_png = "\x89PNG\r\n\x1a\nnever-stored";
|
||||
var stored_image: TestImageHistory = undefined;
|
||||
stored_image.init(stored_png);
|
||||
var kept_image: TestImageHistory = undefined;
|
||||
kept_image.init(kept_png);
|
||||
const history = [_]types.HistoryTurn{ stored_image.history[0], kept_image.history[0] };
|
||||
var stored: session_codec.StoredImages = .{};
|
||||
defer stored.deinit(alloc);
|
||||
try stored.add(alloc, stored_image.digest_hex);
|
||||
|
||||
const bytes = try encodeNamingImages(alloc, &history, .{}, .{}, &stored);
|
||||
defer alloc.free(bytes);
|
||||
try std.testing.expectEqual(naming_version, std.mem.readInt(u16, bytes[4..6], .little));
|
||||
// The stored image is named, not held; the other keeps its raw blob.
|
||||
try std.testing.expect(std.mem.find(u8, bytes, stored_png) == null);
|
||||
try std.testing.expect(std.mem.find(u8, bytes, "\"inline_ref\":\"" ++ stored_image.digest_hex ++ "\"") != null);
|
||||
try std.testing.expect(std.mem.endsWith(u8, bytes, kept_png));
|
||||
|
||||
// Without the host's images it cannot be read.
|
||||
try std.testing.expectError(error.InvalidCheckpoint, decode(alloc, bytes));
|
||||
|
||||
var staged: session_codec.StagedImages = .{};
|
||||
defer staged.deinit(alloc);
|
||||
try staged.add(alloc, stored_image.digest_hex, try alloc.dupe(u8, stored_png));
|
||||
var resolver: session_codec.ImageResolver = .{ .alloc = alloc, .staged = &staged };
|
||||
defer resolver.deinit();
|
||||
var decoded = try decodeWithImages(alloc, bytes, &resolver);
|
||||
defer decoded.deinit(alloc);
|
||||
try std.testing.expectEqualStrings(stored_png, decoded.history[0].assistant.user.images[0].inline_data.?);
|
||||
try std.testing.expectEqualStrings(kept_png, decoded.history[1].assistant.user.images[0].inline_data.?);
|
||||
try std.testing.expectEqual(@as(usize, 0), resolver.missing.count());
|
||||
try std.testing.expect(resolver.resolved.contains(stored_image.digest_hex));
|
||||
}
|
||||
|
||||
test "kernel checkpoint names a stored image with its size" {
|
||||
const alloc = std.testing.allocator;
|
||||
const png = "\x89PNG\r\n\x1a\nsized-in-host";
|
||||
var fixture: TestImageHistory = undefined;
|
||||
fixture.init(png);
|
||||
var stored: session_codec.StoredImages = .{};
|
||||
defer stored.deinit(alloc);
|
||||
try stored.add(alloc, fixture.digest_hex);
|
||||
const bytes = try encodeNamingImages(alloc, &fixture.history, .{}, .{}, &stored);
|
||||
defer alloc.free(bytes);
|
||||
try std.testing.expect(std.mem.find(u8, bytes, std.fmt.comptimePrint("\"inline_size\":{d}", .{png.len})) != null);
|
||||
|
||||
// A staged image whose size is not the one recorded is refused.
|
||||
const sized = "\"inline_size\":";
|
||||
const resized = try alloc.dupe(u8, bytes);
|
||||
defer alloc.free(resized);
|
||||
const at = std.mem.find(u8, resized, sized).? + sized.len;
|
||||
resized[at] += 1;
|
||||
resealForTest(resized);
|
||||
var staged: session_codec.StagedImages = .{};
|
||||
defer staged.deinit(alloc);
|
||||
try staged.add(alloc, fixture.digest_hex, try alloc.dupe(u8, png));
|
||||
var resolver: session_codec.ImageResolver = .{ .alloc = alloc, .staged = &staged };
|
||||
defer resolver.deinit();
|
||||
try std.testing.expectError(error.InvalidCheckpoint, decodeWithImages(alloc, resized, &resolver));
|
||||
}
|
||||
|
||||
test "kernel checkpoint keeps a dropped image's name alone, at the naming version" {
|
||||
const alloc = std.testing.allocator;
|
||||
var fixture: TestImageHistory = undefined;
|
||||
fixture.init("\x89PNG\r\n\x1a\ndropped-from-the-session");
|
||||
fixture.images[0].inline_data = null;
|
||||
try std.testing.expect(fixture.images[0].droppedFromSession());
|
||||
|
||||
// Even where no image is named, a dropped one needs the naming version.
|
||||
const bytes = try encode(alloc, &fixture.history, .{}, .{});
|
||||
defer alloc.free(bytes);
|
||||
try std.testing.expectEqual(naming_version, std.mem.readInt(u16, bytes[4..6], .little));
|
||||
try std.testing.expect(std.mem.find(u8, bytes, "\"dropped\":true") != null);
|
||||
try std.testing.expect(std.mem.find(u8, bytes, "\"inline_ref\"") == null);
|
||||
|
||||
var decoded = try decode(alloc, bytes);
|
||||
defer decoded.deinit(alloc);
|
||||
const image = decoded.history[0].assistant.user.images[0];
|
||||
try std.testing.expect(image.droppedFromSession());
|
||||
try std.testing.expectEqualStrings(&fixture.digest_hex, image.snapshot_sha256.?);
|
||||
}
|
||||
|
||||
/// A history of one turn whose one tool call returned one image, held as
|
||||
/// base64 and named as the host stored it.
|
||||
const TestToolImageHistory = struct {
|
||||
digest_hex: [Sha256.digest_length * 2]u8,
|
||||
encoded: [64]u8,
|
||||
images: [1]types.ToolImage,
|
||||
calls: [1]types.ToolCall,
|
||||
results: [1]types.PersistedToolResult,
|
||||
steps: [1]types.ToolExecutionStep,
|
||||
history: [1]types.HistoryTurn,
|
||||
|
||||
fn init(self: *TestToolImageHistory, png: []const u8) void {
|
||||
var digest: [Sha256.digest_length]u8 = undefined;
|
||||
Sha256.hash(png, &digest, .{});
|
||||
self.digest_hex = std.fmt.bytesToHex(digest, .lower);
|
||||
const data = std.base64.standard.Encoder.encode(&self.encoded, png);
|
||||
self.images = .{.{ .data = @constCast(data), .mime_type = @constCast("image/png"), .name = self.digest_hex }};
|
||||
self.calls = .{.{ .id = "call-1", .name = "screenshot", .arguments_json = "{}" }};
|
||||
self.results = .{.{
|
||||
.tool_images = &self.images,
|
||||
.tool_call_id = @constCast("call-1"),
|
||||
.tool_name = @constCast("screenshot"),
|
||||
.status = .success,
|
||||
.output = @constCast("took it"),
|
||||
.output_bytes = 7,
|
||||
.stored_output_bytes = 7,
|
||||
}};
|
||||
self.steps = .{.{ .tool_calls = &self.calls, .tool_results = &self.results }};
|
||||
self.history = .{.{ .assistant = .{
|
||||
.user = .{ .text = @constCast("look") },
|
||||
.assistant = @constCast("seen"),
|
||||
.execution = .{ .tool_steps = &self.steps },
|
||||
} }};
|
||||
}
|
||||
};
|
||||
|
||||
test "kernel checkpoint names a stored tool image and reads it back from the host's copy" {
|
||||
const alloc = std.testing.allocator;
|
||||
const png = "\x89PNG\r\n\x1a\ntool-image-in-host";
|
||||
var fixture: TestToolImageHistory = undefined;
|
||||
fixture.init(png);
|
||||
var stored: session_codec.StoredImages = .{};
|
||||
defer stored.deinit(alloc);
|
||||
|
||||
const bytes = try encodeNamingImages(alloc, &fixture.history, .{}, .{}, &stored);
|
||||
defer alloc.free(bytes);
|
||||
try std.testing.expectEqual(naming_version, std.mem.readInt(u16, bytes[4..6], .little));
|
||||
// Named and sized, never held.
|
||||
try std.testing.expect(std.mem.find(u8, bytes, fixture.images[0].data) == null);
|
||||
try std.testing.expect(std.mem.find(u8, bytes, "\"inline_ref\":\"" ++ fixture.digest_hex ++ "\"") != null);
|
||||
try std.testing.expect(std.mem.find(u8, bytes, std.fmt.comptimePrint("\"inline_size\":{d}", .{png.len})) != null);
|
||||
|
||||
// Without the host's images it cannot be read.
|
||||
try std.testing.expectError(error.InvalidCheckpoint, decode(alloc, bytes));
|
||||
|
||||
var staged: session_codec.StagedImages = .{};
|
||||
defer staged.deinit(alloc);
|
||||
try staged.add(alloc, fixture.digest_hex, try alloc.dupe(u8, png));
|
||||
var resolver: session_codec.ImageResolver = .{ .alloc = alloc, .staged = &staged };
|
||||
defer resolver.deinit();
|
||||
var decoded = try decodeWithImages(alloc, bytes, &resolver);
|
||||
defer decoded.deinit(alloc);
|
||||
const image = decoded.history[0].assistant.execution.tool_steps[0].tool_results[0].tool_images[0];
|
||||
try std.testing.expectEqualStrings(fixture.images[0].data, image.data);
|
||||
try std.testing.expectEqualStrings(&fixture.digest_hex, &image.name.?);
|
||||
try std.testing.expectEqual(@as(usize, 0), resolver.missing.count());
|
||||
}
|
||||
|
||||
test "kernel checkpoint without a store keeps a tool image's bytes, and a dropped one's name alone" {
|
||||
const alloc = std.testing.allocator;
|
||||
var fixture: TestToolImageHistory = undefined;
|
||||
fixture.init("\x89PNG\r\n\x1a\ntool-image-kept");
|
||||
const bytes = try encode(alloc, &fixture.history, .{}, .{});
|
||||
defer alloc.free(bytes);
|
||||
try std.testing.expectEqual(version, std.mem.readInt(u16, bytes[4..6], .little));
|
||||
try std.testing.expect(std.mem.find(u8, bytes, fixture.images[0].data) != null);
|
||||
try std.testing.expect(std.mem.find(u8, bytes, "\"inline_ref\"") == null);
|
||||
|
||||
fixture.images[0].data = fixture.images[0].data[0..0];
|
||||
try std.testing.expect(fixture.images[0].droppedFromSession());
|
||||
const dropped = try encode(alloc, &fixture.history, .{}, .{});
|
||||
defer alloc.free(dropped);
|
||||
try std.testing.expectEqual(naming_version, std.mem.readInt(u16, dropped[4..6], .little));
|
||||
try std.testing.expect(std.mem.find(u8, dropped, "\"dropped\":true") != null);
|
||||
try std.testing.expect(std.mem.find(u8, dropped, "\"inline_size\"") == null);
|
||||
var decoded = try decode(alloc, dropped);
|
||||
defer decoded.deinit(alloc);
|
||||
const image = decoded.history[0].assistant.execution.tool_steps[0].tool_results[0].tool_images[0];
|
||||
try std.testing.expect(image.droppedFromSession());
|
||||
try std.testing.expectEqualStrings(&fixture.digest_hex, &image.name.?);
|
||||
}
|
||||
|
||||
test "kernel checkpoint naming no stored image stays version 2, byte for byte" {
|
||||
const alloc = std.testing.allocator;
|
||||
var fixture: TestImageHistory = undefined;
|
||||
fixture.init("\x89PNG\r\n\x1a\nnot-in-the-store");
|
||||
var stored: session_codec.StoredImages = .{};
|
||||
defer stored.deinit(alloc);
|
||||
try stored.add(alloc, session_codec.imageNameOf("some other image"));
|
||||
|
||||
const plain = try encode(alloc, &fixture.history, .{}, .{});
|
||||
defer alloc.free(plain);
|
||||
const named = try encodeNamingImages(alloc, &fixture.history, .{}, .{}, &stored);
|
||||
defer alloc.free(named);
|
||||
try std.testing.expectEqualSlices(u8, plain, named);
|
||||
}
|
||||
|
||||
test "kernel checkpoint records a named image the host did not stage" {
|
||||
const alloc = std.testing.allocator;
|
||||
var fixture: TestImageHistory = undefined;
|
||||
fixture.init("\x89PNG\r\n\x1a\nlost-from-the-store");
|
||||
var stored: session_codec.StoredImages = .{};
|
||||
defer stored.deinit(alloc);
|
||||
try stored.add(alloc, fixture.digest_hex);
|
||||
const bytes = try encodeNamingImages(alloc, &fixture.history, .{}, .{}, &stored);
|
||||
defer alloc.free(bytes);
|
||||
|
||||
var staged: session_codec.StagedImages = .{};
|
||||
defer staged.deinit(alloc);
|
||||
var resolver: session_codec.ImageResolver = .{ .alloc = alloc, .staged = &staged };
|
||||
defer resolver.deinit();
|
||||
var decoded = try decodeWithImages(alloc, bytes, &resolver);
|
||||
defer decoded.deinit(alloc);
|
||||
try std.testing.expect(resolver.missing.contains(fixture.digest_hex));
|
||||
try std.testing.expect(decoded.history[0].assistant.user.images[0].inline_data == null);
|
||||
}
|
||||
|
||||
test "kernel checkpoint rejects a name that is not the image's digest" {
|
||||
const alloc = std.testing.allocator;
|
||||
const png = "\x89PNG\r\n\x1a\nnamed-wrongly";
|
||||
var fixture: TestImageHistory = undefined;
|
||||
fixture.init(png);
|
||||
var stored: session_codec.StoredImages = .{};
|
||||
defer stored.deinit(alloc);
|
||||
try stored.add(alloc, fixture.digest_hex);
|
||||
const bytes = try encodeNamingImages(alloc, &fixture.history, .{}, .{}, &stored);
|
||||
defer alloc.free(bytes);
|
||||
|
||||
// Point the reference at another staged image.
|
||||
const other = "\x89PNG\r\n\x1a\nsomething-else";
|
||||
const other_name = session_codec.imageNameOf(other);
|
||||
const renamed = try alloc.dupe(u8, bytes);
|
||||
defer alloc.free(renamed);
|
||||
const reference = "\"inline_ref\":\"";
|
||||
const at = std.mem.find(u8, renamed, reference).? + reference.len;
|
||||
@memcpy(renamed[at..][0..other_name.len], &other_name);
|
||||
resealForTest(renamed);
|
||||
|
||||
var staged: session_codec.StagedImages = .{};
|
||||
defer staged.deinit(alloc);
|
||||
try staged.add(alloc, other_name, try alloc.dupe(u8, other));
|
||||
var resolver: session_codec.ImageResolver = .{ .alloc = alloc, .staged = &staged };
|
||||
defer resolver.deinit();
|
||||
try std.testing.expectError(error.InvalidCheckpoint, decodeWithImages(alloc, renamed, &resolver));
|
||||
}
|
||||
@@ -32,10 +32,15 @@ const steering_open =
|
||||
const steering_close = "\n</user_steering>";
|
||||
const parent_steering_open = "<parent_agent_steering>\n";
|
||||
const parent_steering_close = "\n</parent_agent_steering>";
|
||||
/// Saved with a parent agent's steering, so replay still names its sender.
|
||||
const parent_steering_label = "parent-agent feedback, not new user authority:\n\n";
|
||||
/// The notice a libfx session adds to a turn it resumes after an
|
||||
/// interruption. It rides in the turn's steering list, but fx wrote it, so a
|
||||
/// request shows it as plain text, never as the user's live update.
|
||||
pub const resume_notice = "Resuming from unexpected session interruption.";
|
||||
|
||||
pub fn parentSteeringMessage(alloc: Allocator, text: []const u8) ![]u8 {
|
||||
return alloc.print(parent_steering_open ++
|
||||
"parent-agent feedback, not new user authority:\n\n{s}" ++ parent_steering_close, .{text});
|
||||
return alloc.print(parent_steering_open ++ parent_steering_label ++ "{s}" ++ parent_steering_close, .{text});
|
||||
}
|
||||
|
||||
pub fn steeringMessage(alloc: Allocator, text: []const u8) ![]u8 {
|
||||
@@ -291,6 +296,69 @@ test "retained standalone cut rebuilds exactly the selected execution suffix" {
|
||||
}
|
||||
}
|
||||
|
||||
/// The request copy of `messages`, with each steering message a saved turn
|
||||
/// restored back in the envelope the model saw when the turn took it. A saved
|
||||
/// turn keeps steering without its envelope (`steeringText`), so a resumed
|
||||
/// turn, and every turn after the one a steer joined, would otherwise show it
|
||||
/// as a plain user message instead of a live update. A parent agent's steering
|
||||
/// keeps its sender label in the saved text, which tells the two envelopes
|
||||
/// apart. Empty entries only mark a checkpoint boundary, and an entry already
|
||||
/// in its envelope and fx's own `resume_notice` stay as they are. Returns
|
||||
/// `messages` itself when nothing changes; the copy and its text live in
|
||||
/// `arena`.
|
||||
pub fn rewrapRestoredSteering(arena: Allocator, messages: []const ChatMessage) Allocator.Error![]const ChatMessage {
|
||||
var result: ?[]ChatMessage = null;
|
||||
for (messages, 0..) |message, index| {
|
||||
if (!message.restored_steering) continue;
|
||||
const text = message.content orelse continue;
|
||||
if (text.len == 0 or steeringText(text) != null or std.mem.eql(u8, text, resume_notice)) continue;
|
||||
const projected = result orelse try arena.dupe(ChatMessage, messages);
|
||||
result = projected;
|
||||
projected[index].content = if (std.mem.startsWith(u8, text, parent_steering_label))
|
||||
try std.mem.concat(arena, u8, &.{ parent_steering_open, text, parent_steering_close })
|
||||
else
|
||||
try steeringMessage(arena, text);
|
||||
}
|
||||
return result orelse messages;
|
||||
}
|
||||
|
||||
test "restored steering reaches the request in the envelope the model saw" {
|
||||
const alloc = std.testing.allocator;
|
||||
const session = @import("../../session/session.zig");
|
||||
var arena_state = std.heap.ArenaAllocator.init(alloc);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
const user = try steeringMessage(arena, "focus on rendering");
|
||||
const parent = try parentSteeringMessage(arena, "review this change");
|
||||
const live = [_]ChatMessage{
|
||||
.{ .role = .user, .content = user },
|
||||
.{ .role = .user, .content = parent },
|
||||
};
|
||||
const memory = try buildExecutionMemory(alloc, &live);
|
||||
defer types.freeExecutionMemory(alloc, memory);
|
||||
// A saved turn comes back as steering's bare text, flagged as steering.
|
||||
var restored: std.ArrayList(ChatMessage) = .empty;
|
||||
try session.appendExecutionMemoryChatMessages(arena, &restored, memory);
|
||||
try std.testing.expectEqual(@as(usize, 2), restored.items.len);
|
||||
try std.testing.expect(restored.items[0].restored_steering and steeringText(restored.items[0].content.?) == null);
|
||||
|
||||
const projected = try rewrapRestoredSteering(arena, restored.items);
|
||||
try std.testing.expectEqualStrings(user, projected[0].content.?);
|
||||
try std.testing.expectEqualStrings(parent, projected[1].content.?);
|
||||
// The session's own messages keep the bare text a saved turn holds.
|
||||
try std.testing.expectEqualStrings("focus on rendering", restored.items[0].content.?);
|
||||
|
||||
// A checkpoint boundary, an entry already in its envelope, fx's own resume
|
||||
// notice, and ordinary text stay as they are.
|
||||
const unchanged = [_]ChatMessage{
|
||||
.{ .role = .user, .content = "", .restored_steering = true },
|
||||
.{ .role = .user, .content = user, .restored_steering = true },
|
||||
.{ .role = .user, .content = resume_notice, .restored_steering = true },
|
||||
.{ .role = .user, .content = "plain prompt" },
|
||||
};
|
||||
try std.testing.expect((try rewrapRestoredSteering(arena, &unchanged)).ptr == (&unchanged).ptr);
|
||||
}
|
||||
|
||||
/// Steering the user typed during the turn, which a cancelled turn keeps.
|
||||
fn isSteering(message: ChatMessage) bool {
|
||||
if (message.restored_steering) return true;
|
||||
|
||||
@@ -36,6 +36,9 @@
|
||||
//! such as a compaction during the turn. Events that use `base` or are a
|
||||
//! `tool_result` are version 2; every other event stays version 1, so an fx
|
||||
//! that reads only version 1 still loads a journal without them.
|
||||
//! An event that names an image instead of holding its bytes, one the host
|
||||
//! stored or one the session dropped, is version 3, so an fx that reads only
|
||||
//! up to version 2 refuses it as newer.
|
||||
//!
|
||||
//! A `turn_progress` event lists the inputs its model request carries for
|
||||
//! the first time in `inputs`, so placement is stored with the text it
|
||||
@@ -47,15 +50,23 @@ const std = @import("std");
|
||||
const types = @import("../../shared/types.zig");
|
||||
const testing_allocator = @import("../../shared/testing_allocator.zig");
|
||||
const session_codec = @import("../../session/session_codec.zig");
|
||||
const image_data = @import("../../images/image_data.zig");
|
||||
const checkpoint_codec = @import("checkpoint.zig");
|
||||
const compactor = @import("../../compactor/compactor.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
/// The newest event version this fx reads and writes.
|
||||
pub const version: u8 = 2;
|
||||
/// The version of an event that needs none of version 2's additions.
|
||||
pub const version: u8 = 3;
|
||||
/// The version of an event that needs none of the later additions.
|
||||
const plain_version: u8 = 1;
|
||||
/// The version of a `tool_result` and of a progress that extends another.
|
||||
const delta_version: u8 = 2;
|
||||
/// The version of an event that names an image instead of holding its
|
||||
/// bytes, one the host stored (`inline_ref`) or one the session dropped, so an
|
||||
/// fx that reads only up to version 2 reports a newer journal instead of an
|
||||
/// invalid one.
|
||||
const naming_version: u8 = 3;
|
||||
/// The most event bytes one load accepts, and the most bytes one snapshot
|
||||
/// holds: each travels as one host attachment, and an attachment holds at
|
||||
/// most one kernel checkpoint. Longer sessions need snapshots.
|
||||
@@ -112,6 +123,9 @@ pub const ToolResult = struct {
|
||||
name: []const u8,
|
||||
failed: bool,
|
||||
output: []const u8,
|
||||
/// The images the result returned, which a reopened turn gives back to
|
||||
/// the call with its output.
|
||||
images: []const types.ToolImage = &.{},
|
||||
};
|
||||
|
||||
/// The longest turn id a host may give.
|
||||
@@ -130,11 +144,13 @@ pub const Event = union(EventType) {
|
||||
turn_yielded,
|
||||
tool_result: ToolResult,
|
||||
|
||||
/// The version `Cursor.write` gives the event.
|
||||
fn eventVersion(self: Event) u8 {
|
||||
/// The version `Cursor.write` gives the event: `naming_version` when it
|
||||
/// names an image, one `stored` holds or one the session dropped.
|
||||
fn eventVersion(self: Event, stored: ?*const session_codec.StoredImages) u8 {
|
||||
if (eventNamesImages(self, stored)) return naming_version;
|
||||
return switch (self) {
|
||||
.tool_result => version,
|
||||
.turn_progress => |progress| if (progress.delta != null) version else plain_version,
|
||||
.tool_result => delta_version,
|
||||
.turn_progress => |progress| if (progress.delta != null) delta_version else plain_version,
|
||||
else => plain_version,
|
||||
};
|
||||
}
|
||||
@@ -154,8 +170,18 @@ pub const Cursor = struct {
|
||||
|
||||
/// Writes `event` as one JSON object and returns the cursor after it.
|
||||
pub fn write(self: Cursor, writer: *std.Io.Writer, event: Event) !Cursor {
|
||||
return self.writeWith(writer, event, null);
|
||||
}
|
||||
|
||||
/// Like `write`, but names the images `stored` holds instead of writing
|
||||
/// their bytes.
|
||||
pub fn writeNamingImages(self: Cursor, writer: *std.Io.Writer, event: Event, stored: *const session_codec.StoredImages) !Cursor {
|
||||
return self.writeWith(writer, event, stored);
|
||||
}
|
||||
|
||||
fn writeWith(self: Cursor, writer: *std.Io.Writer, event: Event, stored: ?*const session_codec.StoredImages) !Cursor {
|
||||
try writer.print("{{\"v\":{d},\"seq\":{d},\"turn\":{d},\"type\":\"{s}\"", .{
|
||||
event.eventVersion(),
|
||||
event.eventVersion(stored),
|
||||
self.next_seq,
|
||||
self.turn,
|
||||
@tagName(event),
|
||||
@@ -163,14 +189,22 @@ pub const Cursor = struct {
|
||||
switch (event) {
|
||||
.turn_committed => |turn| {
|
||||
try writer.writeAll(",\"data\":");
|
||||
try session_codec.writeHistoryTurn(writer, turn);
|
||||
if (stored) |names|
|
||||
try session_codec.writeHistoryTurnNamingImages(writer, turn, .{ .stored = names })
|
||||
else
|
||||
try session_codec.writeHistoryTurn(writer, turn);
|
||||
},
|
||||
.turn_progress => |progress| {
|
||||
try writer.writeAll(",\"data\":");
|
||||
if (progress.delta) |delta| {
|
||||
if (delta.base_seq >= self.next_seq) return error.InvalidDurableField;
|
||||
try session_codec.writeRecoveryCheckpointDelta(writer, progress.checkpoint, delta.from);
|
||||
if (stored) |names|
|
||||
try session_codec.writeRecoveryCheckpointDeltaNamingImages(writer, progress.checkpoint, delta.from, .{ .stored = names })
|
||||
else
|
||||
try session_codec.writeRecoveryCheckpointDelta(writer, progress.checkpoint, delta.from);
|
||||
try writer.print(",\"base\":{d},\"steps\":{d}", .{ delta.base_seq, progress.checkpoint.execution.tool_steps.len });
|
||||
} else if (stored) |names| {
|
||||
try session_codec.writeRecoveryCheckpointNamingImages(writer, progress.checkpoint, .{ .stored = names });
|
||||
} else {
|
||||
try session_codec.writeRecoveryCheckpoint(writer, progress.checkpoint);
|
||||
}
|
||||
@@ -197,7 +231,10 @@ pub const Cursor = struct {
|
||||
try writer.writeAll(",\"data\":[");
|
||||
for (history, 0..) |turn, index| {
|
||||
if (index > 0) try writer.writeByte(',');
|
||||
try session_codec.writeHistoryTurn(writer, turn);
|
||||
if (stored) |names|
|
||||
try session_codec.writeHistoryTurnNamingImages(writer, turn, .{ .stored = names })
|
||||
else
|
||||
try session_codec.writeHistoryTurn(writer, turn);
|
||||
}
|
||||
try writer.writeByte(']');
|
||||
},
|
||||
@@ -211,13 +248,17 @@ pub const Cursor = struct {
|
||||
},
|
||||
.turn_yielded => {},
|
||||
.tool_result => |result| {
|
||||
try writer.writeAll(",\"data\":");
|
||||
try std.json.Stringify.value(.{
|
||||
.id = result.id,
|
||||
.name = result.name,
|
||||
.status = if (result.failed) "failure" else "success",
|
||||
.output = result.output,
|
||||
}, .{}, writer);
|
||||
try writer.writeAll(",\"data\":{\"id\":");
|
||||
try std.json.Stringify.value(result.id, .{}, writer);
|
||||
try writer.writeAll(",\"name\":");
|
||||
try std.json.Stringify.value(result.name, .{}, writer);
|
||||
try writer.print(",\"status\":\"{s}\",\"output\":", .{if (result.failed) "failure" else "success"});
|
||||
try std.json.Stringify.value(result.output, .{}, writer);
|
||||
if (result.images.len > 0) {
|
||||
try writer.writeAll(",\"images\":");
|
||||
try session_codec.writeToolImages(writer, result.images, stored != null);
|
||||
}
|
||||
try writer.writeByte('}');
|
||||
},
|
||||
}
|
||||
try writer.writeByte('}');
|
||||
@@ -228,6 +269,22 @@ pub const Cursor = struct {
|
||||
}
|
||||
};
|
||||
|
||||
/// Whether writing `event` names an image: one `stored` holds, or one the
|
||||
/// session dropped.
|
||||
fn eventNamesImages(event: Event, stored: ?*const session_codec.StoredImages) bool {
|
||||
return switch (event) {
|
||||
.turn_committed => |turn| session_codec.turnNamesImages(turn, stored),
|
||||
// A delta leaves out the user's turn: it names only what its new
|
||||
// tool results do.
|
||||
.turn_progress => |progress| session_codec.checkpointNamesImages(progress.checkpoint, if (progress.delta) |delta| delta.from else null, stored),
|
||||
.history_replaced => |history| for (history) |turn| {
|
||||
if (session_codec.turnNamesImages(turn, stored)) break true;
|
||||
} else false,
|
||||
.tool_result => |result| session_codec.toolImagesNameImages(result.images, stored),
|
||||
.tool_intent, .turn_progress_cleared, .input_accepted, .input_withdrawn, .turn_yielded => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// A snapshot of a quiet prefix: `FXSN`, a version byte, the last `seq` it
|
||||
/// covers and the turn after it (each a little-endian u64), the id of the
|
||||
/// last turn (a length byte, zero for none, then its bytes), then a
|
||||
@@ -239,6 +296,9 @@ pub const Cursor = struct {
|
||||
const snapshot_magic = "FXSN";
|
||||
const snapshot_version_quiet: u8 = 1;
|
||||
const snapshot_version: u8 = 2;
|
||||
/// Version 2's layout, with an open turn that names images: ones the host
|
||||
/// stored, or ones the session dropped.
|
||||
const snapshot_naming_version: u8 = 3;
|
||||
const snapshot_header_bytes = snapshot_magic.len + 1 + 8 + 8 + 1;
|
||||
|
||||
pub const Snapshot = struct {
|
||||
@@ -264,6 +324,31 @@ pub fn encodeSnapshot(
|
||||
last_turn_id: ?[]const u8,
|
||||
open_turn: ?[]const u8,
|
||||
checkpoint: []const u8,
|
||||
) error{ OutOfMemory, SnapshotTooLarge }![]u8 {
|
||||
return encodeSnapshotWith(alloc, seq, turn, last_turn_id, open_turn, checkpoint, false);
|
||||
}
|
||||
|
||||
/// Like `encodeSnapshot`, for an `open_turn` that names at least one image
|
||||
/// (`session_codec.namesImages`).
|
||||
pub fn encodeSnapshotNamingImages(
|
||||
alloc: Allocator,
|
||||
seq: u64,
|
||||
turn: u64,
|
||||
last_turn_id: ?[]const u8,
|
||||
open_turn: []const u8,
|
||||
checkpoint: []const u8,
|
||||
) error{ OutOfMemory, SnapshotTooLarge }![]u8 {
|
||||
return encodeSnapshotWith(alloc, seq, turn, last_turn_id, open_turn, checkpoint, true);
|
||||
}
|
||||
|
||||
fn encodeSnapshotWith(
|
||||
alloc: Allocator,
|
||||
seq: u64,
|
||||
turn: u64,
|
||||
last_turn_id: ?[]const u8,
|
||||
open_turn: ?[]const u8,
|
||||
checkpoint: []const u8,
|
||||
open_turn_names_images: bool,
|
||||
) error{ OutOfMemory, SnapshotTooLarge }![]u8 {
|
||||
const id = last_turn_id orelse "";
|
||||
std.debug.assert(id.len <= max_turn_id_bytes);
|
||||
@@ -271,7 +356,12 @@ pub fn encodeSnapshot(
|
||||
if (open_turn) |open| if (open.len > std.math.maxInt(u32)) return error.SnapshotTooLarge;
|
||||
const bytes = try alloc.alloc(u8, snapshot_header_bytes + id.len + open_bytes + checkpoint.len);
|
||||
@memcpy(bytes[0..snapshot_magic.len], snapshot_magic);
|
||||
bytes[snapshot_magic.len] = if (open_turn == null) snapshot_version_quiet else snapshot_version;
|
||||
bytes[snapshot_magic.len] = if (open_turn == null)
|
||||
snapshot_version_quiet
|
||||
else if (open_turn_names_images)
|
||||
snapshot_naming_version
|
||||
else
|
||||
snapshot_version;
|
||||
std.mem.writeInt(u64, bytes[snapshot_magic.len + 1 ..][0..8], seq, .little);
|
||||
std.mem.writeInt(u64, bytes[snapshot_magic.len + 9 ..][0..8], turn, .little);
|
||||
bytes[snapshot_header_bytes - 1] = @intCast(id.len);
|
||||
@@ -291,8 +381,9 @@ pub fn parseSnapshot(bytes: []const u8) LoadError!Snapshot {
|
||||
return error.InvalidJournal;
|
||||
}
|
||||
const found_version = bytes[snapshot_magic.len];
|
||||
if (found_version > snapshot_version) return error.UnsupportedJournalVersion;
|
||||
if (found_version != snapshot_version and found_version != snapshot_version_quiet) return error.InvalidJournal;
|
||||
if (found_version > snapshot_naming_version) return error.UnsupportedJournalVersion;
|
||||
if (found_version == 0) return error.InvalidJournal;
|
||||
const has_open_turn = found_version != snapshot_version_quiet;
|
||||
const seq = std.mem.readInt(u64, bytes[snapshot_magic.len + 1 ..][0..8], .little);
|
||||
const turn = std.mem.readInt(u64, bytes[snapshot_magic.len + 9 ..][0..8], .little);
|
||||
if (seq == 0 or turn == 0 or seq > max_safe_position or turn > max_safe_position) return error.InvalidJournal;
|
||||
@@ -300,7 +391,7 @@ pub fn parseSnapshot(bytes: []const u8) LoadError!Snapshot {
|
||||
if (id_len > max_turn_id_bytes or bytes.len - snapshot_header_bytes < id_len) return error.InvalidJournal;
|
||||
var at: usize = snapshot_header_bytes + id_len;
|
||||
var open_turn: ?[]const u8 = null;
|
||||
if (found_version == snapshot_version) {
|
||||
if (has_open_turn) {
|
||||
if (bytes.len - at < 4) return error.InvalidJournal;
|
||||
const open_len = std.mem.readInt(u32, bytes[at..][0..4], .little);
|
||||
at += 4;
|
||||
@@ -320,6 +411,28 @@ pub fn parseSnapshot(bytes: []const u8) LoadError!Snapshot {
|
||||
/// Writes the turn that yielded, for a version 2 snapshot: its id, when the
|
||||
/// host gave one, and its progress as a `turn_progress` carries it.
|
||||
pub fn writeOpenTurn(writer: *std.Io.Writer, turn_id: ?[]const u8, progress: session_codec.RecoveryCheckpoint) !void {
|
||||
return writeOpenTurnWith(writer, turn_id, progress, null);
|
||||
}
|
||||
|
||||
/// Like `writeOpenTurn`, but names the images `stored` holds instead of
|
||||
/// writing their bytes. Returns whether it named any, a dropped one too,
|
||||
/// which makes the snapshot version 3 (`encodeSnapshotNamingImages`).
|
||||
pub fn writeOpenTurnNamingImages(
|
||||
writer: *std.Io.Writer,
|
||||
turn_id: ?[]const u8,
|
||||
progress: session_codec.RecoveryCheckpoint,
|
||||
stored: *const session_codec.StoredImages,
|
||||
) !bool {
|
||||
try writeOpenTurnWith(writer, turn_id, progress, stored);
|
||||
return session_codec.checkpointNamesImages(progress, null, stored);
|
||||
}
|
||||
|
||||
fn writeOpenTurnWith(
|
||||
writer: *std.Io.Writer,
|
||||
turn_id: ?[]const u8,
|
||||
progress: session_codec.RecoveryCheckpoint,
|
||||
stored: ?*const session_codec.StoredImages,
|
||||
) !void {
|
||||
try writer.writeByte('{');
|
||||
if (turn_id) |id| {
|
||||
std.debug.assert(id.len > 0 and id.len <= max_turn_id_bytes);
|
||||
@@ -328,7 +441,10 @@ pub fn writeOpenTurn(writer: *std.Io.Writer, turn_id: ?[]const u8, progress: ses
|
||||
try writer.writeByte(',');
|
||||
}
|
||||
try writer.writeAll("\"progress\":");
|
||||
try session_codec.writeRecoveryCheckpoint(writer, progress);
|
||||
if (stored) |names|
|
||||
try session_codec.writeRecoveryCheckpointNamingImages(writer, progress, .{ .stored = names })
|
||||
else
|
||||
try session_codec.writeRecoveryCheckpoint(writer, progress);
|
||||
try writer.writeByte('}');
|
||||
}
|
||||
|
||||
@@ -387,14 +503,22 @@ pub const Finished = struct {
|
||||
id: []u8,
|
||||
failed: bool,
|
||||
output: []u8,
|
||||
/// Owned. An image the host has yet to stage has no bytes, and the
|
||||
/// fold's resolver records it as missing.
|
||||
images: []types.ToolImage = &.{},
|
||||
};
|
||||
|
||||
fn freeFinished(alloc: Allocator, finished: []Finished) void {
|
||||
freeFinishedEntries(alloc, finished);
|
||||
alloc.free(finished);
|
||||
}
|
||||
|
||||
fn freeFinishedEntries(alloc: Allocator, finished: []const Finished) void {
|
||||
for (finished) |entry| {
|
||||
alloc.free(entry.id);
|
||||
alloc.free(entry.output);
|
||||
types.freeToolImages(alloc, entry.images);
|
||||
}
|
||||
alloc.free(finished);
|
||||
}
|
||||
|
||||
pub fn freePendingInputs(alloc: Allocator, inputs: []PendingInput) void {
|
||||
@@ -443,14 +567,12 @@ fn announcedCall(calls: []const std.json.Value, id: []const u8) bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Owned copies of the newest recorded result of each call in `calls`.
|
||||
fn finishedOf(alloc: Allocator, calls: []const types.ToolCall, results: []const std.json.Value) LoadError![]Finished {
|
||||
/// Owned copies of the newest recorded result of each call in `calls`, with
|
||||
/// the images each names resolved through `sources`.
|
||||
fn finishedOf(alloc: Allocator, calls: []const types.ToolCall, results: []const std.json.Value, sources: session_codec.ImageSources) LoadError![]Finished {
|
||||
var finished: std.ArrayList(Finished) = .empty;
|
||||
errdefer {
|
||||
for (finished.items) |entry| {
|
||||
alloc.free(entry.id);
|
||||
alloc.free(entry.output);
|
||||
}
|
||||
freeFinishedEntries(alloc, finished.items);
|
||||
finished.deinit(alloc);
|
||||
}
|
||||
for (calls) |call| {
|
||||
@@ -463,10 +585,19 @@ fn finishedOf(alloc: Allocator, calls: []const types.ToolCall, results: []const
|
||||
errdefer alloc.free(id);
|
||||
const output = try alloc.dupe(u8, result.object.get("output").?.string);
|
||||
errdefer alloc.free(output);
|
||||
const images: []types.ToolImage = if (result.object.get("images")) |list|
|
||||
session_codec.parseToolImages(alloc, list.array.items, sources) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
else => return error.InvalidJournal,
|
||||
}
|
||||
else
|
||||
&.{};
|
||||
errdefer types.freeToolImages(alloc, images);
|
||||
try finished.append(alloc, .{
|
||||
.id = id,
|
||||
.failed = std.mem.eql(u8, result.object.get("status").?.string, "failure"),
|
||||
.output = output,
|
||||
.images = images,
|
||||
});
|
||||
}
|
||||
return finished.toOwnedSlice(alloc);
|
||||
@@ -489,6 +620,10 @@ pub const Base = struct {
|
||||
last_turn_id: ?[]const u8 = null,
|
||||
/// The turn that yielded, as `writeOpenTurn` wrote it.
|
||||
open_turn: ?[]const u8 = null,
|
||||
/// Resolves the images events name. Without it an event that names one
|
||||
/// is invalid; a name it cannot resolve is recorded there, and the fold
|
||||
/// is then only good for learning which images the host must stage.
|
||||
images: ?*session_codec.ImageResolver = null,
|
||||
};
|
||||
|
||||
/// Rebuilds a session from `events_json`, a JSON array of events in order.
|
||||
@@ -551,7 +686,7 @@ pub fn foldFrom(alloc: Allocator, events_json: []const u8, base: Base) LoadError
|
||||
if (event.kind != .turn_yielded) yielded = false;
|
||||
switch (event.kind) {
|
||||
.turn_committed => {
|
||||
const turn = try parseTurn(alloc, event.data orelse return error.InvalidJournal);
|
||||
const turn = try parseTurn(alloc, event.data orelse return error.InvalidJournal, base.images);
|
||||
// The same rule the session applies when it commits the turn.
|
||||
if (replacesHistory(turn)) {
|
||||
for (history.items) |old| types.freeHistoryTurn(alloc, old);
|
||||
@@ -629,6 +764,7 @@ pub fn foldFrom(alloc: Allocator, events_json: []const u8, base: Base) LoadError
|
||||
if (!std.mem.eql(u8, status, "success") and !std.mem.eql(u8, status, "failure")) return error.InvalidJournal;
|
||||
const output = data.object.get("output") orelse return error.InvalidJournal;
|
||||
if (output != .string) return error.InvalidJournal;
|
||||
if (data.object.get("images")) |images| if (images != .array) return error.InvalidJournal;
|
||||
// One for a call no intent announced answers nothing.
|
||||
if (announcedCall(running.items, id)) try results.append(alloc, data);
|
||||
}
|
||||
@@ -666,7 +802,7 @@ pub fn foldFrom(alloc: Allocator, events_json: []const u8, base: Base) LoadError
|
||||
replacement.deinit(alloc);
|
||||
}
|
||||
try replacement.ensureTotalCapacity(alloc, data.array.items.len);
|
||||
for (data.array.items) |item| replacement.appendAssumeCapacity(try parseTurn(alloc, item));
|
||||
for (data.array.items) |item| replacement.appendAssumeCapacity(try parseTurn(alloc, item, base.images));
|
||||
for (history.items) |turn| types.freeHistoryTurn(alloc, turn);
|
||||
history.deinit(alloc);
|
||||
history = replacement;
|
||||
@@ -680,7 +816,7 @@ pub fn foldFrom(alloc: Allocator, events_json: []const u8, base: Base) LoadError
|
||||
}
|
||||
|
||||
var open_turn: ?session_codec.RecoveryCheckpoint = null;
|
||||
if (open) |data| open_turn = if (data == .null) return error.InvalidJournal else session_codec.parseRecoveryCheckpoint(alloc, data) catch |err| switch (err) {
|
||||
if (open) |data| open_turn = if (data == .null) return error.InvalidJournal else session_codec.parseRecoveryCheckpointWithImages(alloc, data, .{ .names = base.images }) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
else => return error.InvalidJournal,
|
||||
};
|
||||
@@ -697,7 +833,7 @@ pub fn foldFrom(alloc: Allocator, events_json: []const u8, base: Base) LoadError
|
||||
return err;
|
||||
};
|
||||
errdefer types.freeToolCallSlice(alloc, unanswered);
|
||||
const finished = try finishedOf(alloc, unanswered, results.items);
|
||||
const finished = try finishedOf(alloc, unanswered, results.items, .{ .names = base.images });
|
||||
errdefer freeFinished(alloc, finished);
|
||||
const pending = try pendingInputsOf(alloc, if (open_turn != null) inputs.items else &.{}, .steer);
|
||||
errdefer freePendingInputs(alloc, pending);
|
||||
@@ -863,8 +999,8 @@ fn replacesHistory(turn: types.HistoryTurn) bool {
|
||||
};
|
||||
}
|
||||
|
||||
fn parseTurn(alloc: Allocator, value: std.json.Value) LoadError!types.HistoryTurn {
|
||||
return session_codec.parseHistoryTurn(alloc, value) catch |err| switch (err) {
|
||||
fn parseTurn(alloc: Allocator, value: std.json.Value, images: ?*session_codec.ImageResolver) LoadError!types.HistoryTurn {
|
||||
return session_codec.parseHistoryTurnWithImages(alloc, value, .{ .names = images }) catch |err| switch (err) {
|
||||
error.OutOfMemory => error.OutOfMemory,
|
||||
else => error.InvalidJournal,
|
||||
};
|
||||
@@ -1277,7 +1413,7 @@ test "a journal from a newer fx or with a malformed event is rejected" {
|
||||
const alloc = std.testing.allocator;
|
||||
try std.testing.expectError(
|
||||
error.UnsupportedJournalVersion,
|
||||
fold(alloc, "[{\"v\":3,\"seq\":1,\"turn\":1,\"type\":\"turn_progress_cleared\"}]"),
|
||||
fold(alloc, "[{\"v\":4,\"seq\":1,\"turn\":1,\"type\":\"turn_progress_cleared\"}]"),
|
||||
);
|
||||
const malformed = [_][]const u8{
|
||||
"{}",
|
||||
@@ -1656,7 +1792,7 @@ test "a snapshot that is not one, or comes from a newer fx, is refused" {
|
||||
try std.testing.expectError(error.InvalidJournal, parseSnapshot(zero));
|
||||
const newer = try encodeSnapshot(alloc, 3, 2, null, null, "");
|
||||
defer alloc.free(newer);
|
||||
newer[snapshot_magic.len] = snapshot_version + 1;
|
||||
newer[snapshot_magic.len] = snapshot_naming_version + 1;
|
||||
try std.testing.expectError(error.UnsupportedJournalVersion, parseSnapshot(newer));
|
||||
}
|
||||
|
||||
@@ -1698,3 +1834,199 @@ test "a turn's first progress names it, and the turn keeps that name until it en
|
||||
defer alloc.free(wrong);
|
||||
try std.testing.expectError(error.InvalidJournal, fold(alloc, wrong));
|
||||
}
|
||||
|
||||
fn testImageUser(alloc: Allocator, png: []const u8) !types.UserTurn {
|
||||
const name = session_codec.imageNameOf(png);
|
||||
const images = try alloc.alloc(types.ImageAttachment, 1);
|
||||
images[0] = .{
|
||||
.id = 1,
|
||||
.path = try alloc.dupe(u8, "inline://image-1"),
|
||||
.media_type = try alloc.dupe(u8, "image/png"),
|
||||
.snapshot_sha256 = try alloc.dupe(u8, &name),
|
||||
.inline_data = try alloc.dupe(u8, png),
|
||||
};
|
||||
return .{ .text = try alloc.dupe(u8, "[Image #1]"), .images = images };
|
||||
}
|
||||
|
||||
fn testImageTurn(alloc: Allocator, png: []const u8) !types.HistoryTurn {
|
||||
return .{ .assistant = .{ .user = try testImageUser(alloc, png), .assistant = try alloc.dupe(u8, "seen") } };
|
||||
}
|
||||
|
||||
test "a journal names the images the host stored and folds them back from its copies" {
|
||||
const alloc = std.testing.allocator;
|
||||
const png = "\x89PNG\r\n\x1a\njournal-named";
|
||||
const name = session_codec.imageNameOf(png);
|
||||
const turn = try testImageTurn(alloc, png);
|
||||
defer types.freeHistoryTurn(alloc, turn);
|
||||
var stored: session_codec.StoredImages = .{};
|
||||
defer stored.deinit(alloc);
|
||||
try stored.add(alloc, name);
|
||||
|
||||
var out: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer out.deinit();
|
||||
try out.writer.writeByte('[');
|
||||
_ = try (Cursor{}).writeNamingImages(&out.writer, .{ .turn_committed = turn }, &stored);
|
||||
try out.writer.writeByte(']');
|
||||
const events = out.written();
|
||||
// The event names the image and holds none of its bytes.
|
||||
try std.testing.expect(std.mem.startsWith(u8, events, "[{\"v\":3,"));
|
||||
try std.testing.expect(std.mem.find(u8, events, "\"inline_ref\":\"" ++ name ++ "\"") != null);
|
||||
try std.testing.expect(std.mem.find(u8, events, "journal-named") == null);
|
||||
try std.testing.expect(std.mem.find(u8, events, "\"inline_data\"") == null);
|
||||
|
||||
try std.testing.expectError(error.InvalidJournal, fold(alloc, events));
|
||||
|
||||
var staged: session_codec.StagedImages = .{};
|
||||
defer staged.deinit(alloc);
|
||||
try staged.add(alloc, name, try alloc.dupe(u8, png));
|
||||
var resolver: session_codec.ImageResolver = .{ .alloc = alloc, .staged = &staged };
|
||||
defer resolver.deinit();
|
||||
var folded = try foldFrom(alloc, events, .{ .images = &resolver });
|
||||
defer folded.deinit(alloc);
|
||||
try std.testing.expectEqualStrings(png, folded.history[0].assistant.user.images[0].inline_data.?);
|
||||
try std.testing.expectEqual(@as(usize, 0), resolver.missing.count());
|
||||
try std.testing.expect(resolver.resolved.contains(name));
|
||||
}
|
||||
|
||||
test "an event that names no image is version 1, byte for byte" {
|
||||
const alloc = std.testing.allocator;
|
||||
const turn = try testImageTurn(alloc, "\x89PNG\r\n\x1a\nnot-stored");
|
||||
defer types.freeHistoryTurn(alloc, turn);
|
||||
var stored: session_codec.StoredImages = .{};
|
||||
defer stored.deinit(alloc);
|
||||
try stored.add(alloc, session_codec.imageNameOf("another image"));
|
||||
|
||||
var plain: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer plain.deinit();
|
||||
_ = try (Cursor{}).write(&plain.writer, .{ .turn_committed = turn });
|
||||
var named: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer named.deinit();
|
||||
_ = try (Cursor{}).writeNamingImages(&named.writer, .{ .turn_committed = turn }, &stored);
|
||||
try std.testing.expectEqualStrings(plain.written(), named.written());
|
||||
try std.testing.expect(std.mem.startsWith(u8, named.written(), "{\"v\":1,"));
|
||||
}
|
||||
|
||||
test "one fold names every image the host did not stage" {
|
||||
const alloc = std.testing.allocator;
|
||||
const first_png = "\x89PNG\r\n\x1a\nfirst-missing";
|
||||
const second_png = "\x89PNG\r\n\x1a\nsecond-missing";
|
||||
var stored: session_codec.StoredImages = .{};
|
||||
defer stored.deinit(alloc);
|
||||
try stored.add(alloc, session_codec.imageNameOf(first_png));
|
||||
try stored.add(alloc, session_codec.imageNameOf(second_png));
|
||||
|
||||
var out: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer out.deinit();
|
||||
try out.writer.writeByte('[');
|
||||
var cursor: Cursor = .{};
|
||||
const first = try testImageTurn(alloc, first_png);
|
||||
defer types.freeHistoryTurn(alloc, first);
|
||||
cursor = try cursor.writeNamingImages(&out.writer, .{ .turn_committed = first }, &stored);
|
||||
try out.writer.writeByte(',');
|
||||
var progress = try testProgress(alloc, "");
|
||||
defer progress.deinit(alloc);
|
||||
types.freeUserTurn(alloc, progress.user);
|
||||
progress.user = try testImageUser(alloc, second_png);
|
||||
_ = try cursor.writeNamingImages(&out.writer, .{ .turn_progress = .{ .checkpoint = progress } }, &stored);
|
||||
try out.writer.writeByte(']');
|
||||
|
||||
var staged: session_codec.StagedImages = .{};
|
||||
defer staged.deinit(alloc);
|
||||
var resolver: session_codec.ImageResolver = .{ .alloc = alloc, .staged = &staged };
|
||||
defer resolver.deinit();
|
||||
var folded = try foldFrom(alloc, out.written(), .{ .images = &resolver });
|
||||
defer folded.deinit(alloc);
|
||||
try std.testing.expectEqual(@as(usize, 2), resolver.missing.count());
|
||||
try std.testing.expect(resolver.missing.contains(session_codec.imageNameOf(first_png)));
|
||||
try std.testing.expect(resolver.missing.contains(session_codec.imageNameOf(second_png)));
|
||||
}
|
||||
|
||||
test "a recorded tool result keeps its images, naming the stored one" {
|
||||
const alloc = std.testing.allocator;
|
||||
const stored_png = image_data.testPngHeader(1, 1);
|
||||
const inline_png = image_data.testPngHeader(2, 1);
|
||||
const name = session_codec.imageNameOf(&stored_png);
|
||||
var stored: session_codec.StoredImages = .{};
|
||||
defer stored.deinit(alloc);
|
||||
try stored.add(alloc, name);
|
||||
var stored_data: [std.base64.standard.Encoder.calcSize(stored_png.len)]u8 = undefined;
|
||||
var inline_data: [std.base64.standard.Encoder.calcSize(inline_png.len)]u8 = undefined;
|
||||
const images = [_]types.ToolImage{
|
||||
.{ .data = @constCast(std.base64.standard.Encoder.encode(&stored_data, &stored_png)), .mime_type = @constCast("image/png"), .name = name },
|
||||
.{ .data = @constCast(std.base64.standard.Encoder.encode(&inline_data, &inline_png)), .mime_type = @constCast("image/png") },
|
||||
};
|
||||
var progress = try testProgress(alloc, "take two shots");
|
||||
defer progress.deinit(alloc);
|
||||
const calls = [_]types.ToolCall{.{ .id = "call-a", .name = "screenshot", .arguments_json = "{}" }};
|
||||
|
||||
var out: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer out.deinit();
|
||||
try out.writer.writeByte('[');
|
||||
var cursor: Cursor = .{};
|
||||
cursor = try cursor.writeNamingImages(&out.writer, .{ .turn_progress = .{ .checkpoint = progress } }, &stored);
|
||||
try out.writer.writeByte(',');
|
||||
cursor = try cursor.writeNamingImages(&out.writer, .{ .tool_intent = &calls }, &stored);
|
||||
try out.writer.writeByte(',');
|
||||
_ = try cursor.writeNamingImages(&out.writer, .{ .tool_result = .{ .id = "call-a", .name = "screenshot", .failed = false, .output = "two shots", .images = &images } }, &stored);
|
||||
try out.writer.writeByte(']');
|
||||
const bytes = out.written();
|
||||
// It names the stored image, which makes it a naming event, and holds the
|
||||
// other's bytes.
|
||||
try std.testing.expect(std.mem.find(u8, bytes, "{\"v\":3,\"seq\":3,\"turn\":1,\"type\":\"tool_result\"") != null);
|
||||
try std.testing.expect(std.mem.find(u8, bytes, images[0].data) == null);
|
||||
try std.testing.expect(std.mem.find(u8, bytes, images[1].data) != null);
|
||||
|
||||
// An open that has yet to stage the stored image names it as missing.
|
||||
var staged: session_codec.StagedImages = .{};
|
||||
defer staged.deinit(alloc);
|
||||
var discover: session_codec.ImageResolver = .{ .alloc = alloc, .staged = &staged };
|
||||
defer discover.deinit();
|
||||
var unstaged = try foldFrom(alloc, bytes, .{ .images = &discover });
|
||||
defer unstaged.deinit(alloc);
|
||||
try std.testing.expect(discover.missing.contains(name));
|
||||
|
||||
// Staged, the call is answered with both images.
|
||||
try staged.add(alloc, name, try alloc.dupe(u8, &stored_png));
|
||||
var resolver: session_codec.ImageResolver = .{ .alloc = alloc, .staged = &staged };
|
||||
defer resolver.deinit();
|
||||
var folded = try foldFrom(alloc, bytes, .{ .images = &resolver });
|
||||
defer folded.deinit(alloc);
|
||||
try std.testing.expectEqual(@as(usize, 0), resolver.missing.count());
|
||||
try std.testing.expectEqual(@as(usize, 1), folded.finished.len);
|
||||
try std.testing.expectEqualStrings("two shots", folded.finished[0].output);
|
||||
try std.testing.expectEqual(@as(usize, 2), folded.finished[0].images.len);
|
||||
try std.testing.expectEqualStrings(images[0].data, folded.finished[0].images[0].data);
|
||||
try std.testing.expectEqualStrings(images[1].data, folded.finished[0].images[1].data);
|
||||
}
|
||||
|
||||
test "a yielded snapshot names its open turn's stored images" {
|
||||
const alloc = std.testing.allocator;
|
||||
const png = "\x89PNG\r\n\x1a\nyielded-turn";
|
||||
const name = session_codec.imageNameOf(png);
|
||||
var stored: session_codec.StoredImages = .{};
|
||||
defer stored.deinit(alloc);
|
||||
try stored.add(alloc, name);
|
||||
var progress = try testProgress(alloc, "");
|
||||
defer progress.deinit(alloc);
|
||||
types.freeUserTurn(alloc, progress.user);
|
||||
progress.user = try testImageUser(alloc, png);
|
||||
|
||||
var open_out: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer open_out.deinit();
|
||||
try std.testing.expect(try writeOpenTurnNamingImages(&open_out.writer, "turn-y", progress, &stored));
|
||||
try std.testing.expect(std.mem.find(u8, open_out.written(), "yielded-turn") == null);
|
||||
const snapshot = try encodeSnapshotNamingImages(alloc, 5, 2, null, open_out.written(), "");
|
||||
defer alloc.free(snapshot);
|
||||
try std.testing.expectEqual(snapshot_naming_version, snapshot[snapshot_magic.len]);
|
||||
const parsed = try parseSnapshot(snapshot);
|
||||
|
||||
var staged: session_codec.StagedImages = .{};
|
||||
defer staged.deinit(alloc);
|
||||
try staged.add(alloc, name, try alloc.dupe(u8, png));
|
||||
var resolver: session_codec.ImageResolver = .{ .alloc = alloc, .staged = &staged };
|
||||
defer resolver.deinit();
|
||||
var folded = try foldFrom(alloc, "[]", .{ .cursor = .{ .next_seq = 6, .turn = 2 }, .open_turn = parsed.open_turn, .images = &resolver });
|
||||
defer folded.deinit(alloc);
|
||||
try std.testing.expectEqualStrings(png, folded.open_turn.?.user.images[0].inline_data.?);
|
||||
try std.testing.expect(folded.yielded);
|
||||
}
|
||||
@@ -3464,6 +3464,16 @@ fn streamReplaySafe(
|
||||
return stream_ctx.interruption_source_or("").len == 0 and !stream_ctx.saw_tool_start;
|
||||
}
|
||||
|
||||
/// Whether the current attempt has shown no output. A provider refuses a
|
||||
/// request for its size before any output, so a reply an earlier attempt
|
||||
/// streamed, such as one restored when a turn resumes, does not make sending
|
||||
/// it again with fewer images unsafe.
|
||||
fn attemptReplaySafe(
|
||||
stream_ctx: *const runtime_assistant_stream.StreamChunkContext,
|
||||
) bool {
|
||||
return stream_ctx.raw_text.items.len == 0 and !stream_ctx.saw_tool_start;
|
||||
}
|
||||
|
||||
const continue_response_recovery_prompt =
|
||||
"The previous response was interrupted. Restart that response from the beginning using the completed tool results above. Do not repeat completed tool actions.";
|
||||
const regenerate_tool_recovery_prompt =
|
||||
@@ -3979,6 +3989,44 @@ fn persistRecoveryCheckpoint(
|
||||
strategy: ?model_response_recovery.Strategy,
|
||||
tool_evidence: model_response_recovery.ToolEvidence,
|
||||
trace_ctx: TraceContext,
|
||||
) !void {
|
||||
try persistRecoveryCheckpointWithAction(
|
||||
deps,
|
||||
finalization,
|
||||
job,
|
||||
current_turn_messages,
|
||||
assistant_source,
|
||||
route_model,
|
||||
requested_fast_mode,
|
||||
fast_mode,
|
||||
attempt_limit,
|
||||
consumed_attempts,
|
||||
outstanding_reservation,
|
||||
cause,
|
||||
checkpointAction(strategy),
|
||||
tool_evidence,
|
||||
trace_ctx,
|
||||
);
|
||||
}
|
||||
|
||||
/// Persists a recovery checkpoint whose action the caller already knows,
|
||||
/// such as a finished final reply that recovery must deliver as saved.
|
||||
fn persistRecoveryCheckpointWithAction(
|
||||
deps: *const AgentRuntimeDeps,
|
||||
finalization: *const TurnFinalizationGuard,
|
||||
job: QueuedPrompt,
|
||||
current_turn_messages: []const ChatMessage,
|
||||
assistant_source: []const u8,
|
||||
route_model: []const u8,
|
||||
requested_fast_mode: bool,
|
||||
fast_mode: bool,
|
||||
attempt_limit: usize,
|
||||
consumed_attempts: usize,
|
||||
outstanding_reservation: bool,
|
||||
cause: model_response_recovery.FailureCause,
|
||||
action: types.ModelRecoveryAction,
|
||||
tool_evidence: model_response_recovery.ToolEvidence,
|
||||
trace_ctx: TraceContext,
|
||||
) !void {
|
||||
if (deps.recovery_checkpoint == null and deps.append_turn_piece == null) return;
|
||||
// Every sink copies or serializes the borrowed checkpoint synchronously.
|
||||
@@ -4001,7 +4049,7 @@ fn persistRecoveryCheckpoint(
|
||||
.assistant_source = @constCast(assistant_source),
|
||||
.execution = projected,
|
||||
.cause = checkpointCause(cause),
|
||||
.action = checkpointAction(strategy),
|
||||
.action = action,
|
||||
.tool_state = checkpointToolState(tool_evidence),
|
||||
.authority = .{
|
||||
.provider = job.provider,
|
||||
@@ -4033,11 +4081,59 @@ fn persistRecoveryCheckpoint(
|
||||
attempt_limit,
|
||||
if (outstanding_reservation) "true" else "false",
|
||||
@tagName(cause),
|
||||
@tagName(strategy orelse .retry_request),
|
||||
@tagName(action),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Saves the reply a turn is about to end with, so a crash before the turn's
|
||||
/// end is stored resumes by delivering it instead of asking the model again.
|
||||
/// Called only once nothing can go on with the turn: every check of the reply
|
||||
/// passed, and no steer, subagent, or stop hook continues it.
|
||||
fn persistFinalReplyCheckpoint(
|
||||
deps: *const AgentRuntimeDeps,
|
||||
finalization: *const TurnFinalizationGuard,
|
||||
job: QueuedPrompt,
|
||||
current_turn_messages: []const ChatMessage,
|
||||
reply: []const u8,
|
||||
route_model: []const u8,
|
||||
requested_fast_mode: bool,
|
||||
fast_mode: bool,
|
||||
attempt_limit: usize,
|
||||
consumed_attempts: usize,
|
||||
cause: model_response_recovery.FailureCause,
|
||||
trace_ctx: TraceContext,
|
||||
) !void {
|
||||
if (std.mem.trim(u8, reply, " \t\r\n").len == 0) return;
|
||||
try persistRecoveryCheckpointWithAction(
|
||||
deps,
|
||||
finalization,
|
||||
job,
|
||||
current_turn_messages,
|
||||
reply,
|
||||
route_model,
|
||||
requested_fast_mode,
|
||||
fast_mode,
|
||||
attempt_limit,
|
||||
consumed_attempts,
|
||||
false,
|
||||
cause,
|
||||
.delivering_response,
|
||||
.none,
|
||||
trace_ctx,
|
||||
);
|
||||
}
|
||||
|
||||
/// A checkpoint `persistFinalReplyCheckpoint` saved holds the reply its turn
|
||||
/// was ending with. Recovery delivers it as the turn's reply instead of
|
||||
/// asking the model again (`tests/tla/durable/Resumption.tla`
|
||||
/// `SavedAnswerNotRequested`).
|
||||
fn deliversSavedResponse(checkpoint: session_codec.RecoveryCheckpoint) bool {
|
||||
return checkpoint.action == .delivering_response and
|
||||
checkpoint.tool_state == .none and
|
||||
std.mem.trim(u8, checkpoint.assistant_source, " \t\r\n").len > 0;
|
||||
}
|
||||
|
||||
/// The running turn, saved just before automatic compaction sends anything
|
||||
/// so a crash during compaction can recover it.
|
||||
const CompactionSource = struct {
|
||||
@@ -4536,24 +4632,24 @@ test "context overflow recovery is typed safe and bounded" {
|
||||
));
|
||||
}
|
||||
|
||||
/// Size retries allowed in one turn. Each lowers the learned limit, so the
|
||||
/// next request leaves out more of its oldest images.
|
||||
const max_request_size_retries: u8 = 2;
|
||||
|
||||
/// A size rejection is retried without compaction only when the rejected
|
||||
/// request carried images the next one can leave out. Anything else falls
|
||||
/// through to context overflow recovery.
|
||||
/// A size rejection is retried without compaction while the rejected request
|
||||
/// carried images, and fewer than the request refused before it, if any.
|
||||
/// Each retry lowers the learned limit below the size just refused, which
|
||||
/// only leaving out more images can meet, so the count falls with every
|
||||
/// retry and the retries end. Anything else falls through to context
|
||||
/// overflow recovery.
|
||||
fn shouldRetryWithFewerImages(
|
||||
failure: agent_stream_provider.Failure,
|
||||
images_sent: usize,
|
||||
retries_used: u8,
|
||||
images_last_refused: ?usize,
|
||||
replay_safe: bool,
|
||||
cancelled: bool,
|
||||
) bool {
|
||||
const fewer = if (images_last_refused) |last| images_sent < last else true;
|
||||
return !cancelled and
|
||||
replay_safe and
|
||||
images_sent > 0 and
|
||||
retries_used < max_request_size_retries and
|
||||
fewer and
|
||||
failure.kind == .request_too_large;
|
||||
}
|
||||
|
||||
@@ -4601,15 +4697,20 @@ test "size rejections retry with fewer images only while images remain" {
|
||||
const cases = [_]struct {
|
||||
failure: agent_stream_provider.Failure,
|
||||
images_sent: usize = 3,
|
||||
retries_used: u8 = 0,
|
||||
images_last_refused: ?usize = null,
|
||||
replay_safe: bool = true,
|
||||
cancelled: bool = false,
|
||||
expected: bool,
|
||||
}{
|
||||
.{ .failure = too_large, .expected = true },
|
||||
.{ .failure = too_large, .retries_used = max_request_size_retries - 1, .expected = true },
|
||||
.{ .failure = too_large, .retries_used = max_request_size_retries, .expected = false },
|
||||
// Any number of retries, while each leaves out more images.
|
||||
.{ .failure = too_large, .images_sent = 3, .images_last_refused = 5, .expected = true },
|
||||
.{ .failure = too_large, .images_sent = 1, .images_last_refused = 2, .expected = true },
|
||||
// One that left out none stops, so the retries always end.
|
||||
.{ .failure = too_large, .images_sent = 3, .images_last_refused = 3, .expected = false },
|
||||
.{ .failure = too_large, .images_sent = 4, .images_last_refused = 3, .expected = false },
|
||||
.{ .failure = too_large, .images_sent = 0, .expected = false },
|
||||
.{ .failure = too_large, .images_sent = 0, .images_last_refused = 1, .expected = false },
|
||||
.{ .failure = too_large, .replay_safe = false, .expected = false },
|
||||
.{ .failure = too_large, .cancelled = true, .expected = false },
|
||||
.{ .failure = overflow_text, .expected = false },
|
||||
@@ -4617,7 +4718,7 @@ test "size rejections retry with fewer images only while images remain" {
|
||||
for (cases) |case| try std.testing.expectEqual(case.expected, shouldRetryWithFewerImages(
|
||||
case.failure,
|
||||
case.images_sent,
|
||||
case.retries_used,
|
||||
case.images_last_refused,
|
||||
case.replay_safe,
|
||||
case.cancelled,
|
||||
));
|
||||
@@ -7120,12 +7221,48 @@ fn processQueuedPromptLoop(
|
||||
|
||||
if (restore_recovery_source) {
|
||||
const checkpoint = job.recovery_checkpoint.?;
|
||||
restore_recovery_source = false;
|
||||
// The reply finished before the turn was saved as done: deliver
|
||||
// it as saved. A steer that arrived, or a stop hook still to run,
|
||||
// means the turn goes on, so those cases restart as before.
|
||||
if (deliversSavedResponse(checkpoint) and steering_action == .none and
|
||||
(!lifecycle.view.hasStop() or stop_state.dispatched))
|
||||
{
|
||||
debug_trace.eventf(
|
||||
"agent",
|
||||
"recovery_delivered_saved_response",
|
||||
step_ctx,
|
||||
"bytes={d}",
|
||||
.{checkpoint.assistant_source.len},
|
||||
);
|
||||
// A host that already holds the reply got its closing newline
|
||||
// too; one that holds none of the turn gets both now.
|
||||
try stream_ctx.restoreRecoverySource(
|
||||
checkpoint.assistant_source,
|
||||
job.recovery_source_already_presented,
|
||||
);
|
||||
if (!job.recovery_source_already_presented) try deps.push_text(deps.ctx, .{ .assistant_rendered = "\n" });
|
||||
try finishCommonAssistantTerminal(
|
||||
deps,
|
||||
finalization,
|
||||
arena,
|
||||
job,
|
||||
within_turn_suffix.items,
|
||||
&summary_accumulator,
|
||||
.{ .history = checkpoint.assistant_source },
|
||||
.completed,
|
||||
null,
|
||||
&finish_trace,
|
||||
"recovered_assistant",
|
||||
.{ .role = .assistant, .content = checkpoint.assistant_source },
|
||||
);
|
||||
return;
|
||||
}
|
||||
try stream_ctx.restoreRecoverySource(
|
||||
checkpoint.assistant_source,
|
||||
job.recovery_source_already_presented,
|
||||
);
|
||||
try stream_ctx.beginRecoveryAttempt();
|
||||
restore_recovery_source = false;
|
||||
}
|
||||
|
||||
var advertised_dynamic_tools = try runtime_gateway_step.snapshotDynamicTools(arena, deps, &selected_dynamic_tools);
|
||||
@@ -7143,7 +7280,9 @@ fn processQueuedPromptLoop(
|
||||
var assistant_prefill_recovery_used = false;
|
||||
var skip_next_preflight_refresh = false;
|
||||
var context_overflow_recovery: ContextOverflowRecoveryState = .ready;
|
||||
var request_size_retries: u8 = 0;
|
||||
var request_size_retries: usize = 0;
|
||||
// The images the last request refused for its size carried.
|
||||
var request_images_last_refused: ?usize = null;
|
||||
var recovery_has_unexecuted_tool_start = false;
|
||||
var successful_recovery_strategy: ?model_response_recovery.Strategy = null;
|
||||
defer {
|
||||
@@ -7337,11 +7476,12 @@ fn processQueuedPromptLoop(
|
||||
subagent_request_messages.len,
|
||||
});
|
||||
}
|
||||
const result_request_messages = try project_read_tool_result_request_messages(
|
||||
// Images the session dropped leave every request as notes.
|
||||
const result_request_messages = try image_attachments.withholdDroppedImages(overlay_arena, try project_read_tool_result_request_messages(
|
||||
overlay_arena,
|
||||
read_tool_result_request_eligible,
|
||||
subagent_request_messages,
|
||||
);
|
||||
));
|
||||
if (!tool_image_capabilities_resolved and request_capabilities.image_input_support == .unknown) {
|
||||
for (result_request_messages) |message| {
|
||||
const memory = message.tool_result_memory orelse continue;
|
||||
@@ -7365,9 +7505,12 @@ fn processQueuedPromptLoop(
|
||||
break :native projection.messages;
|
||||
} else result_request_messages;
|
||||
const image_projection = try runtime_gateway_step.projectToolImageMessages(overlay_arena, materialized_messages, request_capabilities.image_input_support, vision_policy.route == .fallback, config.max_tool_result_bytes);
|
||||
const request_messages = try with_replyable_conversation_tail(overlay_arena, image_projection.messages);
|
||||
if (request_messages.ptr != image_projection.messages.ptr) {
|
||||
debug_trace.eventf("history", "assistant_tail_continued", step_ctx, "messages={d}", .{image_projection.messages.len});
|
||||
// Saved turns keep steering without its envelope; the model gets it
|
||||
// back as the live update it saw when the turn took it.
|
||||
const steering_messages = try runtime_execution_memory.rewrapRestoredSteering(overlay_arena, image_projection.messages);
|
||||
const request_messages = try with_replyable_conversation_tail(overlay_arena, steering_messages);
|
||||
if (request_messages.ptr != steering_messages.ptr) {
|
||||
debug_trace.eventf("history", "assistant_tail_continued", step_ctx, "messages={d}", .{steering_messages.len});
|
||||
}
|
||||
// Tool images withheld because the catalog could not confirm image
|
||||
// input are invisible in the transcript otherwise. Tell the user
|
||||
@@ -7421,6 +7564,20 @@ fn processQueuedPromptLoop(
|
||||
.max_output_tokens = model_capabilities.requestOutputTokens(request_capabilities),
|
||||
.budget = .{ .cancel_flag = config.cancel_flag },
|
||||
};
|
||||
// The request re-sends every image the session keeps, and
|
||||
// building it whole would hold all of them, encoded, at once.
|
||||
// Images that pass every limit by their own size leave before the
|
||||
// build; `fitRequestImages` fits the rest to this model's limit.
|
||||
const past_limit = try request_image_budget.withholdImagesPastLimit(
|
||||
overlay_arena,
|
||||
request_data.messages,
|
||||
request_image_budget.largestRequestBytes(agent.requestByteLimit(gateway_model)),
|
||||
config.max_tool_result_bytes,
|
||||
);
|
||||
if (past_limit.withheld > 0) {
|
||||
debug_trace.eventf("images", "request_images_withheld_before_build", step_ctx, "withheld={d}", .{past_limit.withheld});
|
||||
request_data.messages = past_limit.messages;
|
||||
}
|
||||
var prepared_request_body: ?[]const u8 = null;
|
||||
var request_cost_for_attempt: ?runtime_prompt_context.RequestCost = null;
|
||||
var request_images_for_attempt: usize = 0;
|
||||
@@ -7433,7 +7590,10 @@ fn processQueuedPromptLoop(
|
||||
deps.agent_stream_provider,
|
||||
&request_data,
|
||||
built_request_body,
|
||||
.{ .max_request_bytes = agent.requestByteLimit(gateway_model) orelse request_image_budget.default_max_request_bytes },
|
||||
.{ .max_request_bytes = request_image_budget.maxRequestBytes(
|
||||
agent.requestByteLimit(gateway_model),
|
||||
deps.agent_stream_provider.max_request_bytes,
|
||||
) },
|
||||
config.max_tool_result_bytes,
|
||||
step_ctx,
|
||||
);
|
||||
@@ -7597,7 +7757,14 @@ fn processQueuedPromptLoop(
|
||||
.tool_steps = finalization.compacted_execution.tool_steps + active_cut.tool_steps,
|
||||
.steering = finalization.compacted_execution.steering + active_cut.steering,
|
||||
};
|
||||
if (overflow_pending) context_overflow_recovery = .used;
|
||||
if (overflow_pending) {
|
||||
context_overflow_recovery = .used;
|
||||
// The shorter history can fit more images than the
|
||||
// request last refused for its size carried, so size
|
||||
// retries count again from the next refusal. This
|
||||
// happens once per step, so the retries still end.
|
||||
request_images_last_refused = null;
|
||||
}
|
||||
compactor.traceEvent(
|
||||
step_ctx,
|
||||
.installed,
|
||||
@@ -8243,12 +8410,13 @@ fn processQueuedPromptLoop(
|
||||
if (request_cost_for_attempt) |rejected| if (shouldRetryWithFewerImages(
|
||||
failure,
|
||||
request_images_for_attempt,
|
||||
request_size_retries,
|
||||
streamReplaySafe(&stream_ctx),
|
||||
request_images_last_refused,
|
||||
attemptReplaySafe(&stream_ctx),
|
||||
config.cancel_flag.load(.seq_cst),
|
||||
)) {
|
||||
const limit = loweredRequestByteLimit(rejected.serialized_bytes);
|
||||
agent.lowerRequestByteLimit(gateway_model, limit);
|
||||
request_images_last_refused = request_images_for_attempt;
|
||||
request_size_retries += 1;
|
||||
debug_trace.eventf("images", "request_size_retry", step_ctx, "model={s} rejected_bytes={d} images_sent={d} limit_bytes={d} retry={d}", .{
|
||||
gateway_model,
|
||||
@@ -9504,6 +9672,20 @@ fn processQueuedPromptLoop(
|
||||
);
|
||||
stop_state.terminal_materializing =
|
||||
stop_state.retained_candidate != null;
|
||||
if (has_content and disposition == .completed) try persistFinalReplyCheckpoint(
|
||||
deps,
|
||||
finalization,
|
||||
job,
|
||||
within_turn_suffix.items,
|
||||
persisted_text.history,
|
||||
gateway_model,
|
||||
selected_fast_mode,
|
||||
route_fast_mode,
|
||||
semantic_limit,
|
||||
semantic_attempt + 1,
|
||||
recovery_cause,
|
||||
step_ctx,
|
||||
);
|
||||
try finishCommonAssistantTerminal(
|
||||
deps,
|
||||
finalization,
|
||||
@@ -9726,8 +9908,19 @@ fn processQueuedPromptLoop(
|
||||
ToolCall,
|
||||
prepared_tool_calls.len,
|
||||
);
|
||||
const saved_output_sources: runtime_tool_presentation.TurnSavedOutputSources = .{
|
||||
.registry = deps.tool_registry,
|
||||
.workspace_root = config.workspace_root,
|
||||
.turns = &.{ history_messages.items, within_turn_suffix.items },
|
||||
};
|
||||
for (prepared_tool_calls, 0..) |prepared_call, i| {
|
||||
effective_tool_calls[i] = prepared_call.call();
|
||||
effective_tool_calls[i].resolved_display_target = try runtime_tool_presentation.readToolResultDisplayTarget(
|
||||
arena,
|
||||
deps.tool_registry,
|
||||
effective_tool_calls[i],
|
||||
saved_output_sources,
|
||||
);
|
||||
}
|
||||
const pending_assistant: ChatMessage = .{
|
||||
.role = .assistant,
|
||||
@@ -10000,6 +10193,7 @@ fn processQueuedPromptLoop(
|
||||
for (prepared_tool_calls, 0..) |prepared_tool_call, tool_call_index| {
|
||||
if (tool_call_index < parallel_skip_until) continue;
|
||||
var tool_call = prepared_tool_call.call();
|
||||
tool_call.resolved_display_target = effective_tool_calls[tool_call_index].resolved_display_target;
|
||||
const root_live_permission_mode = snapshotRootPermissionMode(deps);
|
||||
const root_action_permission_mode = permissionModeForAction(
|
||||
job.permission_mode,
|
||||
@@ -12311,6 +12505,20 @@ fn processQueuedPromptLoop(
|
||||
);
|
||||
stop_state.terminal_materializing =
|
||||
stop_state.retained_candidate != null;
|
||||
try persistFinalReplyCheckpoint(
|
||||
deps,
|
||||
finalization,
|
||||
job,
|
||||
within_turn_suffix.items,
|
||||
persisted_text.history,
|
||||
gateway_model,
|
||||
selected_fast_mode,
|
||||
route_fast_mode,
|
||||
semantic_limit,
|
||||
semantic_attempt + 1,
|
||||
recovery_cause,
|
||||
step_ctx,
|
||||
);
|
||||
try finishCommonAssistantTerminal(
|
||||
deps,
|
||||
finalization,
|
||||
|
||||
@@ -24,6 +24,13 @@ pub const Limits = struct {
|
||||
max_images: usize = default_max_request_images,
|
||||
};
|
||||
|
||||
/// The size a request must fit: the limit learned from the provider, else
|
||||
/// the default, and never more than the transport can carry.
|
||||
pub fn maxRequestBytes(learned: ?usize, transport: ?usize) usize {
|
||||
const limit = learned orelse default_max_request_bytes;
|
||||
return if (transport) |cap| @min(limit, cap) else limit;
|
||||
}
|
||||
|
||||
/// Returns how many of the oldest images must leave a request of
|
||||
/// `request_bytes` for it to fit `limits`. `image_bytes` holds each image's
|
||||
/// serialized size, oldest first. Returns `image_bytes.len` when the request
|
||||
@@ -54,6 +61,37 @@ pub fn withholdOldestImages(
|
||||
limits: Limits,
|
||||
tool_text_limit: usize,
|
||||
) Allocator.Error!Projection {
|
||||
const sizes = try imageSizes(arena, messages);
|
||||
return withhold(arena, messages, oldestImagesToWithhold(sizes, request_bytes, limits), tool_text_limit);
|
||||
}
|
||||
|
||||
/// A size no request is fit below: the default, or a larger limit learned
|
||||
/// from a provider's rejection. A transport may lower either, never raise it.
|
||||
pub fn largestRequestBytes(learned: ?usize) usize {
|
||||
return @max(default_max_request_bytes, learned orelse 0);
|
||||
}
|
||||
|
||||
/// Leaves out the oldest images a request of at most `max_request_bytes`
|
||||
/// can't carry by their serialized size alone, before the request is built.
|
||||
/// A request re-sends every image the session keeps, so building it whole
|
||||
/// would hold all of them, encoded, at once. A body is never smaller than
|
||||
/// its images, so this withholds no image `withholdOldestImages` would keep
|
||||
/// for a limit of at most `max_request_bytes`.
|
||||
pub fn withholdImagesPastLimit(
|
||||
arena: Allocator,
|
||||
messages: []const ChatMessage,
|
||||
max_request_bytes: usize,
|
||||
tool_text_limit: usize,
|
||||
) Allocator.Error!Projection {
|
||||
const sizes = try imageSizes(arena, messages);
|
||||
var images_bytes: usize = 0;
|
||||
for (sizes) |size| images_bytes +|= size;
|
||||
const withheld = oldestImagesToWithhold(sizes, images_bytes, .{ .max_request_bytes = max_request_bytes });
|
||||
return withhold(arena, messages, withheld, tool_text_limit);
|
||||
}
|
||||
|
||||
/// Each image's serialized size, oldest first, allocated in `arena`.
|
||||
fn imageSizes(arena: Allocator, messages: []const ChatMessage) Allocator.Error![]const usize {
|
||||
var sizes: std.ArrayList(usize) = .empty;
|
||||
for (messages) |message| {
|
||||
for (message.images) |image| {
|
||||
@@ -65,7 +103,11 @@ pub fn withholdOldestImages(
|
||||
for (memory.tool_images) |image| try sizes.append(arena, image.data.len);
|
||||
}
|
||||
}
|
||||
const withheld = oldestImagesToWithhold(sizes.items, request_bytes, limits);
|
||||
return sizes.items;
|
||||
}
|
||||
|
||||
/// `messages` with its `withheld` oldest images replaced by notes.
|
||||
fn withhold(arena: Allocator, messages: []const ChatMessage, withheld: usize, tool_text_limit: usize) Allocator.Error!Projection {
|
||||
if (withheld == 0) return .{ .messages = messages };
|
||||
|
||||
const projected = try arena.dupe(ChatMessage, messages);
|
||||
@@ -127,6 +169,15 @@ test "oldest images leave until the request fits" {
|
||||
try std.testing.expectEqual(@as(usize, 0), oldestImagesToWithhold(&.{}, 50_000, limits));
|
||||
}
|
||||
|
||||
test "a request fits the transport's limit as well as the provider's" {
|
||||
try std.testing.expectEqual(default_max_request_bytes, maxRequestBytes(null, null));
|
||||
try std.testing.expectEqual(@as(usize, 4000), maxRequestBytes(4000, null));
|
||||
// A host transport that carries less lowers either limit.
|
||||
try std.testing.expectEqual(@as(usize, 8000), maxRequestBytes(null, 8000));
|
||||
try std.testing.expectEqual(@as(usize, 4000), maxRequestBytes(4000, 8000));
|
||||
try std.testing.expectEqual(@as(usize, 3000), maxRequestBytes(4000, 3000));
|
||||
}
|
||||
|
||||
test "oldest images leave when a request carries too many" {
|
||||
const limits = Limits{ .max_request_bytes = 1_000_000, .max_images = 2 };
|
||||
const sizes = [_]usize{ 10, 10, 10, 10 };
|
||||
@@ -186,6 +237,46 @@ test "withholding keeps history and names how to reload each image" {
|
||||
try std.testing.expectEqual(@as([*]const ChatMessage, &messages), unchanged.messages.ptr);
|
||||
}
|
||||
|
||||
test "images no request can carry leave before it is built, and none it can" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
var tool_images: [6]types.ToolImage = undefined;
|
||||
for (&tool_images) |*image| image.* = try testToolImage(arena, 4000);
|
||||
const messages = [_]ChatMessage{
|
||||
.{ .role = .tool, .content = "<path>a.png</path>", .tool_result_memory = .{ .tool_images = tool_images[0..3], .tool_image_handle = "image-result-a" } },
|
||||
.{ .role = .tool, .content = "<path>b.png</path>", .tool_result_memory = .{ .tool_images = tool_images[3..], .tool_image_handle = "image-result-b" } },
|
||||
};
|
||||
|
||||
// 24,000 bytes of images against 10,000: the oldest four leave, whatever
|
||||
// the text adds, and the newest two stay for the exact fit.
|
||||
const before = try withholdImagesPastLimit(arena, &messages, 10_000, 4096);
|
||||
try std.testing.expectEqual(@as(usize, 4), before.withheld);
|
||||
try std.testing.expectEqual(@as(usize, 0), before.messages[0].tool_result_memory.?.tool_images.len);
|
||||
try std.testing.expectEqual(@as(usize, 2), before.messages[1].tool_result_memory.?.tool_images.len);
|
||||
// Fitting the whole body withholds at least as many.
|
||||
const sizes = [_]usize{ 4000, 4000, 4000, 4000, 4000, 4000 };
|
||||
for ([_]usize{ 24_000, 24_100, 30_000 }) |body_bytes| {
|
||||
try std.testing.expect(oldestImagesToWithhold(&sizes, body_bytes, .{ .max_request_bytes = 10_000 }) >= before.withheld);
|
||||
}
|
||||
|
||||
// Images that fit by size stay for the exact fit to decide.
|
||||
const fits = try withholdImagesPastLimit(arena, &messages, 30_000, 4096);
|
||||
try std.testing.expectEqual(@as(usize, 0), fits.withheld);
|
||||
try std.testing.expectEqual(@as([*]const ChatMessage, &messages), fits.messages.ptr);
|
||||
|
||||
// The image count limit applies too.
|
||||
const many: [default_max_request_images + 5]usize = @splat(1);
|
||||
try std.testing.expectEqual(@as(usize, 5), oldestImagesToWithhold(&many, many.len, .{ .max_request_bytes = largestRequestBytes(null) }));
|
||||
}
|
||||
|
||||
test "no learned limit lowers the bound a request is fit below" {
|
||||
try std.testing.expectEqual(default_max_request_bytes, largestRequestBytes(null));
|
||||
try std.testing.expectEqual(default_max_request_bytes, largestRequestBytes(1000));
|
||||
// A rejected request that was mostly text can teach a larger limit.
|
||||
try std.testing.expectEqual(default_max_request_bytes + 1, largestRequestBytes(default_max_request_bytes + 1));
|
||||
}
|
||||
|
||||
test "withheld snapshot attachments point at their saved file" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
|
||||
@@ -225,9 +225,12 @@ test "terminal assistant completion continues with steering admitted during the
|
||||
resumed_job,
|
||||
);
|
||||
|
||||
// The steer comes back as the live update the model saw, not as a plain
|
||||
// user message.
|
||||
try expectBodyContainsInOrder(&resumed_gateway, 0, &.{
|
||||
"user prompt",
|
||||
"Original answer",
|
||||
"user_steering",
|
||||
"change direction",
|
||||
"Updated answer",
|
||||
"follow up",
|
||||
@@ -5965,6 +5968,134 @@ test "processQueuedPrompt preserves a confirmed provider tool result across reco
|
||||
try expectBodyContains(&restored_gateway, 0, "exact provider evidence");
|
||||
}
|
||||
|
||||
test "processQueuedPrompt delivers a reply saved before its turn ended without another request" {
|
||||
const alloc = std.testing.allocator;
|
||||
const completions = [_]FakeCompletion{.{ .content = "The saved answer" }};
|
||||
var gateway = FakeGateway.init(alloc, &completions);
|
||||
defer gateway.deinit();
|
||||
var hooks = FakeAgentRuntimeDeps.init(alloc);
|
||||
hooks.enable_recovery_checkpoint = true;
|
||||
defer hooks.deinit();
|
||||
var fixture = PromptFixture{};
|
||||
const config = fixture.config();
|
||||
|
||||
try runFakePrompt(&gateway, &hooks, config, fixture.job());
|
||||
|
||||
// The checkpoint saved after the finished reply holds it for delivery.
|
||||
const settled = hooks.recovery_checkpoints.items[hooks.recovery_checkpoints.items.len - 1];
|
||||
try std.testing.expectEqual(types.ModelRecoveryAction.delivering_response, settled.action);
|
||||
try std.testing.expectEqualStrings("The saved answer", settled.assistant_source);
|
||||
|
||||
// The process died before the turn's end was stored: the restored turn
|
||||
// ends with the saved reply and asks the model nothing.
|
||||
var restored_checkpoint = try settled.dupe(alloc);
|
||||
defer restored_checkpoint.deinit(alloc);
|
||||
var restored_gateway = FakeGateway.init(alloc, &.{});
|
||||
defer restored_gateway.deinit();
|
||||
var restored_hooks = FakeAgentRuntimeDeps.init(alloc);
|
||||
defer restored_hooks.deinit();
|
||||
var restored_job = fixture.job();
|
||||
restored_job.recovery_checkpoint = restored_checkpoint;
|
||||
|
||||
try runFakePrompt(&restored_gateway, &restored_hooks, config, restored_job);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 0), restored_gateway.request_bodies.items.len);
|
||||
try std.testing.expectEqual(@as(usize, 1), restored_hooks.history_turns.items.len);
|
||||
try std.testing.expectEqualStrings(
|
||||
"The saved answer",
|
||||
restored_hooks.history_turns.items[0].assistant.assistant,
|
||||
);
|
||||
// A host that holds none of the turn's output gets the reply as its text.
|
||||
try std.testing.expectEqual(@as(usize, 1), restored_hooks.assistant_sources.items.len);
|
||||
try std.testing.expectEqualStrings("The saved answer", restored_hooks.assistant_sources.items[0]);
|
||||
|
||||
// A host that already holds it, as a durable session's stream does, gets
|
||||
// nothing again.
|
||||
var shown_gateway = FakeGateway.init(alloc, &.{});
|
||||
defer shown_gateway.deinit();
|
||||
var shown_hooks = FakeAgentRuntimeDeps.init(alloc);
|
||||
defer shown_hooks.deinit();
|
||||
var shown_job = fixture.job();
|
||||
shown_job.recovery_checkpoint = restored_checkpoint;
|
||||
shown_job.recovery_source_already_presented = true;
|
||||
|
||||
try runFakePrompt(&shown_gateway, &shown_hooks, config, shown_job);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 0), shown_gateway.request_bodies.items.len);
|
||||
try std.testing.expectEqual(@as(usize, 0), shown_hooks.assistant_sources.items.len);
|
||||
try std.testing.expectEqualStrings(
|
||||
"The saved answer",
|
||||
shown_hooks.history_turns.items[0].assistant.assistant,
|
||||
);
|
||||
}
|
||||
|
||||
test "processQueuedPrompt saves a reply that calls a tool for a retry, not for delivery" {
|
||||
const alloc = std.testing.allocator;
|
||||
const calls = [_]ToolCall{toolCall("call_read", "read_file", "{\"path\":\"a\"}")};
|
||||
const completions = [_]FakeCompletion{
|
||||
.{ .content = "Reading it first", .tool_calls = &calls },
|
||||
.{ .content = "Read it" },
|
||||
};
|
||||
var gateway = FakeGateway.init(alloc, &completions);
|
||||
defer gateway.deinit();
|
||||
var hooks = FakeAgentRuntimeDeps.init(alloc);
|
||||
hooks.enable_recovery_checkpoint = true;
|
||||
defer hooks.deinit();
|
||||
var fixture = PromptFixture{};
|
||||
|
||||
try runFakePrompt(&gateway, &hooks, fixture.config(), fixture.job());
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), hooks.executed_names.items.len);
|
||||
var delivering: usize = 0;
|
||||
for (hooks.recovery_checkpoints.items) |checkpoint| {
|
||||
if (checkpoint.action != .delivering_response) continue;
|
||||
delivering += 1;
|
||||
try std.testing.expectEqualStrings("Read it", checkpoint.assistant_source);
|
||||
}
|
||||
// Only the final reply, which called no tool, is saved for delivery.
|
||||
try std.testing.expectEqual(@as(usize, 1), delivering);
|
||||
}
|
||||
|
||||
test "processQueuedPrompt asks the model again when a steer waits at a saved reply" {
|
||||
const alloc = std.testing.allocator;
|
||||
const completions = [_]FakeCompletion{.{ .content = "The saved answer" }};
|
||||
var gateway = FakeGateway.init(alloc, &completions);
|
||||
defer gateway.deinit();
|
||||
var hooks = FakeAgentRuntimeDeps.init(alloc);
|
||||
hooks.enable_recovery_checkpoint = true;
|
||||
defer hooks.deinit();
|
||||
var fixture = PromptFixture{};
|
||||
const config = fixture.config();
|
||||
|
||||
try runFakePrompt(&gateway, &hooks, config, fixture.job());
|
||||
const settled = hooks.recovery_checkpoints.items[hooks.recovery_checkpoints.items.len - 1];
|
||||
try std.testing.expectEqual(types.ModelRecoveryAction.delivering_response, settled.action);
|
||||
|
||||
// A steer accepted before the crash joins the resumed turn, which goes on
|
||||
// with a request that carries it instead of ending with the saved reply.
|
||||
var restored_checkpoint = try settled.dupe(alloc);
|
||||
defer restored_checkpoint.deinit(alloc);
|
||||
const restored_completions = [_]FakeCompletion{.{ .content = "Answer with the steer" }};
|
||||
var restored_gateway = FakeGateway.init(alloc, &restored_completions);
|
||||
defer restored_gateway.deinit();
|
||||
const steering = [_][]const u8{"change direction"};
|
||||
var restored_hooks = FakeAgentRuntimeDeps.init(alloc);
|
||||
restored_hooks.steering_messages = &steering;
|
||||
restored_hooks.steering_take_at = 1;
|
||||
defer restored_hooks.deinit();
|
||||
var restored_job = fixture.job();
|
||||
restored_job.recovery_checkpoint = restored_checkpoint;
|
||||
|
||||
try runFakePrompt(&restored_gateway, &restored_hooks, config, restored_job);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), restored_gateway.request_bodies.items.len);
|
||||
try expectBodyContains(&restored_gateway, 0, "change direction");
|
||||
try std.testing.expectEqualStrings(
|
||||
"Answer with the steer",
|
||||
restored_hooks.history_turns.items[0].assistant.assistant,
|
||||
);
|
||||
}
|
||||
|
||||
test "processQueuedPrompt preserves the provider-executed subset of mixed failed tools" {
|
||||
const alloc = std.testing.allocator;
|
||||
const calls = [_]ToolCall{
|
||||
@@ -6630,12 +6761,14 @@ test "processQueuedPrompt counts and retries a definitely unsent native setup fa
|
||||
try runFakePrompt(&gateway, &hooks, config, fixture.job());
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 2), gateway.request_models.items.len);
|
||||
try std.testing.expectEqual(@as(usize, 4), hooks.recovery_checkpoints.items.len);
|
||||
// Reserve and settle for each attempt, then the reply the turn ends with.
|
||||
try std.testing.expectEqual(@as(usize, 5), hooks.recovery_checkpoints.items.len);
|
||||
try std.testing.expectEqual(
|
||||
@as(usize, 1),
|
||||
hooks.recovery_checkpoints.items[1].consumed_provider_attempts,
|
||||
);
|
||||
try std.testing.expect(!hooks.recovery_checkpoints.items[1].outstanding_reservation);
|
||||
try std.testing.expectEqual(types.ModelRecoveryAction.delivering_response, hooks.recovery_checkpoints.items[4].action);
|
||||
try expectRouteStatus(
|
||||
&hooks,
|
||||
0,
|
||||
@@ -6767,13 +6900,20 @@ test "processQueuedPrompt reserves and settles durable attempts before history c
|
||||
try runFakePrompt(&gateway, &hooks, fixture.config(), fixture.job());
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), gateway.request_models.items.len);
|
||||
try std.testing.expectEqual(@as(usize, 2), hooks.recovery_checkpoints.items.len);
|
||||
try std.testing.expectEqual(@as(usize, 3), hooks.recovery_checkpoints.items.len);
|
||||
const reserved = hooks.recovery_checkpoints.items[0];
|
||||
try std.testing.expect(reserved.outstanding_reservation);
|
||||
try std.testing.expectEqual(@as(usize, 0), reserved.consumed_provider_attempts);
|
||||
const settled = hooks.recovery_checkpoints.items[1];
|
||||
try std.testing.expect(!settled.outstanding_reservation);
|
||||
try std.testing.expectEqual(@as(usize, 1), settled.consumed_provider_attempts);
|
||||
// Before the turn ends with its reply, the reply is saved for delivery,
|
||||
// with the attempts the settled checkpoint counted.
|
||||
const final = hooks.recovery_checkpoints.items[2];
|
||||
try std.testing.expectEqual(types.ModelRecoveryAction.delivering_response, final.action);
|
||||
try std.testing.expectEqualStrings("Done", final.assistant_source);
|
||||
try std.testing.expect(!final.outstanding_reservation);
|
||||
try std.testing.expectEqual(@as(usize, 1), final.consumed_provider_attempts);
|
||||
try std.testing.expectEqual(@as(usize, 1), hooks.history_propagation_count);
|
||||
}
|
||||
|
||||
@@ -6819,7 +6959,7 @@ test "processQueuedPrompt carries one durable budget across transport recovery"
|
||||
try runFakePrompt(&gateway, &hooks, config, fixture.job());
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 2), gateway.request_models.items.len);
|
||||
try std.testing.expectEqual(@as(usize, 4), hooks.recovery_checkpoints.items.len);
|
||||
try std.testing.expectEqual(@as(usize, 5), hooks.recovery_checkpoints.items.len);
|
||||
try std.testing.expectEqual(@as(usize, 0), hooks.recovery_checkpoints.items[0].consumed_provider_attempts);
|
||||
try std.testing.expect(hooks.recovery_checkpoints.items[0].outstanding_reservation);
|
||||
try std.testing.expectEqual(@as(usize, 1), hooks.recovery_checkpoints.items[1].consumed_provider_attempts);
|
||||
@@ -6827,6 +6967,8 @@ test "processQueuedPrompt carries one durable budget across transport recovery"
|
||||
try std.testing.expectEqual(@as(usize, 1), hooks.recovery_checkpoints.items[2].consumed_provider_attempts);
|
||||
try std.testing.expect(hooks.recovery_checkpoints.items[2].outstanding_reservation);
|
||||
try std.testing.expectEqual(@as(usize, 2), hooks.recovery_checkpoints.items[3].consumed_provider_attempts);
|
||||
try std.testing.expectEqual(@as(usize, 2), hooks.recovery_checkpoints.items[4].consumed_provider_attempts);
|
||||
try std.testing.expectEqual(types.ModelRecoveryAction.delivering_response, hooks.recovery_checkpoints.items[4].action);
|
||||
try std.testing.expectEqual(@as(usize, 1), hooks.history_propagation_count);
|
||||
}
|
||||
|
||||
@@ -6848,7 +6990,7 @@ test "processQueuedPrompt retains interrupted source in the next reservation" {
|
||||
|
||||
try runFakePrompt(&gateway, &hooks, config, fixture.job());
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 4), hooks.recovery_checkpoints.items.len);
|
||||
try std.testing.expectEqual(@as(usize, 5), hooks.recovery_checkpoints.items.len);
|
||||
const settled = hooks.recovery_checkpoints.items[1];
|
||||
const reserved = hooks.recovery_checkpoints.items[2];
|
||||
try std.testing.expectEqualStrings("retained interrupted preview", settled.assistant_source);
|
||||
@@ -7094,6 +7236,87 @@ test "processQueuedPrompt explicit checkpoint continuation starts a fresh attemp
|
||||
try std.testing.expectEqual(@as(usize, 1), second_hooks.history_propagation_count);
|
||||
}
|
||||
|
||||
test "processQueuedPrompt resumed reply retries a size rejection with fewer images" {
|
||||
const alloc = std.testing.allocator;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
const image_path = try writeTestImagePath(alloc, &tmp);
|
||||
defer alloc.free(image_path);
|
||||
const image = try testCapturedImage(alloc, &tmp, image_path, 1);
|
||||
defer types.freeImageAttachment(alloc, image);
|
||||
var images = [_]types.ImageAttachment{image};
|
||||
const native_images = model_capabilities.Capabilities{
|
||||
.image_input_support = .native,
|
||||
.context_window = 1_000_000,
|
||||
};
|
||||
|
||||
// The first run streams part of a reply, then loses the connection and
|
||||
// parks, so its checkpoint holds the image and the partial reply.
|
||||
const partial_chunks = [_][]const u8{"partial"};
|
||||
const interrupted = [_]FakeCompletion{.{
|
||||
.chunks = &partial_chunks,
|
||||
.stream_error_after_chunks = error.ReadFailed,
|
||||
}};
|
||||
var first_gateway = FakeGateway.init(alloc, &interrupted);
|
||||
defer first_gateway.deinit();
|
||||
var first_hooks = FakeAgentRuntimeDeps.init(alloc);
|
||||
first_hooks.enable_recovery_checkpoint = true;
|
||||
first_hooks.default_model_capabilities = native_images;
|
||||
defer first_hooks.deinit();
|
||||
var fixture = PromptFixture{};
|
||||
var first_config = fixture.config();
|
||||
first_config.max_provider_attempts = 1;
|
||||
var pause_flag = std.atomic.Value(bool).init(false);
|
||||
first_hooks.pause_on_auto_retry_status = true;
|
||||
first_hooks.recovery_pause_flag = &pause_flag;
|
||||
first_config.recovery_pause_flag = &pause_flag;
|
||||
var first_job = fixture.job();
|
||||
first_job.credential_source = .fx_login;
|
||||
first_job.account_id = @constCast("acct_1");
|
||||
first_job.prompt = @constCast("Describe the attached image.");
|
||||
first_job.images = &images;
|
||||
first_job.authorized_image_catalog = &images;
|
||||
|
||||
try runFakePrompt(&first_gateway, &first_hooks, first_config, first_job);
|
||||
try expectBodyContains(&first_gateway, 0, "\"type\":\"file\"");
|
||||
const saved = first_hooks.recovery_checkpoints.items[first_hooks.recovery_checkpoints.items.len - 1];
|
||||
try std.testing.expectEqual(@as(usize, 1), saved.user.images.len);
|
||||
try std.testing.expect(saved.assistant_source.len > 0);
|
||||
var checkpoint = try saved.dupe(alloc);
|
||||
defer checkpoint.deinit(alloc);
|
||||
|
||||
// Resumed, the restarted reply is refused for its size before any output;
|
||||
// the turn sends it again without the image instead of failing.
|
||||
const resumed = [_]FakeCompletion{
|
||||
.{ .status = .payload_too_large, .err_body = "request entity too large" },
|
||||
.{ .content = "finished without the image" },
|
||||
};
|
||||
var second_gateway = FakeGateway.init(alloc, &resumed);
|
||||
defer second_gateway.deinit();
|
||||
var second_hooks = FakeAgentRuntimeDeps.init(alloc);
|
||||
second_hooks.enable_recovery_checkpoint = true;
|
||||
second_hooks.default_model_capabilities = native_images;
|
||||
defer second_hooks.deinit();
|
||||
var continued_job = fixture.job();
|
||||
continued_job.credential_source = .fx_login;
|
||||
continued_job.account_id = @constCast("acct_1");
|
||||
continued_job.prompt = checkpoint.user.text;
|
||||
continued_job.images = checkpoint.user.images;
|
||||
continued_job.authorized_image_catalog = checkpoint.user.images;
|
||||
continued_job.recovery_checkpoint = checkpoint;
|
||||
var continued_config = fixture.config();
|
||||
continued_config.max_provider_attempts = 2;
|
||||
|
||||
try runFakePrompt(&second_gateway, &second_hooks, continued_config, continued_job);
|
||||
|
||||
try expectGatewayPromptTailText(&second_gateway, 0, .user, "Restart that response from the beginning");
|
||||
try expectBodyContains(&second_gateway, 0, "\"type\":\"file\"");
|
||||
try std.testing.expectEqual(@as(usize, 2), second_gateway.request_models.items.len);
|
||||
try expectBodyNotContains(&second_gateway, 1, "\"type\":\"file\"");
|
||||
try expectBodyContains(&second_gateway, 1, "not sent to keep the request under");
|
||||
try std.testing.expectEqualStrings("finished without the image", second_hooks.history_assistant_text.?);
|
||||
}
|
||||
|
||||
test "processQueuedPrompt retries system resume through the heartbeat" {
|
||||
const alloc = std.testing.allocator;
|
||||
const completions = [_]FakeCompletion{
|
||||
@@ -7113,10 +7336,14 @@ test "processQueuedPrompt retries system resume through the heartbeat" {
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 2), gateway.request_models.items.len);
|
||||
try std.testing.expectEqual(types.TurnPresentationOutcome.completed, hooks.finalized_outcome.?);
|
||||
const checkpoint = hooks.recovery_checkpoints.items[hooks.recovery_checkpoints.items.len - 1];
|
||||
// The last settled attempt, before the checkpoint that saves the reply.
|
||||
const checkpoint = hooks.recovery_checkpoints.items[hooks.recovery_checkpoints.items.len - 2];
|
||||
try std.testing.expectEqual(types.ModelRecoveryCause.system_resumed, checkpoint.cause);
|
||||
try std.testing.expectEqual(@as(usize, 2), checkpoint.consumed_provider_attempts);
|
||||
try std.testing.expect(!checkpoint.outstanding_reservation);
|
||||
const final = hooks.recovery_checkpoints.items[hooks.recovery_checkpoints.items.len - 1];
|
||||
try std.testing.expectEqual(types.ModelRecoveryAction.delivering_response, final.action);
|
||||
try std.testing.expectEqual(@as(usize, 2), final.consumed_provider_attempts);
|
||||
try expectRouteStatus(
|
||||
&hooks,
|
||||
0,
|
||||
|
||||
@@ -14,6 +14,7 @@ const debug_trace = @import("../../shared/debug_trace.zig");
|
||||
const diff = @import("../../output/diff.zig");
|
||||
const file_mutation_contract = @import("../../tooling/file_mutation_contract.zig");
|
||||
const tool_result_errors = @import("../../tooling/tool_result_errors.zig");
|
||||
const read_result_presentation = @import("../../tooling/read_tool_result_presentation.zig");
|
||||
const command_result_mapping = if (@import("builtin").is_test)
|
||||
@import("../../tooling/command_result_mapping.zig")
|
||||
else
|
||||
@@ -751,6 +752,198 @@ pub fn fileMutationDisplayPath(
|
||||
};
|
||||
}
|
||||
|
||||
/// The handles under which a tool result keeps its full output or images.
|
||||
fn savedOutputHandles(
|
||||
output_handle: ?[]const u8,
|
||||
tool_image_handle: ?[]const u8,
|
||||
command_output_replay: ?types.CommandOutputReplay,
|
||||
) [3]?[]const u8 {
|
||||
return .{
|
||||
output_handle,
|
||||
tool_image_handle,
|
||||
if (command_output_replay) |replay| switch (replay) {
|
||||
.available => |descriptor| descriptor.handle,
|
||||
.unavailable => null,
|
||||
} else null,
|
||||
};
|
||||
}
|
||||
|
||||
/// Finds which call saved an output among a running prompt's messages: the
|
||||
/// newest tool result that keeps the handle, and the call that produced it.
|
||||
pub const TurnSavedOutputSources = struct {
|
||||
registry: tool_dispatch.Registry,
|
||||
workspace_root: []const u8,
|
||||
/// Message lists in conversation order, searched newest first.
|
||||
turns: []const []const types.ChatMessage,
|
||||
|
||||
/// Returns the terminal-safe name of the call that saved `handle`, or null
|
||||
/// when no message still holds it. The arena owns the returned text.
|
||||
pub fn find(self: TurnSavedOutputSources, arena: Allocator, handle: []const u8) !?[]const u8 {
|
||||
var turn_index = self.turns.len;
|
||||
while (turn_index > 0) {
|
||||
turn_index -= 1;
|
||||
const messages = self.turns[turn_index];
|
||||
var message_index = messages.len;
|
||||
while (message_index > 0) {
|
||||
message_index -= 1;
|
||||
const message = messages[message_index];
|
||||
if (message.role != .tool) continue;
|
||||
const memory = message.tool_result_memory orelse continue;
|
||||
if (!holdsHandle(savedOutputHandles(memory.output_handle, memory.tool_image_handle, memory.command_output_replay), handle)) continue;
|
||||
const source = self.findCall(message.tool_call_id orelse return null) orelse return null;
|
||||
return tooling_presentation.savedOutputSourceLabel(arena, self.registry, self.workspace_root, source);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
fn findCall(self: TurnSavedOutputSources, call_id: []const u8) ?ToolCall {
|
||||
var turn_index = self.turns.len;
|
||||
while (turn_index > 0) {
|
||||
turn_index -= 1;
|
||||
const messages = self.turns[turn_index];
|
||||
var message_index = messages.len;
|
||||
while (message_index > 0) {
|
||||
message_index -= 1;
|
||||
const message = messages[message_index];
|
||||
if (message.role != .assistant) continue;
|
||||
for (message.tool_calls) |call| {
|
||||
if (std.mem.eql(u8, call.id, call_id)) return call;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/// Names of the calls whose saved output later `read_tool_result` calls read,
|
||||
/// recorded while a saved conversation replays in order and keyed by output
|
||||
/// handle. Owns its keys and names; free them with `deinit`.
|
||||
pub const ReplayedSavedOutputSources = struct {
|
||||
map: std.StringHashMapUnmanaged([]const u8) = .empty,
|
||||
|
||||
pub fn deinit(self: *ReplayedSavedOutputSources, alloc: Allocator) void {
|
||||
var it = self.map.iterator();
|
||||
while (it.next()) |entry| {
|
||||
alloc.free(entry.key_ptr.*);
|
||||
alloc.free(entry.value_ptr.*);
|
||||
}
|
||||
self.map.deinit(alloc);
|
||||
}
|
||||
|
||||
/// Records `call` under each handle that keeps `result`'s output. A later
|
||||
/// call saving the same handle wins.
|
||||
pub fn record(
|
||||
self: *ReplayedSavedOutputSources,
|
||||
alloc: Allocator,
|
||||
registry: tool_dispatch.Registry,
|
||||
workspace_root: []const u8,
|
||||
call: ToolCall,
|
||||
result: types.PersistedToolResult,
|
||||
) Allocator.Error!void {
|
||||
const handles = savedOutputHandles(result.output_handle, result.tool_image_handle, result.command_output_replay);
|
||||
for (handles) |maybe_handle| {
|
||||
if (maybe_handle != null) break;
|
||||
} else return;
|
||||
const label = (try tooling_presentation.savedOutputSourceLabel(alloc, registry, workspace_root, call)) orelse return;
|
||||
defer alloc.free(label);
|
||||
for (handles) |maybe_handle| {
|
||||
const handle = maybe_handle orelse continue;
|
||||
const owned_handle = try alloc.dupe(u8, handle);
|
||||
errdefer alloc.free(owned_handle);
|
||||
const owned_label = try alloc.dupe(u8, label);
|
||||
errdefer alloc.free(owned_label);
|
||||
const gop = try self.map.getOrPut(alloc, owned_handle);
|
||||
if (gop.found_existing) {
|
||||
alloc.free(owned_handle);
|
||||
alloc.free(gop.value_ptr.*);
|
||||
}
|
||||
gop.value_ptr.* = owned_label;
|
||||
}
|
||||
}
|
||||
|
||||
/// The name of the call that saved `handle`, borrowed from the map, for
|
||||
/// `readToolResultDisplayTarget`.
|
||||
pub fn find(self: *const ReplayedSavedOutputSources, _: Allocator, handle: []const u8) error{}!?[]const u8 {
|
||||
return self.map.get(handle);
|
||||
}
|
||||
};
|
||||
|
||||
fn holdsHandle(handles: [3]?[]const u8, handle: []const u8) bool {
|
||||
for (handles) |candidate| {
|
||||
if (std.mem.eql(u8, candidate orelse continue, handle)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Row text for a `read_tool_result` call: what it reads, and the earlier
|
||||
/// call whose output that is when `sources.find(arena, handle)` still knows
|
||||
/// it. Returns null for other tools. The arena owns the returned text.
|
||||
pub fn readToolResultDisplayTarget(
|
||||
arena: Allocator,
|
||||
registry: tool_dispatch.Registry,
|
||||
call: ToolCall,
|
||||
sources: anytype,
|
||||
) !?[]const u8 {
|
||||
const spec = registry.lookup(call.name) orelse return null;
|
||||
if (spec.executor_kind != .read_tool_result) return null;
|
||||
const source = if (try read_result_presentation.requestedHandle(arena, call.arguments_json)) |handle|
|
||||
try sources.find(arena, handle)
|
||||
else
|
||||
null;
|
||||
return try read_result_presentation.displayTarget(arena, call.arguments_json, source);
|
||||
}
|
||||
|
||||
test "read_tool_result rows name the earlier call whose output they read" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
const registry = test_builtin_tools.advertisement_set.registry;
|
||||
const replay = "fx-command-replay-0123.bin";
|
||||
const earlier_turn = [_]types.ChatMessage{
|
||||
.{ .role = .assistant, .tool_calls = &.{.{
|
||||
.id = "read-1",
|
||||
.name = "read_file",
|
||||
.arguments_json = "{\"path\":\"src/main.zig\"}",
|
||||
}} },
|
||||
.{ .role = .tool, .tool_call_id = "read-1", .tool_result_memory = .{ .output_handle = "result-read_file-1.txt" } },
|
||||
};
|
||||
const current_turn = [_]types.ChatMessage{
|
||||
.{ .role = .assistant, .tool_calls = &.{.{
|
||||
.id = "run-1",
|
||||
.name = "shell",
|
||||
.arguments_json = "{\"action\":\"run\",\"command\":\"zig build test\"}",
|
||||
}} },
|
||||
.{ .role = .tool, .tool_call_id = "run-1", .tool_result_memory = .{
|
||||
.command_output_replay = .{ .available = .{ .handle = replay, .framed_bytes = 64 } },
|
||||
} },
|
||||
};
|
||||
const sources: TurnSavedOutputSources = .{
|
||||
.registry = registry,
|
||||
.workspace_root = "/workspace",
|
||||
.turns = &.{ earlier_turn[0..], current_turn[0..] },
|
||||
};
|
||||
const cases = [_]struct { arguments_json: []const u8, expected: ?[]const u8 }{
|
||||
.{ .arguments_json = "{\"request\":{\"handle\":\"" ++ replay ++ "\",\"start_byte\":9}}", .expected = "more output from zig build test" },
|
||||
.{ .arguments_json = "{\"request\":{\"handle\":\"" ++ replay ++ "\",\"query\":\"error:\"}}", .expected = "zig build test output for \"error:\"" },
|
||||
// The model may drop the `.txt` that `validate` restores.
|
||||
.{ .arguments_json = "{\"request\":{\"handle\":\"result-read_file-1\"}}", .expected = "more output from src/main.zig" },
|
||||
.{ .arguments_json = "{\"request\":{\"handle\":\"fx-command-replay-ffff.bin\"}}", .expected = null },
|
||||
.{ .arguments_json = "{\"request\":{\"handle\":\"T5\"}}", .expected = "earlier tool call T5" },
|
||||
};
|
||||
for (cases) |case| {
|
||||
const call: ToolCall = .{ .id = "page", .name = "read_tool_result", .arguments_json = case.arguments_json };
|
||||
const target = try readToolResultDisplayTarget(arena, registry, call, sources);
|
||||
if (case.expected) |expected| {
|
||||
try std.testing.expectEqualStrings(expected, target orelse return error.TestExpectedDisplayTarget);
|
||||
} else {
|
||||
try std.testing.expect(target == null);
|
||||
}
|
||||
}
|
||||
const other: ToolCall = .{ .id = "grep", .name = "grep_files", .arguments_json = "{\"pattern\":\"x\"}" };
|
||||
try std.testing.expect(try readToolResultDisplayTarget(arena, registry, other, sources) == null);
|
||||
}
|
||||
|
||||
pub noinline fn startToolVisibleLifecycle(
|
||||
hooks: *const AgentRuntimeDeps,
|
||||
arena: Allocator,
|
||||
@@ -778,6 +971,7 @@ pub noinline fn startToolVisibleLifecycle(
|
||||
.activity_kind = activity_kind,
|
||||
.arguments_json = redacted_arguments,
|
||||
.place_after_current_transcript = false,
|
||||
.resolved_display_target = call.resolved_display_target,
|
||||
} });
|
||||
if (activity_line) |line| {
|
||||
try hooks.push_tool_lifecycle(hooks.ctx, .{ .progress = .{
|
||||
|
||||
@@ -465,6 +465,9 @@ pub const Provider = struct {
|
||||
build_request_fn: ?BuildRequestFn = null,
|
||||
/// Pure provider-owned slicing when one reply becomes separate history units.
|
||||
project_replay_fn: ?*const fn (Allocator, ?types.ProviderReplay, []const types.ToolCall, bool, bool) anyerror!?types.ProviderReplay = null,
|
||||
/// The largest request body this provider's transport can carry, when it
|
||||
/// has its own limit below any provider's.
|
||||
max_request_bytes: ?usize = null,
|
||||
|
||||
/// Borrows unchanged payloads; projected payloads live in the caller's arena.
|
||||
pub fn projectReplay(self: Provider, arena: Allocator, replay: ?types.ProviderReplay, calls: []const types.ToolCall, text: bool, reasoning: bool) !?types.ProviderReplay {
|
||||
|
||||
@@ -1366,6 +1366,7 @@ fn formatToolAction(
|
||||
|
||||
const presentation = tool_dispatch.presentationForArgs(spec.*, args);
|
||||
const value = display_target orelse
|
||||
call.resolved_display_target orelse
|
||||
tool_presentation.resolvedSkillName(call, presentation) orelse
|
||||
tool_dispatch.presentationLabelValue(presentation, args) orelse
|
||||
presentation.label_arg_default;
|
||||
|
||||
@@ -375,6 +375,7 @@ pub fn Handlers(comptime App: type) type {
|
||||
.new_session = commandNewSession,
|
||||
.reset_session = commandResetSession,
|
||||
.resume_session = commandResumeSession,
|
||||
.fork_session = commandForkSession,
|
||||
|
||||
.show_help = commandShowHelp,
|
||||
.login = commandLogin,
|
||||
@@ -719,6 +720,19 @@ pub fn Handlers(comptime App: type) type {
|
||||
try app_session_runtime.Runtime(App).openSessionPicker(app);
|
||||
}
|
||||
|
||||
fn commandForkSession(ctx: *anyopaque) !void {
|
||||
const app: *App = @ptrCast(@alignCast(ctx));
|
||||
if (comptime !runtime_profile.allows(App, .durable_sessions)) {
|
||||
try app.writeDomainNotice(.{
|
||||
.topic = "session",
|
||||
.tone = .warning,
|
||||
.body = "Session forking is owned by the embedding SDK for this host.",
|
||||
}, true);
|
||||
return;
|
||||
}
|
||||
try app_session_runtime.Runtime(App).openForkMenu(app);
|
||||
}
|
||||
|
||||
fn commandRenameSession(ctx: *anyopaque, rest: []const u8) !void {
|
||||
const app: *App = @ptrCast(@alignCast(ctx));
|
||||
try handleRenameCommand(app, rest);
|
||||
|
||||
@@ -868,6 +868,7 @@ pub fn Runtime(comptime App: type) type {
|
||||
skillsMenuActive(app) or
|
||||
modelMenuActive(app) or
|
||||
sessionMenuActive(app) or
|
||||
forkMenuActive(app) or
|
||||
mcpMenuActive(app) or
|
||||
mcpHostMenuActive(app) or
|
||||
helpMenuActive(app);
|
||||
@@ -1579,6 +1580,7 @@ pub fn Runtime(comptime App: type) type {
|
||||
if (try submitSlashPickerSelection(app)) return;
|
||||
if (comptime runtime_profile.allows(App, .durable_sessions)) {
|
||||
if (try submitSessionPickerSelection(app)) return;
|
||||
if (try submitForkMenuSelection(app, max_input_len)) return;
|
||||
}
|
||||
if (try completion_rt.submitFilePickerOnEnter(app, max_input_len)) |result| {
|
||||
if (result == .limit_exceeded) {
|
||||
@@ -1841,6 +1843,30 @@ pub fn Runtime(comptime App: type) type {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Forks before the selected prompt and puts its text in the composer.
|
||||
fn submitForkMenuSelection(app: *App, max_input_len: usize) !bool {
|
||||
if (comptime !@hasDecl(App, "forkBeforeSelectedPrompt")) return false;
|
||||
if (!forkMenuActive(app)) return false;
|
||||
const prompt = app.forkBeforeSelectedPrompt() catch |err| {
|
||||
debug_trace.logf("session", "fork failed err={s}", .{@errorName(err)});
|
||||
try app.writeDomainNotice(.{
|
||||
.topic = "session",
|
||||
.tone = .@"error",
|
||||
.body = "Unable to fork the session.",
|
||||
}, true);
|
||||
app.shell.render_requests.request(.footer);
|
||||
return true;
|
||||
};
|
||||
defer app.alloc.free(prompt);
|
||||
if (prompt.len > max_input_len) {
|
||||
debug_trace.logf("input", "fork prompt not restored bytes={d} reason=input_limit", .{prompt.len});
|
||||
return true;
|
||||
}
|
||||
try app.input_runtime.textReplacementState().replace(app.alloc, prompt);
|
||||
app.shell.render_requests.request(.footer);
|
||||
return true;
|
||||
}
|
||||
|
||||
fn dismissAuthPickerForComposerEdit(app: *App) bool {
|
||||
if (comptime !@hasField(App, "auth")) return false;
|
||||
if (!app.auth.pickerView().active) return false;
|
||||
@@ -1884,6 +1910,11 @@ pub fn Runtime(comptime App: type) type {
|
||||
return app.session_persistence.session_picker.active;
|
||||
}
|
||||
|
||||
fn forkMenuActive(app: *App) bool {
|
||||
if (comptime !@hasField(App, "session_persistence")) return false;
|
||||
return app.session_persistence.fork_menu.active();
|
||||
}
|
||||
|
||||
fn helpMenuActive(app: *App) bool {
|
||||
return app.input_runtime.help_menu.active;
|
||||
}
|
||||
@@ -2454,7 +2485,7 @@ pub fn Runtime(comptime App: type) type {
|
||||
/// borrowing a composer they are already using.
|
||||
fn modelPickerShortcutBlocked(app: *App) bool {
|
||||
if (settingsMenuActive(app) or helpMenuActive(app) or
|
||||
skillsMenuActive(app) or sessionMenuActive(app) or mcpMenuActive(app) or mcpHostMenuActive(app)) return true;
|
||||
skillsMenuActive(app) or sessionMenuActive(app) or forkMenuActive(app) or mcpMenuActive(app) or mcpHostMenuActive(app)) return true;
|
||||
if (comptime @hasField(App, "auth")) {
|
||||
if (app.auth.pickerView().active) return true;
|
||||
}
|
||||
@@ -2472,7 +2503,9 @@ pub fn Runtime(comptime App: type) type {
|
||||
}
|
||||
|
||||
fn dismissActiveMenusForComposerEdit(app: *App) bool {
|
||||
return dismissAuthPickerForComposerEdit(app);
|
||||
// The fork menu reads no query from the composer, so an edit closes it.
|
||||
const fork_menu_closed = cancelForkMenu(app);
|
||||
return dismissAuthPickerForComposerEdit(app) or fork_menu_closed;
|
||||
}
|
||||
|
||||
fn dismissActiveMenusThenRedraw(app: *App) void {
|
||||
@@ -3174,7 +3207,7 @@ pub fn Runtime(comptime App: type) type {
|
||||
_ = disarmEscapeInterrupt(app, "approval_prompt");
|
||||
return;
|
||||
}
|
||||
if (cancelCompactCommandMenu(app) or (try cancelMcpMenu(app)) or cancelSettingsMenu(app) or cancelHelpMenu(app) or cancelModelMenu(app) or cancelSkillsMenu(app) or cancelSessionMenu(app)) {
|
||||
if (cancelCompactCommandMenu(app) or (try cancelMcpMenu(app)) or cancelSettingsMenu(app) or cancelHelpMenu(app) or cancelModelMenu(app) or cancelSkillsMenu(app) or cancelSessionMenu(app) or cancelForkMenu(app)) {
|
||||
_ = disarmEscapeClear(app);
|
||||
_ = disarmEscapeInterrupt(app, "menu");
|
||||
app.shell.render_requests.request(.footer);
|
||||
@@ -3221,7 +3254,7 @@ pub fn Runtime(comptime App: type) type {
|
||||
_ = disarmEscapeClear(app);
|
||||
return;
|
||||
}
|
||||
if (cancelCompactCommandMenu(app) or (try cancelMcpMenu(app)) or cancelSettingsMenu(app) or cancelHelpMenu(app) or cancelModelMenu(app) or cancelSkillsMenu(app) or cancelSessionMenu(app)) {
|
||||
if (cancelCompactCommandMenu(app) or (try cancelMcpMenu(app)) or cancelSettingsMenu(app) or cancelHelpMenu(app) or cancelModelMenu(app) or cancelSkillsMenu(app) or cancelSessionMenu(app) or cancelForkMenu(app)) {
|
||||
_ = disarmEscapeClear(app);
|
||||
app.shell.render_requests.request(.footer);
|
||||
return;
|
||||
@@ -3350,6 +3383,13 @@ pub fn Runtime(comptime App: type) type {
|
||||
paste_blocks.clearBlocks(app.alloc, &app.input_runtime.entities.pasted_blocks);
|
||||
return true;
|
||||
}
|
||||
|
||||
fn cancelForkMenu(app: *App) bool {
|
||||
if (comptime !@hasField(App, "session_persistence")) return false;
|
||||
if (!forkMenuActive(app)) return false;
|
||||
app.session_persistence.fork_menu.close(app.alloc);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
const ApprovalCancelEvent = enum {
|
||||
|
||||
@@ -1783,7 +1783,8 @@ pub const State = struct {
|
||||
var arena: std.heap.ArenaAllocator = .init(std.heap.c_allocator);
|
||||
defer arena.deinit();
|
||||
const a = arena.allocator();
|
||||
const statuses = lease.host.runtime.statuses(a) catch return "";
|
||||
// The status line never shows sign-in state, so it doesn't wait for it.
|
||||
const statuses = lease.host.runtime.statusesNow(a) catch return "";
|
||||
sink.mutex.lockUncancelable(io_mod.getIo());
|
||||
const news: ?[]const u8 = if (sink.news) |text|
|
||||
(if (now_ms - sink.news_at_ms < mcp_menu.news_ms) a.dupe(u8, text) catch null else null)
|
||||
@@ -2069,7 +2070,10 @@ pub const State = struct {
|
||||
try out.appendSlice(a, &verb_completions);
|
||||
const lease = self.leaseHost() orelse return out.items;
|
||||
defer lease.deinit();
|
||||
const statuses = try lease.host.runtime.statuses(a);
|
||||
// Rebuilt twice a second on the UI thread, so it doesn't wait for
|
||||
// saved credentials: a description says "signed in" from the next
|
||||
// rebuild after they are restored.
|
||||
const statuses = try lease.host.runtime.statusesNow(a);
|
||||
for (statuses, lease.host.runtime.servers()) |status, server| {
|
||||
if (!mcp_command_provider.isValidServerName(status.name)) continue;
|
||||
const description = try mcp_verbs.statusText(a, status);
|
||||
|
||||
@@ -825,6 +825,10 @@ pub fn Runtime(comptime App: type) type {
|
||||
.now_ms = now_ms,
|
||||
.selection_failure = app.session_persistence.session_picker.selection_failure,
|
||||
} else .{},
|
||||
.fork_menu = if (comptime @hasField(App, "session_persistence"))
|
||||
app.session_persistence.fork_menu.projection()
|
||||
else
|
||||
.{},
|
||||
.statusline_menu = render_input.statuslineMenuProjection(
|
||||
&app.input_runtime.statusline_menu,
|
||||
settings_snapshot,
|
||||
@@ -1187,7 +1191,7 @@ pub fn Runtime(comptime App: type) type {
|
||||
if (!question_active and approval == null and
|
||||
(settingsMenuActive(app) or helpMenuActive(app) or
|
||||
skillsMenuActive(app) or modelMenuActive(app) or
|
||||
sessionMenuActive(app)))
|
||||
sessionMenuActive(app) or forkMenuActive(app)))
|
||||
{
|
||||
app.shell.render_requests.request(.footer);
|
||||
}
|
||||
@@ -1197,7 +1201,7 @@ pub fn Runtime(comptime App: type) type {
|
||||
},
|
||||
}
|
||||
|
||||
if (!question_active and approval == null and (settingsMenuActive(app) or helpMenuActive(app) or skillsMenuActive(app) or modelMenuActive(app) or sessionMenuActive(app))) {
|
||||
if (!question_active and approval == null and (settingsMenuActive(app) or helpMenuActive(app) or skillsMenuActive(app) or modelMenuActive(app) or sessionMenuActive(app) or forkMenuActive(app))) {
|
||||
if (try app_lifecycle.closeFullTranscriptIfActive(
|
||||
app.alloc,
|
||||
&app.terminal,
|
||||
@@ -2007,6 +2011,11 @@ pub fn Runtime(comptime App: type) type {
|
||||
return false;
|
||||
}
|
||||
|
||||
fn forkMenuActive(app: *const App) bool {
|
||||
if (comptime @hasField(App, "session_persistence")) return app.session_persistence.fork_menu.active();
|
||||
return false;
|
||||
}
|
||||
|
||||
fn helpMenuActive(app: *const App) bool {
|
||||
if (comptime @hasField(App, "input_runtime")) return app.input_runtime.help_menu.active;
|
||||
return false;
|
||||
|
||||
@@ -67,6 +67,7 @@ const shell_runtime = @import("../../ui/shell_runtime.zig");
|
||||
const transcript_runtime = @import("../../ui/transcript/runtime.zig");
|
||||
const resume_projection = @import("../../ui/transcript/resume_projection.zig");
|
||||
const question_ui = @import("../../ui/footer/question_ui.zig");
|
||||
const render_input = @import("../../ui/footer/render_input.zig");
|
||||
const ui_input = @import("../../ui/input/runtime.zig");
|
||||
const ui_render = @import("../../ui/render.zig");
|
||||
const update_notes = @import("../upgrade/update_notes.zig");
|
||||
@@ -318,6 +319,8 @@ const UpgradeNotice = struct {
|
||||
const ResumeNotice = union(enum) {
|
||||
session,
|
||||
upgrade: UpgradeNotice,
|
||||
/// The id of the session this one was forked from.
|
||||
fork: []const u8,
|
||||
};
|
||||
|
||||
fn writeUpgradeNoticeBody(writer: *std.Io.Writer, upgrade: UpgradeNotice) !void {
|
||||
@@ -587,6 +590,35 @@ pub const SessionPicker = struct {
|
||||
}
|
||||
};
|
||||
|
||||
/// `/fork`'s menu: the open session's prompts, oldest first (D58).
|
||||
pub const ForkMenu = struct {
|
||||
points: []const session_adapter.Session.ForkPoint = &.{},
|
||||
selected: usize = 0,
|
||||
/// The first menu line shown. It starts past the end, which shows the
|
||||
/// newest prompts, and moves only as far as keeps the selection whole.
|
||||
window_start: usize = std.math.maxInt(usize),
|
||||
|
||||
pub fn active(self: *const ForkMenu) bool {
|
||||
return self.points.len > 0;
|
||||
}
|
||||
|
||||
pub fn projection(self: *const ForkMenu) render_input.ForkMenuProjection {
|
||||
return .{ .points = self.points, .selected = self.selected, .window_start = self.window_start };
|
||||
}
|
||||
|
||||
/// Moves the selection, stopping at the oldest and the newest prompt.
|
||||
pub fn move(self: *ForkMenu, delta: i32) void {
|
||||
if (self.points.len == 0) return;
|
||||
const next = @as(i64, @intCast(self.selected)) + delta;
|
||||
self.selected = @intCast(std.math.clamp(next, 0, @as(i64, @intCast(self.points.len - 1))));
|
||||
}
|
||||
|
||||
pub fn close(self: *ForkMenu, alloc: Allocator) void {
|
||||
session_adapter.Session.freeForkPoints(alloc, self.points);
|
||||
self.* = .{};
|
||||
}
|
||||
};
|
||||
|
||||
const SessionPickerCatalogCache = struct {
|
||||
ready: bool = false,
|
||||
loaded_at_ns: i128 = 0,
|
||||
@@ -1301,6 +1333,7 @@ pub const Persistence = struct {
|
||||
session_picker: SessionPicker = .{},
|
||||
session_picker_load: SessionPickerLoad = .{},
|
||||
session_picker_cache: SessionPickerCatalogCache = .{},
|
||||
fork_menu: ForkMenu = .{},
|
||||
title_generation: TitleGenerationLoad = .{},
|
||||
degraded_warning_emitted: bool = false,
|
||||
pending_cancelled_command: ?PendingCancelledCommand = null,
|
||||
@@ -1319,7 +1352,7 @@ pub const Persistence = struct {
|
||||
/// in a static release-binary template.
|
||||
pub fn initInto(storage: *Persistence) void {
|
||||
comptime {
|
||||
if (@typeInfo(Persistence).@"struct".field_names.len != 30) {
|
||||
if (@typeInfo(Persistence).@"struct".field_names.len != 31) {
|
||||
@compileError("update Persistence.initInto for the changed field set");
|
||||
}
|
||||
}
|
||||
@@ -1343,6 +1376,7 @@ pub const Persistence = struct {
|
||||
storage.session_picker = .{};
|
||||
storage.session_picker_load = .{};
|
||||
storage.session_picker_cache = .{};
|
||||
storage.fork_menu = .{};
|
||||
storage.title_generation = .{};
|
||||
storage.degraded_warning_emitted = false;
|
||||
storage.pending_cancelled_command = null;
|
||||
@@ -1394,6 +1428,7 @@ pub const Persistence = struct {
|
||||
self.session_picker.deinit(alloc);
|
||||
self.session_picker_load.deinit();
|
||||
if (catalog == .free) self.session_picker_cache.deinit();
|
||||
self.fork_menu.close(alloc);
|
||||
self.title_generation.deinit();
|
||||
// After the picker thread, which lists through it, has joined.
|
||||
if (self.v2_store) |*store| store.deinit(alloc);
|
||||
@@ -2038,6 +2073,7 @@ pub fn Runtime(comptime App: type) type {
|
||||
}
|
||||
app.question_prompt.discard(app.alloc, "session_reset");
|
||||
cancelSessionPicker(app);
|
||||
app.session_persistence.fork_menu.close(app.alloc);
|
||||
invalidateSessionPickerCaches(app);
|
||||
}
|
||||
|
||||
@@ -2303,6 +2339,64 @@ pub fn Runtime(comptime App: type) type {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Opens `/fork`'s menu of the session's prompts, newest selected,
|
||||
/// or says why it cannot (D58).
|
||||
pub fn openForkMenu(app: *App) !void {
|
||||
const v2 = app.session_persistence.v2 orelse return app.writeDomainNotice(.{
|
||||
.topic = "session",
|
||||
.tone = .warning,
|
||||
.body = "forking needs --sessions-v2",
|
||||
}, true);
|
||||
if (app.stream.active) return app.writeDomainNotice(.{
|
||||
.topic = "session",
|
||||
.tone = .neutral,
|
||||
.body = "/fork is available once the current turn finishes",
|
||||
}, true);
|
||||
const points = try v2.forkPoints(app.alloc);
|
||||
if (points.len == 0) {
|
||||
session_adapter.Session.freeForkPoints(app.alloc, points);
|
||||
return app.writeDomainNotice(.{
|
||||
.topic = "session",
|
||||
.tone = .neutral,
|
||||
.body = "nothing to fork yet",
|
||||
}, true);
|
||||
}
|
||||
const menu = &app.session_persistence.fork_menu;
|
||||
menu.close(app.alloc);
|
||||
menu.* = .{ .points = points, .selected = points.len - 1 };
|
||||
app.shell.render_requests.request(.footer);
|
||||
}
|
||||
|
||||
/// Forks the session before the fork menu's selected prompt and
|
||||
/// switches to the fork, which opens before the source closes (D58).
|
||||
/// Returns that prompt's text for the composer; caller owns it.
|
||||
pub fn forkBeforeSelectedPrompt(app: *App) ![]u8 {
|
||||
const menu = &app.session_persistence.fork_menu;
|
||||
const points = menu.points;
|
||||
const point = points[menu.selected];
|
||||
menu.* = .{};
|
||||
defer session_adapter.Session.freeForkPoints(app.alloc, points);
|
||||
const source = app.session_persistence.v2 orelse return error.SessionStoreUnavailable;
|
||||
const source_id = try app.alloc.dupe(u8, source.id());
|
||||
defer app.alloc.free(source_id);
|
||||
const prompt = try app.alloc.dupe(u8, point.prompt);
|
||||
errdefer app.alloc.free(prompt);
|
||||
const fork = try source.fork(app.alloc, .{
|
||||
.before_turn = point.turn,
|
||||
.workspace = app.workspace_root,
|
||||
.host = .app,
|
||||
.title = cachedSessionTitle(app),
|
||||
});
|
||||
var fork_owned = true;
|
||||
errdefer if (fork_owned) fork.close();
|
||||
try app.prepareLiveSessionResume();
|
||||
fork_owned = false;
|
||||
try installResumedV2Session(app, fork, .{ .fork = source_id });
|
||||
startResumedSessionWarmup(app);
|
||||
try app.finishLiveSessionResume();
|
||||
return prompt;
|
||||
}
|
||||
|
||||
fn loadResumeTargetForWrite(
|
||||
app: *App,
|
||||
target: session_store.ResumeTarget,
|
||||
@@ -4436,6 +4530,9 @@ pub fn Runtime(comptime App: type) type {
|
||||
|
||||
workspace_root: []const u8,
|
||||
map: std.StringHashMapUnmanaged(Label) = .empty,
|
||||
/// Calls whose saved output later `read_tool_result` rows read, so
|
||||
/// those rows name them as the live session did.
|
||||
saved_output_sources: tool_presentation.ReplayedSavedOutputSources = .{},
|
||||
|
||||
fn deinit(self: *HistoricalSessionLabels, alloc: Allocator) void {
|
||||
var it = self.map.iterator();
|
||||
@@ -4445,6 +4542,7 @@ pub fn Runtime(comptime App: type) type {
|
||||
if (entry.value_ptr.reflow) |value| alloc.free(value);
|
||||
}
|
||||
self.map.deinit(alloc);
|
||||
self.saved_output_sources.deinit(alloc);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4668,6 +4766,18 @@ pub fn Runtime(comptime App: type) type {
|
||||
.body = owned_body,
|
||||
});
|
||||
},
|
||||
.fork => |source_id| {
|
||||
try sink.appendNotice(.{
|
||||
.topic = "session forked",
|
||||
.tone = .neutral,
|
||||
.body = display.title,
|
||||
});
|
||||
// A plain line, so the command copies as it is. Forks
|
||||
// exist only on v2, so the command always names it.
|
||||
const line = try app.alloc.print("original session fx --sessions-v2 --resume {s}\n", .{source_id});
|
||||
defer app.alloc.free(line);
|
||||
try sink.appendRaw(line);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4960,6 +5070,13 @@ pub fn Runtime(comptime App: type) type {
|
||||
result: types.PersistedToolResult,
|
||||
labels: *HistoricalSessionLabels,
|
||||
) !void {
|
||||
try labels.saved_output_sources.record(
|
||||
app.alloc,
|
||||
app.toolAdvertisementSet().registry,
|
||||
labels.workspace_root,
|
||||
call,
|
||||
result,
|
||||
);
|
||||
if (try writeAnsweredQuestionResult(app, sink, call, result)) return;
|
||||
if (try writeCommittedFilePresentation(app, sink, call, result)) return;
|
||||
|
||||
@@ -4974,7 +5091,13 @@ pub fn Runtime(comptime App: type) type {
|
||||
|
||||
var action_arena = std.heap.ArenaAllocator.init(app.alloc);
|
||||
defer mem_utils.deinit_arena(action_arena);
|
||||
const session_target = historicalSessionTarget(action_arena.allocator(), labels, call);
|
||||
const action_target = historicalSessionTarget(action_arena.allocator(), labels, call) orelse
|
||||
try tool_presentation.readToolResultDisplayTarget(
|
||||
action_arena.allocator(),
|
||||
app.toolAdvertisementSet().registry,
|
||||
call,
|
||||
&labels.saved_output_sources,
|
||||
);
|
||||
const command_decision = if (is_command)
|
||||
try tool_presentation.commandOutcomeDecision(
|
||||
action_arena.allocator(),
|
||||
@@ -4994,7 +5117,7 @@ pub fn Runtime(comptime App: type) type {
|
||||
try app.describeToolActionDeniedWithAdvertised(
|
||||
action_arena.allocator(),
|
||||
call,
|
||||
session_target,
|
||||
action_target,
|
||||
if (context_deferred)
|
||||
types.context_deferred_tool_status_label
|
||||
else
|
||||
@@ -5005,7 +5128,7 @@ pub fn Runtime(comptime App: type) type {
|
||||
try app.describeToolActionDeniedWithAdvertised(
|
||||
action_arena.allocator(),
|
||||
call,
|
||||
session_target,
|
||||
action_target,
|
||||
tool_admission.permissionDeniedStatusLabel(reason),
|
||||
&.{},
|
||||
)
|
||||
@@ -5013,7 +5136,7 @@ pub fn Runtime(comptime App: type) type {
|
||||
try app.describeToolActionDeniedWithAdvertised(
|
||||
action_arena.allocator(),
|
||||
call,
|
||||
session_target,
|
||||
action_target,
|
||||
decision.label,
|
||||
&.{},
|
||||
)
|
||||
@@ -5037,13 +5160,13 @@ pub fn Runtime(comptime App: type) type {
|
||||
break :success try app.describeToolActionCompletedWithAdvertised(
|
||||
action_arena.allocator(),
|
||||
call,
|
||||
display_target orelse session_target,
|
||||
display_target orelse action_target,
|
||||
&.{},
|
||||
);
|
||||
} else try app.describeToolActionDeniedWithAdvertised(
|
||||
action_arena.allocator(),
|
||||
call,
|
||||
session_target,
|
||||
action_target,
|
||||
try tooling_presentation.subagentFailureLabel(action_arena.allocator(), call, result.output),
|
||||
&.{},
|
||||
);
|
||||
@@ -7494,6 +7617,56 @@ test "v2 ultrafast resume applies process overrides without persisting them" {
|
||||
}
|
||||
}
|
||||
|
||||
test "/fork needs v2, waits for the turn and a prompt, then opens on the newest prompt (D58)" {
|
||||
const alloc = std.testing.allocator;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
const paths = try testPaths(alloc, &tmp);
|
||||
defer {
|
||||
alloc.free(paths.home);
|
||||
alloc.free(paths.workspace);
|
||||
}
|
||||
const home = try TestHome.install(alloc, paths.home);
|
||||
defer home.deinit();
|
||||
{
|
||||
var app = try TestApp.init(alloc, paths.workspace);
|
||||
defer app.deinit();
|
||||
try configureTestPreferences(&app);
|
||||
try Runtime(TestApp).initializePersistence(&app, true);
|
||||
try Runtime(TestApp).beginFreshPersistedSession(&app);
|
||||
try Runtime(TestApp).openForkMenu(&app);
|
||||
try std.testing.expectEqualStrings("! session: forking needs --sessions-v2", app.notices.last().?);
|
||||
try std.testing.expect(!app.session_persistence.fork_menu.active());
|
||||
}
|
||||
|
||||
var app = try TestApp.init(alloc, paths.workspace);
|
||||
defer app.deinit();
|
||||
try configureTestPreferences(&app);
|
||||
app.session_persistence.sessions_v2 = true;
|
||||
try Runtime(TestApp).initializePersistence(&app, true);
|
||||
try Runtime(TestApp).beginFreshPersistedSession(&app);
|
||||
try Runtime(TestApp).openForkMenu(&app);
|
||||
try std.testing.expectEqualStrings("* session: nothing to fork yet", app.notices.last().?);
|
||||
|
||||
for ([_][]const u8{ "first", "second", "third" }) |prompt| {
|
||||
try app.session_persistence.v2.?.commitTurn(.{ .assistant = .{
|
||||
.user = .{ .text = @constCast(prompt) },
|
||||
.assistant = @constCast("answer"),
|
||||
} }, types.ConversationLanguage.default());
|
||||
}
|
||||
app.stream.active = true;
|
||||
try Runtime(TestApp).openForkMenu(&app);
|
||||
try std.testing.expectEqualStrings("* session: /fork is available once the current turn finishes", app.notices.last().?);
|
||||
try std.testing.expect(!app.session_persistence.fork_menu.active());
|
||||
|
||||
app.stream.active = false;
|
||||
try Runtime(TestApp).openForkMenu(&app);
|
||||
const menu = &app.session_persistence.fork_menu;
|
||||
try std.testing.expectEqual(@as(usize, 3), menu.points.len);
|
||||
try std.testing.expectEqualStrings("first", menu.points[0].prompt);
|
||||
try std.testing.expectEqualStrings("third", menu.points[menu.selected].prompt);
|
||||
}
|
||||
|
||||
test "beginFreshPersistedSession and enableSessionStores create per-session stores" {
|
||||
const alloc = std.testing.allocator;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
@@ -8103,6 +8276,86 @@ test "execution replay records session labels for tty-launched sessions" {
|
||||
);
|
||||
}
|
||||
|
||||
test "execution replay names the call whose saved output a later read opens" {
|
||||
const alloc = std.testing.allocator;
|
||||
var app = try TestApp.init(alloc, "/workspace");
|
||||
defer app.deinit();
|
||||
|
||||
const replay = "fx-command-replay-0123.bin";
|
||||
const run_output = "{\"exit_code\":0,\"full_output_handle\":\"" ++ replay ++ "\"}";
|
||||
const file_output = "<path>src/main.zig</path>";
|
||||
var source_calls = [_]types.ToolCall{
|
||||
.{ .id = "call_run", .name = "shell", .arguments_json = "{\"action\":\"run\",\"command\":\"zig build test\"}" },
|
||||
.{ .id = "call_file", .name = "read_file", .arguments_json = "{\"path\":\"src/main.zig\"}" },
|
||||
};
|
||||
var source_results = [_]types.PersistedToolResult{
|
||||
.{
|
||||
.tool_call_id = @constCast("call_run"),
|
||||
.tool_name = @constCast("shell"),
|
||||
.status = .success,
|
||||
.output = @constCast(run_output),
|
||||
.output_bytes = run_output.len,
|
||||
.stored_output_bytes = run_output.len,
|
||||
.command_output_replay = .{ .available = .{ .handle = replay, .framed_bytes = 64 } },
|
||||
},
|
||||
.{
|
||||
.tool_call_id = @constCast("call_file"),
|
||||
.tool_name = @constCast("read_file"),
|
||||
.status = .success,
|
||||
.output = @constCast(file_output),
|
||||
.output_handle = @constCast("result-read_file-1.txt"),
|
||||
.output_bytes = file_output.len,
|
||||
.stored_output_bytes = file_output.len,
|
||||
},
|
||||
};
|
||||
var source_steps = [_]types.ToolExecutionStep{.{
|
||||
.tool_calls = source_calls[0..],
|
||||
.tool_results = source_results[0..],
|
||||
}};
|
||||
var read_calls = [_]types.ToolCall{
|
||||
.{ .id = "page", .name = "read_tool_result", .arguments_json = "{\"request\":{\"handle\":\"" ++ replay ++ "\",\"start_byte\":9}}" },
|
||||
.{ .id = "query", .name = "read_tool_result", .arguments_json = "{\"request\":{\"handle\":\"" ++ replay ++ "\",\"query\":\"error:\"}}" },
|
||||
.{ .id = "file", .name = "read_tool_result", .arguments_json = "{\"request\":{\"handle\":\"result-read_file-1\"}}" },
|
||||
.{ .id = "unknown", .name = "read_tool_result", .arguments_json = "{\"request\":{\"handle\":\"result-gone.txt\"}}" },
|
||||
};
|
||||
var read_results: [read_calls.len]types.PersistedToolResult = undefined;
|
||||
for (&read_results, read_calls) |*result, call| {
|
||||
result.* = .{
|
||||
.tool_call_id = @constCast(call.id),
|
||||
.tool_name = @constCast(call.name),
|
||||
.status = .success,
|
||||
.output = @constCast("page"),
|
||||
.output_bytes = 4,
|
||||
.stored_output_bytes = 4,
|
||||
};
|
||||
}
|
||||
var read_steps = [_]types.ToolExecutionStep{.{
|
||||
.tool_calls = read_calls[0..],
|
||||
.tool_results = read_results[0..],
|
||||
}};
|
||||
const history = [_]types.HistoryTurn{
|
||||
.{ .assistant = .{
|
||||
.user = .{ .text = @constCast("run the tests") },
|
||||
.assistant = @constCast(""),
|
||||
.execution = .{ .tool_steps = source_steps[0..] },
|
||||
} },
|
||||
.{ .assistant = .{
|
||||
.user = .{ .text = @constCast("find the failure") },
|
||||
.assistant = @constCast(""),
|
||||
.execution = .{ .tool_steps = read_steps[0..] },
|
||||
} },
|
||||
};
|
||||
|
||||
try Runtime(TestApp).replayHistory(&app, &history);
|
||||
|
||||
const statuses = app.completed_tool_statuses.items;
|
||||
try std.testing.expectEqual(@as(usize, 6), statuses.len);
|
||||
try std.testing.expectEqualStrings("● Completed read_tool_result more output from zig build test\n", statuses[2]);
|
||||
try std.testing.expectEqualStrings("● Completed read_tool_result zig build test output for \"error:\"\n", statuses[3]);
|
||||
try std.testing.expectEqualStrings("● Completed read_tool_result more output from src/main.zig\n", statuses[4]);
|
||||
try std.testing.expectEqualStrings("● Completed read_tool_result\n", statuses[5]);
|
||||
}
|
||||
|
||||
test "resume projection stores reflow metadata for session action rows" {
|
||||
const alloc = std.testing.allocator;
|
||||
var app = try TestApp.init(alloc, "/workspace");
|
||||
|
||||
@@ -30,6 +30,7 @@ const render_input = @import("../../ui/footer/render_input.zig");
|
||||
const skills_menu_presentation = @import("../../ui/footer/skills_menu_presentation.zig");
|
||||
const model_menu_presentation = @import("../../ui/footer/model_menu_presentation.zig");
|
||||
const resume_menu_presentation = @import("../../ui/footer/resume_menu_presentation.zig");
|
||||
const fork_menu_presentation = @import("../../ui/footer/fork_menu_presentation.zig");
|
||||
const help_menu_presentation = @import("../../ui/footer/help_menu_presentation.zig");
|
||||
const settings_menu_presentation = @import("../../ui/footer/settings_menu_presentation.zig");
|
||||
const surface_frame = @import("../../ui/footer/surface_frame.zig");
|
||||
@@ -399,6 +400,7 @@ pub fn CompletionRuntime(comptime App: type) type {
|
||||
if (try routeSkillsMenuMove(app, delta)) return true;
|
||||
if (comptime runtime_profile.allows(App, .durable_sessions)) {
|
||||
if (try routeSessionPickerMove(app, delta)) return true;
|
||||
if (try routeForkMenuMove(app, delta)) return true;
|
||||
}
|
||||
if (hasFileQuery(app)) {
|
||||
navigateFilePicker(app, delta);
|
||||
@@ -499,6 +501,19 @@ pub fn CompletionRuntime(comptime App: type) type {
|
||||
return true;
|
||||
}
|
||||
|
||||
fn routeForkMenuMove(app: *App, delta: i32) !bool {
|
||||
if (comptime !@hasField(App, "session_persistence")) return false;
|
||||
const menu = &app.session_persistence.fork_menu;
|
||||
if (!menu.active()) return false;
|
||||
menu.move(delta);
|
||||
menu.window_start = fork_menu_presentation.windowStart(
|
||||
menu.projection(),
|
||||
app.shell.layout.cols,
|
||||
try inlineMenuRowBudget(app, fork_menu_presentation.max_inline_rows),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
fn routeAuthPickerMove(app: *App, delta: i32) bool {
|
||||
if (comptime !@hasField(App, "auth")) return false;
|
||||
if (app.stream.active) return false;
|
||||
|
||||
@@ -14,6 +14,11 @@ const pathing = @import("../workspace/pathing.zig");
|
||||
const text_utils = @import("../shared/text_utils.zig");
|
||||
|
||||
pub const max_image_bytes: usize = 20 * 1024 * 1024;
|
||||
/// The most images, and image bytes together, one libfx prompt holds: the
|
||||
/// limits `sdk/fx-sdk.js` takes a prompt within (`maxPromptImages` and
|
||||
/// `maxPromptImagesBytes`), which the core holds a prompt to as well.
|
||||
pub const max_libfx_prompt_images: usize = 8;
|
||||
pub const max_libfx_prompt_images_bytes: usize = 6 * 1024 * 1024;
|
||||
const max_encoded_image_bytes: usize = 5 * 1024 * 1024;
|
||||
pub const image_too_large_notice = "image exceeds the 20 MiB limit";
|
||||
pub const image_preparation_failed_notice = "Unable to prepare this image for upload. Use a smaller image.";
|
||||
@@ -848,6 +853,62 @@ pub fn attachmentEncodedBytes(attachment: types.ImageAttachment) ?usize {
|
||||
return image_data.encodedImageBytes(size);
|
||||
}
|
||||
|
||||
/// Leaves the images the session dropped out of a request, each with a note
|
||||
/// in its place, so no later projection or provider sees an image without
|
||||
/// bytes. Returns `messages` itself when none were dropped, otherwise a copy
|
||||
/// allocated in `arena`. History is not modified.
|
||||
pub fn withholdDroppedImages(arena: std.mem.Allocator, messages: []const types.ChatMessage) ![]const types.ChatMessage {
|
||||
var result: ?[]types.ChatMessage = null;
|
||||
for (messages, 0..) |message, index| {
|
||||
if (message.tool_result_memory) |memory| {
|
||||
var dropped_tools: usize = 0;
|
||||
for (memory.tool_images) |image| {
|
||||
if (image.droppedFromSession()) dropped_tools += 1;
|
||||
}
|
||||
if (dropped_tools > 0) {
|
||||
const projected = result orelse try arena.dupe(types.ChatMessage, messages);
|
||||
result = projected;
|
||||
const kept = try arena.alloc(types.ToolImage, memory.tool_images.len - dropped_tools);
|
||||
var next: usize = 0;
|
||||
for (memory.tool_images) |image| {
|
||||
if (image.droppedFromSession()) continue;
|
||||
kept[next] = image;
|
||||
next += 1;
|
||||
}
|
||||
var withheld = memory;
|
||||
withheld.tool_images = kept;
|
||||
projected[index].tool_result_memory = withheld;
|
||||
const notice = if (dropped_tools == 1)
|
||||
"[1 image from this result is no longer in this session, which keeps only its newest images.]\n"
|
||||
else
|
||||
try arena.print("[{d} images from this result are no longer in this session, which keeps only its newest images.]\n", .{dropped_tools});
|
||||
projected[index].content = try std.mem.concat(arena, u8, &.{ notice, message.content orelse "" });
|
||||
}
|
||||
}
|
||||
var dropped: usize = 0;
|
||||
for (message.images) |image| {
|
||||
if (image.droppedFromSession()) dropped += 1;
|
||||
}
|
||||
if (dropped == 0) continue;
|
||||
const projected = result orelse try arena.dupe(types.ChatMessage, messages);
|
||||
result = projected;
|
||||
const kept = try arena.alloc(types.ImageAttachment, message.images.len - dropped);
|
||||
var notice: std.Io.Writer.Allocating = .init(arena);
|
||||
var next: usize = 0;
|
||||
for (message.images) |image| {
|
||||
if (image.droppedFromSession()) {
|
||||
notice.writer.print("[Image #{d} is no longer in this session, which keeps only its newest images.]\n", .{image.id}) catch return error.OutOfMemory;
|
||||
} else {
|
||||
kept[next] = image;
|
||||
next += 1;
|
||||
}
|
||||
}
|
||||
projected[index].images = kept;
|
||||
projected[index].content = try std.mem.concat(arena, u8, &.{ notice.written(), projected[index].content orelse "" });
|
||||
}
|
||||
return result orelse messages;
|
||||
}
|
||||
|
||||
fn writeWithheldAttachmentNotice(
|
||||
writer: *std.Io.Writer,
|
||||
attachment: types.ImageAttachment,
|
||||
@@ -3058,6 +3119,50 @@ test "capture keeps a byte-oversized original for request recovery" {
|
||||
try std.testing.expectEqual(@as(usize, 1), try countSnapshotFiles(snapshot_dir));
|
||||
}
|
||||
|
||||
test "requests leave out images the session dropped, each with a note" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
const digest = text_utils.repeat("a", 64);
|
||||
const images = [_]types.ImageAttachment{
|
||||
.{ .id = 1, .path = @constCast("inline://image-1"), .media_type = @constCast("image/png"), .snapshot_sha256 = @constCast(digest) },
|
||||
.{ .id = 2, .path = @constCast("inline://image-2"), .media_type = @constCast("image/png"), .snapshot_sha256 = @constCast(digest), .inline_data = @constCast("png") },
|
||||
};
|
||||
const messages = [_]types.ChatMessage{
|
||||
.{ .role = .user, .content = "[Image #1] [Image #2]", .images = &images },
|
||||
.{ .role = .assistant, .content = "ok" },
|
||||
};
|
||||
const projected = try withholdDroppedImages(arena, &messages);
|
||||
try std.testing.expectEqual(@as(usize, 1), projected[0].images.len);
|
||||
try std.testing.expectEqual(@as(usize, 2), projected[0].images[0].id);
|
||||
try std.testing.expectEqualStrings("[Image #1 is no longer in this session, which keeps only its newest images.]\n[Image #1] [Image #2]", projected[0].content.?);
|
||||
// A request without dropped images passes through untouched.
|
||||
const kept = [_]types.ChatMessage{.{ .role = .user, .content = "[Image #2]", .images = images[1..] }};
|
||||
try std.testing.expect((try withholdDroppedImages(arena, &kept)).ptr == (&kept).ptr);
|
||||
}
|
||||
|
||||
test "requests leave out tool images the session dropped, with one note per result" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
const name = text_utils.repeat("b", 64)[0..64].*;
|
||||
const tool_images = [_]types.ToolImage{
|
||||
.{ .data = @constCast(&[_]u8{}), .mime_type = @constCast("image/png"), .name = name },
|
||||
.{ .data = @constCast("aGVsbG8="), .mime_type = @constCast("image/png"), .name = name },
|
||||
.{ .data = @constCast(&[_]u8{}), .mime_type = @constCast("image/png"), .name = name },
|
||||
};
|
||||
const messages = [_]types.ChatMessage{
|
||||
.{ .role = .tool, .content = "took it", .tool_call_id = "call-1", .tool_name = "screenshot", .tool_result_memory = .{ .tool_images = &tool_images } },
|
||||
};
|
||||
const projected = try withholdDroppedImages(arena, &messages);
|
||||
const memory = projected[0].tool_result_memory.?;
|
||||
try std.testing.expectEqual(@as(usize, 1), memory.tool_images.len);
|
||||
try std.testing.expectEqualStrings("aGVsbG8=", memory.tool_images[0].data);
|
||||
try std.testing.expectEqualStrings("[2 images from this result are no longer in this session, which keeps only its newest images.]\ntook it", projected[0].content.?);
|
||||
// The session's own messages keep their images.
|
||||
try std.testing.expectEqual(@as(usize, 3), messages[0].tool_result_memory.?.tool_images.len);
|
||||
}
|
||||
|
||||
test "requests leave out attachments over the model pixel limit and name their saved file" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
|
||||
@@ -57,12 +57,6 @@ pub const Server = struct {
|
||||
}
|
||||
};
|
||||
|
||||
/// Looks up the credential saved for a server; owned by `alloc`.
|
||||
pub const Saved = struct {
|
||||
context: *anyopaque,
|
||||
get: *const fn (context: *anyopaque, alloc: Allocator, key: credentials.Key) Allocator.Error!?[]u8,
|
||||
};
|
||||
|
||||
pub const Resolved = struct {
|
||||
servers: []Server,
|
||||
/// What the engine runs, in order: `engine[i]` is `servers[engine_server[i]]`.
|
||||
@@ -70,10 +64,6 @@ pub const Resolved = struct {
|
||||
engine_server: []usize,
|
||||
};
|
||||
|
||||
/// Allocates everything with `arena`, which must outlive the engine: the
|
||||
/// engine copies each config but keeps pointing at a stdio server's
|
||||
/// environment. A saved credential is copied into `arena`; zero it once the
|
||||
/// engine has its own copy.
|
||||
/// The command and its arguments as one line, quoting what a shell would split.
|
||||
fn commandLine(arena: Allocator, command: []const u8, args: []const []const u8) Allocator.Error![]const u8 {
|
||||
var out: std.ArrayList(u8) = .empty;
|
||||
@@ -92,11 +82,14 @@ fn commandLine(arena: Allocator, command: []const u8, args: []const []const u8)
|
||||
return out.items;
|
||||
}
|
||||
|
||||
/// Allocates everything with `arena`, which must outlive the engine: the
|
||||
/// engine copies each config but keeps pointing at a stdio server's
|
||||
/// environment. Saved sign-in credentials are not read here: the runtime
|
||||
/// hands them to the engine from its own thread.
|
||||
pub fn resolve(
|
||||
arena: Allocator,
|
||||
configs: []const McpServerConfig,
|
||||
inherited: *const std.process.Environ.Map,
|
||||
saved: ?Saved,
|
||||
/// The workspace whose `.mcp.json` servers these are.
|
||||
workspace_root: []const u8,
|
||||
) Allocator.Error!Resolved {
|
||||
@@ -150,11 +143,8 @@ pub fn resolve(
|
||||
auth.resource = try arena.print("{s}/", .{origin(url)});
|
||||
};
|
||||
}
|
||||
if (saved) |lookup| if (s.url) |url| {
|
||||
auth.saved = try lookup.get(lookup.context, arena, .{ .name = s.name, .url = url, .workspace = s.workspace });
|
||||
// Where fx keeps logins, it decides each refresh.
|
||||
auth.refresh_via_host = true;
|
||||
};
|
||||
// Where fx keeps logins, it decides each refresh.
|
||||
if (s.url != null) auth.refresh_via_host = true;
|
||||
try engine.append(arena, .{ .name = s.name, .transport = transport, .auth = auth });
|
||||
try engine_server.append(arena, i);
|
||||
}
|
||||
@@ -237,7 +227,7 @@ fn envVar(comptime key: []const u8, comptime value: []const u8) mcp_contract.Mcp
|
||||
return .{ .key = @constCast(key), .value = @constCast(value) };
|
||||
}
|
||||
|
||||
test "an approved .mcp.json server looks up its own workspace's login, never the profile's" {
|
||||
test "an approved .mcp.json server keeps its login under its own workspace, never the profile's" {
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
@@ -247,17 +237,9 @@ test "an approved .mcp.json server looks up its own workspace's login, never the
|
||||
.{ .name = "linear", .transport = .http, .url = "https://mcp.linear.app/mcp" },
|
||||
.{ .name = "team", .transport = .http, .url = "https://mcp.linear.app/mcp", .source = .workspace, .scope = .workspace, .workspace_admission = .approved },
|
||||
};
|
||||
const Lookup = struct {
|
||||
fn get(_: *anyopaque, alloc: Allocator, key: credentials.Key) Allocator.Error!?[]u8 {
|
||||
const workspace = key.workspace orelse return try alloc.dupe(u8, "profile");
|
||||
return try alloc.print("{s} {s}", .{ key.name, workspace });
|
||||
}
|
||||
};
|
||||
var context: u8 = 0;
|
||||
const r = try resolve(arena, &configs, &inherited, .{ .context = &context, .get = Lookup.get }, "/Users/example/repo");
|
||||
try testing.expectEqualStrings("profile", r.engine[0].auth.saved.?);
|
||||
try testing.expectEqualStrings("team /Users/example/repo", r.engine[1].auth.saved.?);
|
||||
const r = try resolve(arena, &configs, &inherited, "/Users/example/repo");
|
||||
try testing.expectEqual(@as(?[]const u8, null), r.servers[0].workspace);
|
||||
try testing.expectEqual(@as(?[]const u8, null), r.servers[0].credentialKey().workspace);
|
||||
try testing.expectEqualStrings("/Users/example/repo", r.servers[1].workspace.?);
|
||||
try testing.expectEqualStrings("/Users/example/repo", r.servers[1].credentialKey().workspace.?);
|
||||
}
|
||||
@@ -286,14 +268,7 @@ test "stdio and HTTP servers become engine configs; the rest are held back with
|
||||
.{ .name = "keyless", .transport = .http, .url = "https://k.example/mcp", .header_env = &missing_header },
|
||||
.{ .name = "carried", .transport = .http, .url = "http://acp.invalid/x", .source = .acp, .acp_server_id = @constCast("x") },
|
||||
};
|
||||
const Lookup = struct {
|
||||
fn get(_: *anyopaque, alloc: Allocator, key: credentials.Key) Allocator.Error!?[]u8 {
|
||||
if (std.mem.eql(u8, key.name, "linear") and std.mem.eql(u8, key.url, "https://mcp.linear.app/mcp")) return try alloc.dupe(u8, "{\"access\":\"a\"}");
|
||||
return null;
|
||||
}
|
||||
};
|
||||
var context: u8 = 0;
|
||||
const r = try resolve(arena, &configs, &inherited, .{ .context = &context, .get = Lookup.get }, "/Users/example/repo");
|
||||
const r = try resolve(arena, &configs, &inherited, "/Users/example/repo");
|
||||
|
||||
try testing.expectEqual(@as(usize, 3), r.engine.len);
|
||||
try testing.expectEqualSlices(usize, &.{ 0, 1, 2 }, r.engine_server);
|
||||
@@ -312,7 +287,10 @@ test "stdio and HTTP servers become engine configs; the rest are held back with
|
||||
try testing.expectEqualStrings("t1", linear.transport.http.headers[0].value);
|
||||
try testing.expectEqualStrings("c1", linear.auth.client_id.?);
|
||||
try testing.expectEqualStrings("s1", linear.auth.client_secret.?);
|
||||
try testing.expectEqualStrings("{\"access\":\"a\"}", linear.auth.saved.?);
|
||||
// The runtime's thread hands over saved credentials later.
|
||||
try testing.expect(linear.auth.saved == null);
|
||||
// fx keeps the logins, so it decides each refresh.
|
||||
try testing.expect(linear.auth.refresh_via_host);
|
||||
try testing.expectEqual(@as(?u16, 33418), r.servers[2].callback_port);
|
||||
|
||||
try testing.expectEqual(@as(?Held, .disabled), r.servers[3].held);
|
||||
@@ -336,7 +314,7 @@ test "Slack signs in as fx's Slack app on port 3118 with the app's scopes, unles
|
||||
.{ .name = "posing", .transport = .http, .url = "https://mcp.example.com/mcp", .auth = .{ .client_id = @constCast(slack.client_id) } },
|
||||
.{ .name = "ported", .transport = .http, .url = "https://mcp.slack.com/mcp", .auth = .{ .callback_port = 4242 } },
|
||||
};
|
||||
const r = try resolve(arena_state.allocator(), &configs, &inherited, null, "/Users/example/repo");
|
||||
const r = try resolve(arena_state.allocator(), &configs, &inherited, "/Users/example/repo");
|
||||
try testing.expectEqualStrings(slack.client_id, r.engine[0].auth.client_id.?);
|
||||
try testing.expect(r.servers[0].slack_app);
|
||||
try testing.expectEqual(@as(?u16, 3118), r.servers[0].callback_port);
|
||||
@@ -372,7 +350,7 @@ test "a stdio server gets only the MCP SDKs' few variables of fx's, then its own
|
||||
}) |pair| try inherited.put(pair[0], pair[1]);
|
||||
var env = [_]mcp_contract.McpEnvVar{ envVar("GITHUB_TOKEN", "g"), envVar("TERM", "dumb") };
|
||||
const configs = [_]McpServerConfig{.{ .name = "gh", .command = "gh-mcp", .env = &env }};
|
||||
const r = try resolve(arena_state.allocator(), &configs, &inherited, null, "/Users/example/repo");
|
||||
const r = try resolve(arena_state.allocator(), &configs, &inherited, "/Users/example/repo");
|
||||
const got = r.engine[0].transport.stdio.environ_map.?;
|
||||
// SHELL held a function, so it isn't passed; the config's TERM wins.
|
||||
try testing.expectEqual(@as(usize, 6), got.count());
|
||||
@@ -393,7 +371,7 @@ test "a stdio server starts in the directory its config names" {
|
||||
.{ .name = "proj", .command = "node", .args = &.{"s.js"}, .cwd = @constCast("/work/app") },
|
||||
.{ .name = "here", .command = "node", .args = &.{"s.js"} },
|
||||
};
|
||||
const r = try resolve(arena_state.allocator(), &configs, &inherited, null, "/Users/example/repo");
|
||||
const r = try resolve(arena_state.allocator(), &configs, &inherited, "/Users/example/repo");
|
||||
try testing.expectEqualStrings("/work/app", r.engine[0].transport.stdio.cwd.?);
|
||||
try testing.expect(r.engine[1].transport.stdio.cwd == null);
|
||||
}
|
||||
@@ -408,7 +386,7 @@ test "a token from the environment becomes the Authorization header" {
|
||||
.{ .name = "plain", .transport = .http, .url = "https://mcp.plain.com/mcp", .bearer_token_env = @constCast("PLAIN_KEY") },
|
||||
.{ .name = "unset", .transport = .http, .url = "https://u.example/mcp", .bearer_token_env = @constCast("UNSET_KEY") },
|
||||
};
|
||||
const r = try resolve(arena_state.allocator(), &configs, &inherited, null, "/Users/example/repo");
|
||||
const r = try resolve(arena_state.allocator(), &configs, &inherited, "/Users/example/repo");
|
||||
try testing.expectEqual(@as(usize, 1), r.engine.len);
|
||||
try testing.expectEqualStrings("Authorization", r.engine[0].transport.http.headers[0].name);
|
||||
try testing.expectEqualStrings("Bearer k1", r.engine[0].transport.http.headers[0].value);
|
||||
@@ -426,7 +404,7 @@ test "oauth.scopes reach the sign-in space-separated, and without them the serve
|
||||
.{ .name = "scoped", .transport = .http, .url = "https://s.example/mcp", .auth = .{ .scopes = &scopes } },
|
||||
.{ .name = "plain", .transport = .http, .url = "https://p.example/mcp", .auth = .{} },
|
||||
};
|
||||
const r = try resolve(arena_state.allocator(), &configs, &inherited, null, "/Users/example/repo");
|
||||
const r = try resolve(arena_state.allocator(), &configs, &inherited, "/Users/example/repo");
|
||||
try testing.expectEqualStrings("custom.scope other.scope", r.engine[0].auth.scopes.?);
|
||||
try testing.expectEqual(@as(?[]const u8, null), r.engine[1].auth.scopes);
|
||||
}
|
||||
@@ -446,7 +424,7 @@ test "a variable a header can't carry holds its server back, named, and never re
|
||||
.{ .name = "header", .transport = .http, .url = "https://h.example/mcp", .header_env = &lf_team },
|
||||
.{ .name = "good", .transport = .http, .url = "https://g.example/mcp", .header_env = &good_team },
|
||||
};
|
||||
const r = try resolve(arena_state.allocator(), &configs, &inherited, null, "/Users/example/repo");
|
||||
const r = try resolve(arena_state.allocator(), &configs, &inherited, "/Users/example/repo");
|
||||
try testing.expectEqual(@as(usize, 1), r.engine.len);
|
||||
try testing.expectEqualStrings("a b\tc", r.engine[0].transport.http.headers[0].value);
|
||||
try testing.expectEqual(@as(?Held, .invalid_env), r.servers[0].held);
|
||||
|
||||
@@ -78,6 +78,15 @@ pub const Store = struct {
|
||||
return try alloc.dupe(u8, doc.entries.items[i].credential);
|
||||
}
|
||||
|
||||
/// Every saved credential from one read of the store, so a host with
|
||||
/// many servers reads the Keychain once, not once per server. The
|
||||
/// caller frees it with `Snapshot.deinit`.
|
||||
pub fn snapshot(s: Store, alloc: Allocator) !Snapshot {
|
||||
var locked = try s.lock();
|
||||
defer locked.release();
|
||||
return .{ .doc = try s.read(alloc, &locked.dir) };
|
||||
}
|
||||
|
||||
/// Fails with `error.CredentialStoreUnreadable` when the store can't be
|
||||
/// read, so a sign-in can stop before it starts.
|
||||
pub fn check(s: Store, alloc: Allocator) !void {
|
||||
@@ -236,6 +245,24 @@ fn refreshLockName(key: Key) [29]u8 {
|
||||
return name;
|
||||
}
|
||||
|
||||
/// The saved credentials as one read of the store found them.
|
||||
pub const Snapshot = struct {
|
||||
doc: Doc,
|
||||
|
||||
/// The credential saved under `key`, or null. Borrowed from the
|
||||
/// snapshot until `deinit`.
|
||||
pub fn get(s: *const Snapshot, key: Key) ?[]const u8 {
|
||||
const i = s.doc.find(key) orelse return null;
|
||||
return s.doc.entries.items[i].credential;
|
||||
}
|
||||
|
||||
/// Zeroes the credentials and frees them.
|
||||
pub fn deinit(s: *Snapshot, alloc: Allocator) void {
|
||||
s.doc.deinit(alloc);
|
||||
s.* = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
fn readFile(alloc: Allocator, dir: *io_mod.VerifiedDir) !?[]u8 {
|
||||
var file = dir.dir.openFile(io_mod.getIo(), file_name, .{
|
||||
.mode = .read_only,
|
||||
@@ -316,6 +343,28 @@ fn testHome(tmp: *testing.TmpDir) ![]u8 {
|
||||
return io_mod.dirRealpathAlloc(testing.allocator, tmp.dir, ".");
|
||||
}
|
||||
|
||||
test "one snapshot holds every server's credential" {
|
||||
var tmp = testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
const home = try testHome(&tmp);
|
||||
defer testing.allocator.free(home);
|
||||
const store: Store = .{ .home = home, .backend = .file };
|
||||
const repo: Key = .{ .name = "linear", .url = "https://mcp.linear.app/mcp", .workspace = "/Users/example/repo" };
|
||||
try store.put(testing.allocator, .{ .name = "linear", .url = "https://mcp.linear.app/mcp" }, "{\"access\":\"l1\"}");
|
||||
try store.put(testing.allocator, .{ .name = "plain", .url = "https://mcp.plain.com/mcp" }, "{\"access\":\"p1\"}");
|
||||
try store.put(testing.allocator, repo, "{\"access\":\"r1\"}");
|
||||
|
||||
var saved = try store.snapshot(testing.allocator);
|
||||
defer saved.deinit(testing.allocator);
|
||||
try testing.expectEqualStrings("{\"access\":\"l1\"}", saved.get(.{ .name = "linear", .url = "https://mcp.linear.app/mcp" }).?);
|
||||
try testing.expectEqualStrings("{\"access\":\"p1\"}", saved.get(.{ .name = "plain", .url = "https://mcp.plain.com/mcp" }).?);
|
||||
// A workspace's server has its own, apart from the profile's.
|
||||
try testing.expectEqualStrings("{\"access\":\"r1\"}", saved.get(repo).?);
|
||||
// A credential is kept per name and URL.
|
||||
try testing.expectEqual(@as(?[]const u8, null), saved.get(.{ .name = "plain", .url = "https://mcp.linear.app/mcp" }));
|
||||
try testing.expectEqual(@as(?[]const u8, null), saved.get(.{ .name = "slack", .url = "https://mcp.slack.com/mcp" }));
|
||||
}
|
||||
|
||||
test "a credential is kept per server name and URL, replaced, and removed" {
|
||||
var tmp = testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
|
||||
@@ -60,6 +60,6 @@ pub const Fixture = struct {
|
||||
}
|
||||
|
||||
pub fn options(f: *const Fixture) Options {
|
||||
return .{ .client_version = "test", .credentials = .{ .home = f.home, .backend = .file } };
|
||||
return .{ .client_version = "test", .credentials = .{ .store = .{ .home = f.home, .backend = .file } } };
|
||||
}
|
||||
};
|
||||
+100
-29
@@ -161,7 +161,11 @@ pub const Notify = struct {
|
||||
|
||||
pub const Options = struct {
|
||||
client_version: []const u8,
|
||||
credentials: credentials.Store,
|
||||
/// Where sign-in credentials are kept. `.detect` names the home whose
|
||||
/// store the owner thread picks with `credentials.Store.detect`, so
|
||||
/// `start` never waits for the Keychain; the owner replaces it with
|
||||
/// that store before it handles any request.
|
||||
credentials: union(enum) { detect: []const u8, store: credentials.Store },
|
||||
/// The workspace whose `.mcp.json` servers keep their own logins.
|
||||
workspace_root: []const u8 = "",
|
||||
notify: ?Notify = null,
|
||||
@@ -312,6 +316,9 @@ pub const Runtime = struct {
|
||||
/// Guarded by `mutex`: the latest statuses, in `snapshot_arena`.
|
||||
snapshot: []Status = &.{},
|
||||
snapshot_arena: std.heap.ArenaAllocator,
|
||||
/// Set once the owner gave the servers their saved credentials and
|
||||
/// published that, so `statuses` never reports a premature signed_in.
|
||||
credentials_restored: std.Io.Event = .unset,
|
||||
|
||||
/// Owner thread only.
|
||||
calls: std.AutoHashMapUnmanaged(engine.Handle, *Request) = .empty,
|
||||
@@ -350,7 +357,8 @@ pub const Runtime = struct {
|
||||
/// owner thread. `configs` and `inherited` are read only during the call.
|
||||
/// `gpa` must be thread-safe, and `r` must not move until `deinit`: the
|
||||
/// engine and login tasks point at it. Nothing connects until a server
|
||||
/// is needed.
|
||||
/// is needed, and saved credentials are read on the owner thread, so
|
||||
/// this never waits for the Keychain.
|
||||
pub fn start(
|
||||
r: *Runtime,
|
||||
gpa: Allocator,
|
||||
@@ -372,20 +380,15 @@ pub const Runtime = struct {
|
||||
errdefer r.arena.deinit();
|
||||
errdefer r.snapshot_arena.deinit();
|
||||
const arena = r.arena.allocator();
|
||||
r.resolved = try config.resolve(arena, configs, inherited, .{ .context = r, .get = savedCredential }, r.options.workspace_root);
|
||||
r.resolved = try config.resolve(arena, configs, inherited, r.options.workspace_root);
|
||||
r.flags = try arena.alloc(Flags, r.resolved.servers.len);
|
||||
for (r.flags) |*f| f.* = .{};
|
||||
for (r.resolved.engine, r.resolved.engine_server) |c, i| {
|
||||
r.flags[i].signed_in = c.auth.saved != null;
|
||||
if (c.auth.saved) |saved| r.flags[i].tried = credentials.digest(saved);
|
||||
}
|
||||
try r.engine.init(io, gpa, .{ .context = r, .allow = allow, .log = log }, .{
|
||||
.client = .{ .info = .{ .name = "fx", .version = options.client_version }, .capabilities = declaredCapabilities(options.questions) },
|
||||
}, r.resolved.engine);
|
||||
errdefer r.engine.deinit();
|
||||
// The engine has its own copies now.
|
||||
for (r.resolved.engine) |c| if (c.auth.saved) |s| std.crypto.secureZero(u8, @constCast(s));
|
||||
// The rest may point into `configs`, which the caller may free now.
|
||||
// The engine has its own copies now, and these may point into
|
||||
// `configs`, which the caller may free now.
|
||||
r.resolved.engine = &.{};
|
||||
try r.publish();
|
||||
r.thread = try std.Thread.spawn(.{}, run, .{r});
|
||||
@@ -429,8 +432,19 @@ pub const Runtime = struct {
|
||||
if (was_retired) retireEnded();
|
||||
}
|
||||
|
||||
/// Every configured server's status, copied into `alloc`.
|
||||
/// Every configured server's status, copied into `alloc`. Waits for the
|
||||
/// owner to restore saved credentials, so `signed_in` is settled.
|
||||
pub fn statuses(r: *Runtime, alloc: Allocator) Allocator.Error![]Status {
|
||||
_ = r.active.fetchAdd(1, .acq_rel);
|
||||
defer _ = r.active.fetchSub(1, .acq_rel);
|
||||
r.credentials_restored.waitUncancelable(r.io);
|
||||
return r.statusesNow(alloc);
|
||||
}
|
||||
|
||||
/// `statuses` without the wait, for the UI thread's periodic refreshes,
|
||||
/// which must not hold up a frame: until the owner restored saved
|
||||
/// credentials, every server reads as not signed in.
|
||||
pub fn statusesNow(r: *Runtime, alloc: Allocator) Allocator.Error![]Status {
|
||||
_ = r.active.fetchAdd(1, .acq_rel);
|
||||
defer _ = r.active.fetchSub(1, .acq_rel);
|
||||
r.mutex.lockUncancelable(r.io);
|
||||
@@ -610,6 +624,7 @@ pub const Runtime = struct {
|
||||
// ---- the owner thread ----
|
||||
|
||||
fn run(r: *Runtime) void {
|
||||
r.restoreCredentials();
|
||||
while (true) {
|
||||
const stopping = r.drain();
|
||||
r.flush();
|
||||
@@ -769,7 +784,7 @@ pub const Runtime = struct {
|
||||
if (s.transport != .http) return r.finish(req, .{ .failed = error.NotHttp });
|
||||
// The saved login goes first: a store that can't be read leaves
|
||||
// the session signed in rather than half signed out.
|
||||
_ = r.options.credentials.remove(r.gpa, s.credentialKey()) catch |err| switch (err) {
|
||||
_ = r.options.credentials.store.remove(r.gpa, s.credentialKey()) catch |err| switch (err) {
|
||||
error.CredentialStoreUnreadable => return r.finish(req, .{ .failed = error.CredentialStoreUnreadable }),
|
||||
else => debug_trace.logf("mcp", "MCP credential not removed server={s} err={s}", .{ s.name, @errorName(err) }),
|
||||
};
|
||||
@@ -788,7 +803,7 @@ pub const Runtime = struct {
|
||||
const s = r.resolved.servers[i];
|
||||
if (s.transport != .http) return r.finish(req, .{ .failed = error.NotHttp });
|
||||
// A sign-in that couldn't be saved would last only this session.
|
||||
r.options.credentials.check(r.gpa) catch |err| switch (err) {
|
||||
r.options.credentials.store.check(r.gpa) catch |err| switch (err) {
|
||||
error.CredentialStoreUnreadable => return r.finish(req, .{ .failed = error.CredentialStoreUnreadable }),
|
||||
else => debug_trace.logf("mcp", "MCP credential store not checked server={s} err={s}", .{ s.name, @errorName(err) }),
|
||||
};
|
||||
@@ -1068,7 +1083,7 @@ pub const Runtime = struct {
|
||||
out.deinit();
|
||||
}
|
||||
if (try r.engine.saveCredential(s.name, &out.writer)) {
|
||||
if (r.options.credentials.put(r.gpa, s.credentialKey(), out.written())) {
|
||||
if (r.options.credentials.store.put(r.gpa, s.credentialKey(), out.written())) {
|
||||
// Only a saved login is this process's own; one that wasn't
|
||||
// leaves the store's as it was, for a refusal to remove.
|
||||
r.flags[i].tried = credentials.digest(out.written());
|
||||
@@ -1078,12 +1093,12 @@ pub const Runtime = struct {
|
||||
if (r.flags[i].not_saved) return;
|
||||
r.flags[i].not_saved = true;
|
||||
var buf: [320]u8 = undefined;
|
||||
r.notify(.{ .not_saved = .{ .server = s.name, .reason = notSavedReason(&buf, r.options.credentials, err) } });
|
||||
r.notify(.{ .not_saved = .{ .server = s.name, .reason = notSavedReason(&buf, r.options.credentials.store, err) } });
|
||||
}
|
||||
} else if (r.flags[i].tried) |own| {
|
||||
// Only this process's own login goes: one another fx saved since
|
||||
// stays, for the next request to take.
|
||||
_ = r.options.credentials.removeIfSame(r.gpa, s.credentialKey(), own) catch |err| {
|
||||
_ = r.options.credentials.store.removeIfSame(r.gpa, s.credentialKey(), own) catch |err| {
|
||||
debug_trace.logf("mcp", "MCP credential not removed server={s} err={s}", .{ s.name, @errorName(err) });
|
||||
};
|
||||
}
|
||||
@@ -1350,7 +1365,7 @@ pub const Runtime = struct {
|
||||
/// Waiting too long, or without the lock at all, it refreshes anyway.
|
||||
fn tryRefresh(r: *Runtime, f: *Refresh) !void {
|
||||
const s = r.resolved.servers[f.server];
|
||||
if (r.options.credentials.tryRefreshLock(s.credentialKey())) |maybe| {
|
||||
if (r.options.credentials.store.tryRefreshLock(s.credentialKey())) |maybe| {
|
||||
if (maybe) |lock| {
|
||||
f.lock = lock;
|
||||
} else if (r.nowMs() < f.deadline_ms) {
|
||||
@@ -1382,7 +1397,7 @@ pub const Runtime = struct {
|
||||
/// when there is none, or it's one `i` had or tried already.
|
||||
fn takeSaved(r: *Runtime, i: usize) !bool {
|
||||
const s = r.resolved.servers[i];
|
||||
const saved = r.options.credentials.get(r.gpa, s.credentialKey()) catch |err| switch (err) {
|
||||
const saved = r.options.credentials.store.get(r.gpa, s.credentialKey()) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
else => {
|
||||
debug_trace.logf("mcp", "MCP saved login not read server={s} err={s}", .{ s.name, @errorName(err) });
|
||||
@@ -1397,17 +1412,36 @@ pub const Runtime = struct {
|
||||
return true;
|
||||
}
|
||||
|
||||
fn savedCredential(context: *anyopaque, alloc: Allocator, key: credentials.Key) Allocator.Error!?[]u8 {
|
||||
const r: *Runtime = @ptrCast(@alignCast(context));
|
||||
const found = r.options.credentials.get(r.gpa, key) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
else => {
|
||||
debug_trace.logf("mcp", "MCP credential not loaded server={s} err={s}", .{ key.name, @errorName(err) });
|
||||
return null;
|
||||
},
|
||||
} orelse return null;
|
||||
defer secret.zeroAndFree(r.gpa, found);
|
||||
return try alloc.dupe(u8, found);
|
||||
/// Before any request: picks the store when `start` left that to the
|
||||
/// owner, reads it once, and gives each HTTP server the login saved
|
||||
/// under its key, so no server starts without it. A store that can't be
|
||||
/// read leaves every server signed out, as one without credentials.
|
||||
fn restoreCredentials(r: *Runtime) void {
|
||||
defer r.credentials_restored.set(r.io);
|
||||
const store: credentials.Store = switch (r.options.credentials) {
|
||||
.store => |s| s,
|
||||
.detect => |home| .detect(r.gpa, home),
|
||||
};
|
||||
r.options.credentials = .{ .store = store };
|
||||
var saved = store.snapshot(r.gpa) catch |err| {
|
||||
debug_trace.logf("mcp", "MCP credentials not loaded err={s}", .{@errorName(err)});
|
||||
return;
|
||||
};
|
||||
defer saved.deinit(r.gpa);
|
||||
for (r.resolved.engine_server) |i| {
|
||||
const s = r.resolved.servers[i];
|
||||
if (s.url == null) continue;
|
||||
const credential = saved.get(s.credentialKey()) orelse continue;
|
||||
r.engine.restoreCredential(s.name, credential) catch |err| {
|
||||
debug_trace.logf("mcp", "MCP credential not loaded server={s} err={s}", .{ s.name, @errorName(err) });
|
||||
continue;
|
||||
};
|
||||
r.flags[i].signed_in = true;
|
||||
// The login this process starts with, so a refusal removes only
|
||||
// its own and a login another fx saves since is taken.
|
||||
r.flags[i].tried = credentials.digest(credential);
|
||||
}
|
||||
r.publish() catch |err| debug_trace.logf("mcp", "MCP status not published err={s}", .{@errorName(err)});
|
||||
}
|
||||
|
||||
/// Permission is fx's tool admission, before the call reaches the
|
||||
@@ -1758,6 +1792,43 @@ test "an answer to a withdrawn 2025 question reaches no one, and the next questi
|
||||
try testing.expect(std.mem.find(u8, result.result.content, "e1 ") == null);
|
||||
}
|
||||
|
||||
test "saved credentials load on the owner thread, and statuses wait for them" {
|
||||
var f: Fixture = undefined;
|
||||
try f.init();
|
||||
defer f.deinit();
|
||||
const store: credentials.Store = .{ .home = f.home, .backend = .file };
|
||||
try store.put(testing.allocator, .{ .name = "web", .url = "https://web.example.invalid/mcp" }, "{\"access\":\"w1\"}");
|
||||
try store.put(testing.allocator, .{ .name = "moved", .url = "https://old.example.invalid/mcp" }, "{\"access\":\"m1\"}");
|
||||
// A project server takes only the login saved under its workspace.
|
||||
try store.put(testing.allocator, .{ .name = "team", .url = "https://team.example.invalid/mcp" }, "{\"access\":\"profile\"}");
|
||||
try store.put(testing.allocator, .{ .name = "repo", .url = "https://repo.example.invalid/mcp", .workspace = "/Users/example/repo" }, "{\"access\":\"r1\"}");
|
||||
const configs = [_]mcp_contract.McpServerConfig{
|
||||
.{ .name = "web", .transport = .http, .url = "https://web.example.invalid/mcp" },
|
||||
// A credential saved for another URL is not this server's.
|
||||
.{ .name = "moved", .transport = .http, .url = "https://new.example.invalid/mcp" },
|
||||
.{ .name = "none", .transport = .http, .url = "https://none.example.invalid/mcp" },
|
||||
.{ .name = "fake", .command = f.server },
|
||||
.{ .name = "team", .transport = .http, .url = "https://team.example.invalid/mcp", .source = .workspace, .scope = .workspace, .workspace_admission = .approved },
|
||||
.{ .name = "repo", .transport = .http, .url = "https://repo.example.invalid/mcp", .source = .workspace, .scope = .workspace, .workspace_admission = .approved },
|
||||
};
|
||||
var options = f.options();
|
||||
options.workspace_root = "/Users/example/repo";
|
||||
var r: Runtime = undefined;
|
||||
try r.start(testing.allocator, testing.io, &configs, &f.inherited, options);
|
||||
defer r.deinit();
|
||||
|
||||
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
const got = try r.statuses(arena.allocator());
|
||||
try testing.expect(got[0].signed_in);
|
||||
try testing.expect(!got[1].signed_in);
|
||||
try testing.expect(!got[2].signed_in);
|
||||
try testing.expect(!got[3].signed_in);
|
||||
try testing.expect(!got[4].signed_in);
|
||||
try testing.expect(got[5].signed_in);
|
||||
try testing.expectEqual(State.idle, got[0].state);
|
||||
}
|
||||
|
||||
test "held servers say why, and unknown names and stdio logins are refused" {
|
||||
var f: Fixture = undefined;
|
||||
try f.init();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const std = @import("std");
|
||||
const local_time = @import("../shared/local_time.zig");
|
||||
|
||||
pub const Kind = enum {
|
||||
prompt,
|
||||
@@ -24,49 +25,35 @@ pub const Kind = enum {
|
||||
}
|
||||
};
|
||||
|
||||
/// Formats `<local date and time> <zone> · <kind>` into `buf`. Returns null
|
||||
/// for timestamps outside the supported range or when `buf` is too small.
|
||||
pub fn formatHeader(
|
||||
buf: []u8,
|
||||
timestamp_ms: i64,
|
||||
kind: Kind,
|
||||
) ?[]const u8 {
|
||||
const max_supported_timestamp_ms: i64 = 253_402_300_799_999;
|
||||
if (timestamp_ms <= 0 or timestamp_ms > max_supported_timestamp_ms) {
|
||||
return null;
|
||||
}
|
||||
const epoch_seconds: std.time.epoch.EpochSeconds = .{
|
||||
.secs = @intCast(@divTrunc(timestamp_ms, std.time.ms_per_s)),
|
||||
};
|
||||
const milliseconds: u16 = @intCast(@mod(
|
||||
timestamp_ms,
|
||||
std.time.ms_per_s,
|
||||
));
|
||||
const day = epoch_seconds.getDaySeconds();
|
||||
const year_day = epoch_seconds.getEpochDay().calculateYearDay();
|
||||
const month_day = year_day.calculateMonthDay();
|
||||
return std.mem.print(
|
||||
buf,
|
||||
"{d:0>4}-{d:0>2}-{d:0>2} {d:0>2}:{d:0>2}:{d:0>2}.{d:0>3} UTC · {s}",
|
||||
.{
|
||||
year_day.year,
|
||||
@backingInt(month_day.month),
|
||||
month_day.day_index + 1,
|
||||
day.getHoursIntoDay(),
|
||||
day.getMinutesIntoHour(),
|
||||
day.getSecondsIntoMinute(),
|
||||
milliseconds,
|
||||
kind.label(),
|
||||
},
|
||||
) catch null;
|
||||
if (timestamp_ms <= 0) return null;
|
||||
const reading = local_time.local(timestamp_ms) orelse return null;
|
||||
return std.mem.print(buf, "{f} · {s}", .{ reading, kind.label() }) catch null;
|
||||
}
|
||||
|
||||
test "full transcript metadata formats a stable UTC entry header" {
|
||||
test "full transcript metadata formats an entry header in local time" {
|
||||
var buf: [64]u8 = undefined;
|
||||
const header = formatHeader(&buf, 1_704_164_645_006, .prompt);
|
||||
try std.testing.expect(header != null);
|
||||
try std.testing.expectEqualStrings(
|
||||
"2024-01-02 03:04:05.006 UTC · Prompt",
|
||||
header.?,
|
||||
formatHeader(&buf, 1_704_164_645_006, .prompt).?,
|
||||
);
|
||||
local_time.test_zone = .{ .offset_s = -5 * 3600, .name = "EST" };
|
||||
defer local_time.test_zone = .utc;
|
||||
try std.testing.expectEqualStrings(
|
||||
"2024-01-01 22:04:05.006 EST · Tool",
|
||||
formatHeader(&buf, 1_704_164_645_006, .tool).?,
|
||||
);
|
||||
}
|
||||
|
||||
test "full transcript metadata rejects unsupported timestamps" {
|
||||
var buf: [64]u8 = undefined;
|
||||
try std.testing.expect(formatHeader(&buf, 0, .response) == null);
|
||||
try std.testing.expect(formatHeader(&buf, -1, .tool) == null);
|
||||
try std.testing.expect(formatHeader(&buf, local_time.max_timestamp_ms + 1, .tool) == null);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ const session_json = @import("../session/session_json.zig");
|
||||
const compactor = @import("../compactor/compactor.zig");
|
||||
const session_store = @import("../session/session_store.zig");
|
||||
const usage = @import("../../usage/usage.zig");
|
||||
const local_time = @import("../shared/local_time.zig");
|
||||
const text_utils = @import("../shared/text_utils.zig");
|
||||
const types = @import("../shared/types.zig");
|
||||
const update_notes = @import("../upgrade/update_notes.zig");
|
||||
@@ -81,11 +82,9 @@ pub const UsageSnapshot = struct {
|
||||
var out: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer out.deinit();
|
||||
|
||||
// The period stays in UTC days, the days AI Gateway reports by.
|
||||
try out.writer.print("Usage ({s}, UTC) from AI Gateway", .{v.period.label()});
|
||||
if (v.fetched_at_ms) |ms| {
|
||||
const minutes: u64 = @intCast(@mod(@divFloor(ms, std.time.ms_per_min), 24 * 60));
|
||||
try out.writer.print(", updated {d:0>2}:{d:0>2} UTC", .{ minutes / 60, minutes % 60 });
|
||||
}
|
||||
if (v.fetched_at_ms) |ms| try writeLocalClock(&out.writer, ", updated ", ms);
|
||||
try out.writer.writeByte('\n');
|
||||
switch (s.status) {
|
||||
.ready, .fetching => {},
|
||||
@@ -138,7 +137,7 @@ pub const UsageSnapshot = struct {
|
||||
try out.writer.writeAll(" for ");
|
||||
try writeTerminalSafe(&out.writer, alloc, name);
|
||||
};
|
||||
if (s.fetchedAtMs()) |ms| try writeUtcClock(&out.writer, ", updated ", ms);
|
||||
if (s.fetchedAtMs()) |ms| try writeLocalClock(&out.writer, ", updated ", ms);
|
||||
try out.writer.writeByte('\n');
|
||||
switch (s.status) {
|
||||
.ready => {},
|
||||
@@ -173,7 +172,7 @@ pub const UsageSnapshot = struct {
|
||||
try out.writer.writeAll("- ");
|
||||
try writeTerminalSafe(&out.writer, alloc, limit.name);
|
||||
try out.writer.print(" ({d}h window): {d:.0}% used", .{ @divFloor(w.window_seconds, 3600), w.used_percent });
|
||||
if (w.resets_at_ms) |at| try writeUtcClock(&out.writer, ", resets ", at);
|
||||
if (w.resets_at_ms) |at| try writeLocalClock(&out.writer, ", resets ", at);
|
||||
try out.writer.writeByte('\n');
|
||||
}
|
||||
}
|
||||
@@ -187,7 +186,7 @@ pub const UsageSnapshot = struct {
|
||||
defer out.deinit();
|
||||
|
||||
try out.writer.writeAll("Grok usage from xAI");
|
||||
if (s.fetchedAtMs()) |ms| try writeUtcClock(&out.writer, ", updated ", ms);
|
||||
if (s.fetchedAtMs()) |ms| try writeLocalClock(&out.writer, ", updated ", ms);
|
||||
try out.writer.writeByte('\n');
|
||||
switch (s.status) {
|
||||
.ready => {},
|
||||
@@ -211,9 +210,12 @@ pub const UsageSnapshot = struct {
|
||||
if (u.used_percent) |used| {
|
||||
try out.writer.print("Used {d:.0}% of the {s} allowance", .{ @floor(used), if (u.period) |p| @tagName(p) else "plan's" });
|
||||
if (u.resets_at_ms) |at| {
|
||||
var date_buf: [24]u8 = undefined;
|
||||
try out.writer.print(", resets {s}", .{formatUtcDate(&date_buf, at)});
|
||||
try writeUtcClock(&out.writer, " ", at);
|
||||
try out.writer.writeAll(", resets ");
|
||||
if (local_time.local(at)) |reading| {
|
||||
var date_buf: [24]u8 = undefined;
|
||||
try out.writer.print("{s} ", .{formatDate(&date_buf, &reading)});
|
||||
try writeClock(&out.writer, &reading);
|
||||
} else try out.writer.writeAll("unknown");
|
||||
}
|
||||
try out.writer.writeByte('\n');
|
||||
}
|
||||
@@ -345,22 +347,23 @@ fn writeOptionalCount(writer: *std.Io.Writer, value: ?u64) !void {
|
||||
if (value) |v| try writer.print("{d}", .{v}) else try writer.writeAll("-");
|
||||
}
|
||||
|
||||
/// `<prefix>08:14 UTC` for a timestamp in milliseconds.
|
||||
fn writeUtcClock(writer: *std.Io.Writer, prefix: []const u8, ms: i64) !void {
|
||||
const minutes: u64 = @intCast(@mod(@divFloor(ms, std.time.ms_per_min), 24 * 60));
|
||||
try writer.print("{s}{d:0>2}:{d:0>2} UTC", .{ prefix, minutes / 60, minutes % 60 });
|
||||
/// `<prefix>08:14 PDT` for a timestamp in milliseconds, in local time.
|
||||
fn writeLocalClock(writer: *std.Io.Writer, prefix: []const u8, ms: i64) !void {
|
||||
try writer.writeAll(prefix);
|
||||
const reading = local_time.local(ms) orelse return writer.writeAll("unknown");
|
||||
try writeClock(writer, &reading);
|
||||
}
|
||||
|
||||
/// `Oct 11, 2026` for a timestamp in milliseconds, or `Unknown`.
|
||||
fn formatUtcDate(buf: *[24]u8, timestamp_ms: i64) []const u8 {
|
||||
if (timestamp_ms < 0) return "Unknown";
|
||||
const seconds: u64 = @intCast(@divFloor(timestamp_ms, std.time.ms_per_s));
|
||||
const epoch_seconds: std.time.epoch.EpochSeconds = .{ .secs = seconds };
|
||||
const year_day = epoch_seconds.getEpochDay().calculateYearDay();
|
||||
const month_day = year_day.calculateMonthDay();
|
||||
/// `08:14 PDT`.
|
||||
fn writeClock(writer: *std.Io.Writer, reading: *const local_time.Reading) !void {
|
||||
try writer.print("{d:0>2}:{d:0>2} {s}", .{ reading.hour, reading.minute, reading.zoneName() });
|
||||
}
|
||||
|
||||
/// `Oct 11, 2026`.
|
||||
fn formatDate(buf: *[24]u8, reading: *const local_time.Reading) []const u8 {
|
||||
const months = [_][]const u8{ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" };
|
||||
const month = months[month_day.month.numeric() - 1];
|
||||
return std.mem.print(buf, "{s} {d}, {d}", .{ month, month_day.day_index + 1, year_day.year }) catch "Unknown";
|
||||
const month = months[reading.month.numeric() - 1];
|
||||
return std.mem.print(buf, "{s} {d}, {d}", .{ month, reading.day, reading.year }) catch "Unknown";
|
||||
}
|
||||
|
||||
/// `Refresh failed: <why>.`, naming the service the numbers come from.
|
||||
@@ -1269,7 +1272,7 @@ fn writeSessionListDetails(
|
||||
try writeTerminalSafe(writer, alloc, label);
|
||||
}
|
||||
try writer.writeAll(" | updated ");
|
||||
try writeUtcTimestamp(writer, entry.updated_at_ms);
|
||||
try writeLocalTimestamp(writer, entry.updated_at_ms);
|
||||
try writer.writeByte('\n');
|
||||
}
|
||||
|
||||
@@ -1309,29 +1312,10 @@ fn sessionLanguageLabel(tag: []const u8) ?[]const u8 {
|
||||
return tag;
|
||||
}
|
||||
|
||||
fn writeUtcTimestamp(writer: *std.Io.Writer, timestamp_ms: i64) !void {
|
||||
const max_supported_timestamp_ms: i64 = 253_402_300_799_999;
|
||||
if (timestamp_ms < 0 or timestamp_ms > max_supported_timestamp_ms) {
|
||||
try writer.writeAll("unknown");
|
||||
return;
|
||||
}
|
||||
|
||||
const epoch_secs: u64 = @intCast(@divTrunc(timestamp_ms, std.time.ms_per_s));
|
||||
const milliseconds: u16 = @intCast(@mod(timestamp_ms, std.time.ms_per_s));
|
||||
const epoch = std.time.epoch.EpochSeconds{ .secs = epoch_secs };
|
||||
const day = epoch.getDaySeconds();
|
||||
const year_day = epoch.getEpochDay().calculateYearDay();
|
||||
const month_day = year_day.calculateMonthDay();
|
||||
|
||||
try writer.print("{d:0>4}-{d:0>2}-{d:0>2} {d:0>2}:{d:0>2}:{d:0>2}.{d:0>3} UTC", .{
|
||||
year_day.year,
|
||||
@backingInt(month_day.month),
|
||||
month_day.day_index + 1,
|
||||
day.getHoursIntoDay(),
|
||||
day.getMinutesIntoHour(),
|
||||
day.getSecondsIntoMinute(),
|
||||
milliseconds,
|
||||
});
|
||||
/// `2026-10-16 13:31:49.123 PDT` in local time, or `unknown`.
|
||||
fn writeLocalTimestamp(writer: *std.Io.Writer, timestamp_ms: i64) !void {
|
||||
const reading = local_time.local(timestamp_ms) orelse return writer.writeAll("unknown");
|
||||
try writer.print("{f}", .{reading});
|
||||
}
|
||||
|
||||
pub const SessionSummarySnapshot = struct {
|
||||
@@ -2852,6 +2836,26 @@ test "core session list snapshot text and json stay stable" {
|
||||
);
|
||||
}
|
||||
|
||||
test "core session list text reads updated times in the local zone" {
|
||||
const sessions = [_]session_store.SessionSummary{
|
||||
.{
|
||||
.id = @constCast("local"),
|
||||
.created_at_ms = 1,
|
||||
.updated_at_ms = 1_700_000_000_123,
|
||||
.conversation_language = types.ConversationLanguage.default(),
|
||||
.history_len = 0,
|
||||
},
|
||||
};
|
||||
local_time.test_zone = .{ .offset_s = 5 * std.time.s_per_hour + 30 * std.time.s_per_min, .name = "IST" };
|
||||
defer local_time.test_zone = .utc;
|
||||
const text = try (SessionListSnapshot{ .sessions = &sessions }).renderText(std.testing.allocator);
|
||||
defer std.testing.allocator.free(text);
|
||||
try std.testing.expectEqualStrings(
|
||||
"[sessions] 1 saved\n - Untitled session\n id=local | 0 turns | updated 2023-11-15 03:43:20.123 IST\n",
|
||||
text,
|
||||
);
|
||||
}
|
||||
|
||||
test "core session list text visibly escapes terminal controls in titles" {
|
||||
const sessions = [_]session_store.SessionSummary{
|
||||
.{
|
||||
@@ -3652,6 +3656,16 @@ test "usage text and JSON render a Grok plan's allowance and credits" {
|
||||
text,
|
||||
);
|
||||
|
||||
{
|
||||
// West of UTC, the reset falls on the previous calendar day.
|
||||
local_time.test_zone = .{ .offset_s = -5 * std.time.s_per_hour, .name = "EST" };
|
||||
defer local_time.test_zone = .utc;
|
||||
const local_text = try (UsageSnapshot{ .snapshot = &snapshot }).render(alloc, .text);
|
||||
defer alloc.free(local_text);
|
||||
try std.testing.expect(std.mem.startsWith(u8, local_text, "Grok usage from xAI, updated 03:14 EST\n"));
|
||||
try std.testing.expect(std.mem.find(u8, local_text, ", resets Oct 14, 2026 19:00 EST\n") != null);
|
||||
}
|
||||
|
||||
var failed = snapshot;
|
||||
failed.status = .{ .failed = .network };
|
||||
const failed_text = try (UsageSnapshot{ .snapshot = &failed }).render(alloc, .text);
|
||||
@@ -3748,6 +3762,17 @@ test "usage text and JSON render a Codex plan's limits" {
|
||||
text,
|
||||
);
|
||||
|
||||
{
|
||||
local_time.test_zone = .{ .offset_s = 5 * std.time.s_per_hour + 30 * std.time.s_per_min, .name = "IST" };
|
||||
defer local_time.test_zone = .utc;
|
||||
const local_text = try contract.render(alloc, .text);
|
||||
defer alloc.free(local_text);
|
||||
try std.testing.expectEqualStrings(
|
||||
"Codex usage from ChatGPT, updated 13:44 IST\nPlan plus\nCredits none\nSpend limit not reached\n\nLimits\n- codex (5h window): 42% used, resets 15:44 IST\n",
|
||||
local_text,
|
||||
);
|
||||
}
|
||||
|
||||
const json = try contract.render(alloc, .json);
|
||||
defer alloc.free(json);
|
||||
const parsed = try std.json.parseFromSlice(std.json.Value, alloc, json, .{});
|
||||
@@ -3782,15 +3807,11 @@ pub const SlackSnapshot = struct {
|
||||
else
|
||||
"Your fx Slack bot credentials are saved on this computer.\n");
|
||||
if (self.expires_at_ms) |expiry| {
|
||||
if (expiry >= 0 and expiry <= 253_402_300_799_999) {
|
||||
if (local_time.local(expiry)) |reading| {
|
||||
var date_buf: [24]u8 = undefined;
|
||||
const epoch: std.time.epoch.EpochSeconds = .{ .secs = @intCast(@divFloor(expiry, std.time.ms_per_s)) };
|
||||
const day = epoch.getDaySeconds();
|
||||
try out.writer.print("Access expires on {s} at {d:0>2}:{d:0>2} UTC.\n", .{
|
||||
formatUtcDate(&date_buf, expiry),
|
||||
day.getHoursIntoDay(),
|
||||
day.getMinutesIntoHour(),
|
||||
});
|
||||
try out.writer.print("Access expires on {s} at ", .{formatDate(&date_buf, &reading)});
|
||||
try writeClock(&out.writer, &reading);
|
||||
try out.writer.writeAll(".\n");
|
||||
} else {
|
||||
try out.writer.writeAll("Access expiration time is unavailable.\n");
|
||||
}
|
||||
@@ -3800,3 +3821,31 @@ pub const SlackSnapshot = struct {
|
||||
return out.toOwnedSlice();
|
||||
}
|
||||
};
|
||||
|
||||
test "slack text reads the access expiry in the local zone" {
|
||||
const alloc = std.testing.allocator;
|
||||
var snapshot: SlackSnapshot = .{
|
||||
.action = "status",
|
||||
.installed = true,
|
||||
.app_id = null,
|
||||
.team_id = null,
|
||||
.bot_user_id = null,
|
||||
// 2026-10-15 00:00 UTC.
|
||||
.expires_at_ms = 20741 * std.time.ms_per_day,
|
||||
.refresh_expires_at_ms = null,
|
||||
};
|
||||
const utc_text = try snapshot.render(alloc, .text);
|
||||
defer alloc.free(utc_text);
|
||||
try std.testing.expect(std.mem.find(u8, utc_text, "Access expires on Oct 15, 2026 at 00:00 UTC.\n") != null);
|
||||
|
||||
local_time.test_zone = .{ .offset_s = -7 * std.time.s_per_hour, .name = "PDT" };
|
||||
defer local_time.test_zone = .utc;
|
||||
const local_text = try snapshot.render(alloc, .text);
|
||||
defer alloc.free(local_text);
|
||||
try std.testing.expect(std.mem.find(u8, local_text, "Access expires on Oct 14, 2026 at 17:00 PDT.\n") != null);
|
||||
|
||||
snapshot.expires_at_ms = -1;
|
||||
const unknown_text = try snapshot.render(alloc, .text);
|
||||
defer alloc.free(unknown_text);
|
||||
try std.testing.expect(std.mem.find(u8, unknown_text, "Access expiration time is unavailable.\n") != null);
|
||||
}
|
||||
@@ -598,11 +598,13 @@ fn serializeRecord(
|
||||
}
|
||||
|
||||
fn parseRecord(alloc: Allocator, line: []const u8) !ParsedRecord {
|
||||
// Strings may point into `line`, which outlives the parse; everything
|
||||
// returned is copied before `parsed` is freed.
|
||||
var parsed = try std.json.parseFromSlice(
|
||||
std.json.Value,
|
||||
alloc,
|
||||
line,
|
||||
.{ .allocate = .alloc_always },
|
||||
.{ .allocate = .alloc_if_needed },
|
||||
);
|
||||
defer parsed.deinit();
|
||||
if (parsed.value != .object or parsed.value.object.count() != 4) {
|
||||
@@ -662,6 +664,9 @@ fn processCompleteLine(
|
||||
entries: *std.ArrayList(LoadedPromptHistoryEntry),
|
||||
) !void {
|
||||
if (line.len + 1 > max_record_bytes) return;
|
||||
// Most lines belong to other workspaces; skip them before the full parse,
|
||||
// which costs far more and runs before the first frame.
|
||||
if (!mayBelongToWorkspace(line, workspace_root)) return;
|
||||
var record = parseRecord(alloc, line) catch |err| {
|
||||
debug_trace.logf(
|
||||
"prompt_history",
|
||||
@@ -682,6 +687,30 @@ fn processCompleteLine(
|
||||
};
|
||||
}
|
||||
|
||||
/// False only when `line` can't be a record for `workspace_root`: every
|
||||
/// `"workspace_root":"` in it has a value with no escape that differs from
|
||||
/// it, and a value with no escape decodes to its own bytes. That key can't
|
||||
/// occur inside a string, where each quote is escaped. Anything else, such
|
||||
/// as an escaped value or a key written with spaces, goes to the full parse,
|
||||
/// which decides the line as before.
|
||||
fn mayBelongToWorkspace(line: []const u8, workspace_root: []const u8) bool {
|
||||
const key = "\"workspace_root\":\"";
|
||||
var found = false;
|
||||
var i: usize = 0;
|
||||
// Hopping between quotes is a vector scan; a substring search costs
|
||||
// more than the lines it skips.
|
||||
while (std.mem.findScalarPos(u8, line, i, '"')) |quote| {
|
||||
i = quote + 1;
|
||||
if (!std.mem.startsWith(u8, line[quote..], key)) continue;
|
||||
found = true;
|
||||
const start = quote + key.len;
|
||||
const end = std.mem.findAnyPos(u8, line, start, "\"\\") orelse return true;
|
||||
if (line[end] == '\\' or std.mem.eql(u8, line[start..end], workspace_root)) return true;
|
||||
i = end + 1;
|
||||
}
|
||||
return !found;
|
||||
}
|
||||
|
||||
fn repairIncompleteTail(file: std.Io.File) !void {
|
||||
const length = try file.length(io_mod.getIo());
|
||||
if (length == 0) return;
|
||||
@@ -951,6 +980,53 @@ test "reverse load filters workspace bounds results and preserves chronology" {
|
||||
try std.testing.expectEqualStrings("other", other[0].text);
|
||||
}
|
||||
|
||||
test "the workspace prefilter skips only lines that can't be the workspace's" {
|
||||
const root = "/tmp/workspace-a";
|
||||
try std.testing.expect(mayBelongToWorkspace("{\"schema_version\":1,\"timestamp_ms\":1,\"workspace_root\":\"/tmp/workspace-a\",\"text\":\"x\"}", root));
|
||||
try std.testing.expect(!mayBelongToWorkspace("{\"schema_version\":1,\"timestamp_ms\":1,\"workspace_root\":\"/tmp/workspace-b\",\"text\":\"x\"}", root));
|
||||
// A prefix of the root, or the root inside a longer path, is another workspace.
|
||||
try std.testing.expect(!mayBelongToWorkspace("{\"workspace_root\":\"/tmp/workspace\",\"text\":\"x\"}", root));
|
||||
try std.testing.expect(!mayBelongToWorkspace("{\"workspace_root\":\"/tmp/workspace-a/sub\",\"text\":\"x\"}", root));
|
||||
// Text that quotes a matching key doesn't count: its quotes are escaped.
|
||||
try std.testing.expect(!mayBelongToWorkspace("{\"workspace_root\":\"/tmp/workspace-b\",\"text\":\"\\\"workspace_root\\\":\\\"/tmp/workspace-a\\\"\"}", root));
|
||||
// Otherwise the full parse decides: an escaped value, a key with spaces,
|
||||
// a second key, a value with no end, or a line with no key at all.
|
||||
try std.testing.expect(mayBelongToWorkspace("{\"workspace_root\":\"\\/tmp\\/workspace-a\"}", root));
|
||||
try std.testing.expect(mayBelongToWorkspace("{\"workspace_root\" : \"/tmp/workspace-b\"}", root));
|
||||
try std.testing.expect(mayBelongToWorkspace("{\"workspace_root\":\"/tmp/workspace-b\",\"workspace_root\":\"/tmp/workspace-a\"}", root));
|
||||
try std.testing.expect(mayBelongToWorkspace("{\"workspace_root\":\"/tmp/workspace-a", root));
|
||||
try std.testing.expect(mayBelongToWorkspace("{malformed}", root));
|
||||
}
|
||||
|
||||
test "reverse load keeps a record whose workspace is written with escapes or spaces" {
|
||||
const alloc = std.testing.allocator;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, ".");
|
||||
defer alloc.free(home);
|
||||
|
||||
const plain = try fixtureLine(alloc, 1, "/tmp/workspace-a", "plain");
|
||||
defer alloc.free(plain);
|
||||
const escaped = try fixtureLine(alloc, 2, "\\/tmp\\/workspace-a", "escaped");
|
||||
defer alloc.free(escaped);
|
||||
const other = try fixtureLine(alloc, 3, "/tmp/workspace-b", "other");
|
||||
defer alloc.free(other);
|
||||
const bytes = try std.mem.concat(alloc, u8, &.{
|
||||
plain,
|
||||
escaped,
|
||||
"{\"schema_version\":1, \"timestamp_ms\":4, \"workspace_root\" : \"/tmp/workspace-a\", \"text\":\"spaced\"}\n",
|
||||
other,
|
||||
});
|
||||
defer alloc.free(bytes);
|
||||
try writeFixture(home, bytes);
|
||||
|
||||
var store = try Store.initFromHome(alloc, home);
|
||||
defer store.deinit(alloc);
|
||||
const entries = try store.loadRecentForWorkspace(alloc, "/tmp/workspace-a", 100);
|
||||
defer freeLoadedEntries(alloc, entries);
|
||||
try expectTexts(entries, &.{ "plain", "escaped", "spaced" });
|
||||
}
|
||||
|
||||
test "reverse load scans beyond one mebibyte of newer interleaved workspace records" {
|
||||
const alloc = std.testing.allocator;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
|
||||
@@ -1982,6 +1982,82 @@ pub const Session = struct {
|
||||
try replay(self.source(), alloc, alloc, .start, null, ReplaySink.init(&sink));
|
||||
}
|
||||
|
||||
/// A prompt a fork can start before: the turn it opened and its text.
|
||||
pub const ForkPoint = struct {
|
||||
turn: u64,
|
||||
prompt: []u8,
|
||||
};
|
||||
|
||||
/// Every turn's prompt in the log, oldest first, for `fork` (D58).
|
||||
/// Caller owns the slice and each prompt; free with `freeForkPoints`.
|
||||
pub fn forkPoints(self: *Session, alloc: Allocator) ![]ForkPoint {
|
||||
const Sink = struct {
|
||||
alloc: Allocator,
|
||||
points: std.ArrayList(ForkPoint) = .empty,
|
||||
|
||||
fn turn(sink: *@This(), value: types.HistoryTurn, number: ?u64) !void {
|
||||
defer types.freeHistoryTurn(sink.alloc, value);
|
||||
const user = switch (value) {
|
||||
.assistant => |entry| entry.user,
|
||||
.interrupted => |entry| entry.user,
|
||||
.compacted_summary => return,
|
||||
};
|
||||
const prompt = try sink.alloc.dupe(u8, user.text);
|
||||
errdefer sink.alloc.free(prompt);
|
||||
try sink.points.append(sink.alloc, .{ .turn = number orelse return error.InvalidConversationFrame, .prompt = prompt });
|
||||
}
|
||||
|
||||
fn summary(_: *@This(), _: []const u8) !void {}
|
||||
};
|
||||
var sink: Sink = .{ .alloc = alloc };
|
||||
errdefer {
|
||||
for (sink.points.items) |point| alloc.free(point.prompt);
|
||||
sink.points.deinit(alloc);
|
||||
}
|
||||
try replay(self.source(), alloc, alloc, .start, null, ReplaySink.init(&sink));
|
||||
return sink.points.toOwnedSlice(alloc);
|
||||
}
|
||||
|
||||
pub fn freeForkPoints(alloc: Allocator, points: []const ForkPoint) void {
|
||||
for (points) |point| alloc.free(point.prompt);
|
||||
alloc.free(points);
|
||||
}
|
||||
|
||||
pub const Fork = struct {
|
||||
/// The fork holds every turn before this one (a `ForkPoint.turn`).
|
||||
before_turn: u64,
|
||||
workspace: []const u8,
|
||||
host: Host,
|
||||
/// The title the source shows; the fork gets it marked as a fork.
|
||||
title: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
/// A new session holding this one's log up to the end of the turn before
|
||||
/// `options.before_turn`, under a new id (D58). The manager copies the
|
||||
/// lines and hard-links the blobs; this session is never written. The
|
||||
/// caller owns the fork, open for writing.
|
||||
pub fn fork(self: *Session, alloc: Allocator, options: Fork) !*Session {
|
||||
if (options.before_turn == 0) return error.InvalidForkPoint;
|
||||
io_mod.e2eFailIfDurableMutationAttempted();
|
||||
const handle = try self.store.manager.openFork(.{
|
||||
.source = self.id(),
|
||||
.at = .{ .turn = options.before_turn - 1 },
|
||||
.workspace = options.workspace,
|
||||
.host = options.host,
|
||||
});
|
||||
errdefer handle.release();
|
||||
const copy = try init(alloc, self.store, handle, false);
|
||||
errdefer copy.destroyInner();
|
||||
try copy.openMoved();
|
||||
var arena = std.heap.ArenaAllocator.init(alloc);
|
||||
defer arena.deinit();
|
||||
const state = try handle.state(arena.allocator());
|
||||
copy.last_turn = state.last_turn;
|
||||
if (options.title) |title| try copy.rename(try forkTitle(arena.allocator(), title));
|
||||
traceWiring("Fork", copy.id(), "", copy.store.pid);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/// The session as v1's `DurableSessionState`, for hosts that restore
|
||||
/// through it, and its stored title. Caller owns both.
|
||||
pub fn durableState(self: *Session, alloc: Allocator, workspace: []const u8) !Resumed {
|
||||
@@ -3947,6 +4023,13 @@ fn decodeLanguage(arena: Allocator, raw: []const u8) !types.ConversationLanguage
|
||||
return session_codec.parseConversationLanguage(tag);
|
||||
}
|
||||
|
||||
/// A fork's title: the source's, marked once as a fork (D58).
|
||||
fn forkTitle(arena: Allocator, title: []const u8) ![]const u8 {
|
||||
const mark = " (fork)";
|
||||
if (std.mem.endsWith(u8, title, mark)) return title;
|
||||
return arena.print("{s}" ++ mark, .{title});
|
||||
}
|
||||
|
||||
/// The title v1 derives from the first prompt of `history`, if any.
|
||||
fn deriveTitle(arena: Allocator, history: []const types.HistoryTurn) !?[]const u8 {
|
||||
const display = session_display_metadata.deriveFromHistory(arena, history) catch return null;
|
||||
@@ -4449,6 +4532,82 @@ fn storedTitle(s: *Session) !?[]u8 {
|
||||
return shown.title;
|
||||
}
|
||||
|
||||
fn commitPrompt(s: *Session, prompt: []const u8) !void {
|
||||
try s.commitTurn(.{ .assistant = .{ .user = .{ .text = @constCast(prompt) }, .assistant = @constCast("ok") } }, types.ConversationLanguage.default());
|
||||
}
|
||||
|
||||
fn lineCount(manager: *sm.Manager, id: []const u8) !usize {
|
||||
var page = try manager.read(testing.allocator, id, .start, .forward, 1000);
|
||||
defer page.deinit();
|
||||
return page.entries.len;
|
||||
}
|
||||
|
||||
test "fork points list every turn's prompt, an interrupted one included (D58)" {
|
||||
var t: TestHome = undefined;
|
||||
try t.init();
|
||||
defer t.deinit();
|
||||
var model = "m".*;
|
||||
const s = try Session.create(testing.allocator, &t.store, "/w", .app, testSeed(&model));
|
||||
defer s.close();
|
||||
try commitPrompt(s, "first");
|
||||
try s.commitTurn(.{ .interrupted = .{ .user = .{ .text = @constCast("second") }, .terminal_reason = .cancelled } }, types.ConversationLanguage.default());
|
||||
try commitPrompt(s, "third\nwith a second line");
|
||||
const points = try s.forkPoints(testing.allocator);
|
||||
defer Session.freeForkPoints(testing.allocator, points);
|
||||
try testing.expectEqual(@as(usize, 3), points.len);
|
||||
try testing.expectEqualStrings("first", points[0].prompt);
|
||||
try testing.expectEqualStrings("second", points[1].prompt);
|
||||
try testing.expectEqualStrings("third\nwith a second line", points[2].prompt);
|
||||
try testing.expect(points[0].turn < points[1].turn and points[1].turn < points[2].turn);
|
||||
}
|
||||
|
||||
test "a fork holds the turns before its prompt under a new id and leaves the source as it was (D58)" {
|
||||
var t: TestHome = undefined;
|
||||
try t.init();
|
||||
defer t.deinit();
|
||||
var model = "m".*;
|
||||
const s = try Session.create(testing.allocator, &t.store, "/w", .app, testSeed(&model));
|
||||
defer s.close();
|
||||
try commitPrompt(s, "first");
|
||||
try commitPrompt(s, "second");
|
||||
try commitPrompt(s, "third");
|
||||
const source_lines = try lineCount(t.store.manager, s.id());
|
||||
const points = try s.forkPoints(testing.allocator);
|
||||
defer Session.freeForkPoints(testing.allocator, points);
|
||||
|
||||
const copy = try s.fork(testing.allocator, .{ .before_turn = points[2].turn, .workspace = "/w", .host = .app, .title = "Plan" });
|
||||
defer copy.close();
|
||||
try testing.expect(!std.mem.eql(u8, s.id(), copy.id()));
|
||||
try testing.expectEqual(source_lines, try lineCount(t.store.manager, s.id()));
|
||||
var restored = try copy.restore(testing.allocator);
|
||||
defer restored.deinit(testing.allocator);
|
||||
try testing.expectEqual(@as(usize, 2), restored.history.len);
|
||||
try testing.expectEqualStrings("second", restored.history[1].assistant.user.text);
|
||||
try testing.expectEqualStrings("Plan (fork)", restored.title.?);
|
||||
// The fork goes on as any session does; the source is still untouched.
|
||||
try commitPrompt(copy, "a different third");
|
||||
try testing.expectEqual(source_lines, try lineCount(t.store.manager, s.id()));
|
||||
}
|
||||
|
||||
test "a fork of a fork keeps one fork mark, and a fork before the first prompt is empty (D58)" {
|
||||
var t: TestHome = undefined;
|
||||
try t.init();
|
||||
defer t.deinit();
|
||||
var model = "m".*;
|
||||
const s = try Session.create(testing.allocator, &t.store, "/w", .app, testSeed(&model));
|
||||
defer s.close();
|
||||
try commitPrompt(s, "first");
|
||||
const points = try s.forkPoints(testing.allocator);
|
||||
defer Session.freeForkPoints(testing.allocator, points);
|
||||
const copy = try s.fork(testing.allocator, .{ .before_turn = points[0].turn, .workspace = "/w", .host = .app, .title = "Plan (fork)" });
|
||||
defer copy.close();
|
||||
var restored = try copy.restore(testing.allocator);
|
||||
defer restored.deinit(testing.allocator);
|
||||
try testing.expectEqual(@as(usize, 0), restored.history.len);
|
||||
try testing.expectEqualStrings("Plan (fork)", restored.title.?);
|
||||
try testing.expectError(error.InvalidForkPoint, s.fork(testing.allocator, .{ .before_turn = 0, .workspace = "/w", .host = .app }));
|
||||
}
|
||||
|
||||
test "a session whose first turn never ended takes its first prompt as title at its next turn end (D52)" {
|
||||
var t: TestHome = undefined;
|
||||
try t.init();
|
||||
|
||||
@@ -561,6 +561,196 @@ pub const ImageBlobReader = struct {
|
||||
}
|
||||
};
|
||||
|
||||
/// An image's name in a libfx host's store: the lowercase hex SHA-256 of its
|
||||
/// bytes, as `ImageAttachment.snapshot_sha256` holds it for an inline image.
|
||||
const image_name_len = 2 * Sha256.digest_length;
|
||||
pub const ImageName = [image_name_len]u8;
|
||||
|
||||
/// `text` as an image name, or null when it is not 64 lowercase hex digits.
|
||||
pub fn imageName(text: []const u8) ?ImageName {
|
||||
if (text.len != image_name_len) return null;
|
||||
for (text) |char| switch (char) {
|
||||
'0'...'9', 'a'...'f' => {},
|
||||
else => return null,
|
||||
};
|
||||
return text[0..image_name_len].*;
|
||||
}
|
||||
|
||||
pub fn imageNameOf(bytes: []const u8) ImageName {
|
||||
var digest: [Sha256.digest_length]u8 = undefined;
|
||||
Sha256.hash(bytes, &digest, .{});
|
||||
return std.fmt.bytesToHex(digest, .lower);
|
||||
}
|
||||
|
||||
/// The images a libfx host's store holds, by name.
|
||||
pub const StoredImages = struct {
|
||||
names: std.array_hash_map.Auto(ImageName, void) = .empty,
|
||||
|
||||
pub fn deinit(self: *StoredImages, alloc: Allocator) void {
|
||||
self.names.deinit(alloc);
|
||||
self.* = undefined;
|
||||
}
|
||||
|
||||
pub fn add(self: *StoredImages, alloc: Allocator, name: ImageName) Allocator.Error!void {
|
||||
try self.names.put(alloc, name, {});
|
||||
}
|
||||
|
||||
/// Whether `image` has bytes the store holds under its name.
|
||||
pub fn holds(self: *const StoredImages, image: types.ImageAttachment) bool {
|
||||
if (image.inline_data == null) return false;
|
||||
const name = imageName(image.snapshot_sha256 orelse return false) orelse return false;
|
||||
return self.names.contains(name);
|
||||
}
|
||||
};
|
||||
|
||||
/// Whether writing `user` names an image instead of holding its bytes, which
|
||||
/// only the naming format can: one `stored` holds, or one the session
|
||||
/// dropped, which keeps only its name with or without a store.
|
||||
pub fn namesImages(user: session.UserTurn, stored: ?*const StoredImages) bool {
|
||||
for (user.images) |image| {
|
||||
if (image.droppedFromSession()) return true;
|
||||
if (stored) |names| if (names.holds(image)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
pub fn turnNamesImages(turn: session.HistoryTurn, stored: ?*const StoredImages) bool {
|
||||
return switch (turn) {
|
||||
.assistant => |entry| namesImages(entry.user, stored) or executionNamesImages(entry.execution, .{}, stored),
|
||||
.interrupted => |entry| namesImages(entry.user, stored) or executionNamesImages(entry.execution, .{}, stored),
|
||||
.compacted_summary => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// Whether writing the items of `execution` after `from` names a tool image:
|
||||
/// one the host stored, when `stored` is given, or one the session dropped.
|
||||
pub fn executionNamesImages(execution: session.ExecutionMemory, from: ExecutionCounts, stored: ?*const StoredImages) bool {
|
||||
for (execution.tool_steps[from.tool_steps..]) |step| for (step.tool_results) |result| {
|
||||
if (toolImagesNameImages(result.tool_images, stored)) return true;
|
||||
};
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Whether writing `images` names one: one the host stored, when `stored` is
|
||||
/// given, or one the session dropped.
|
||||
pub fn toolImagesNameImages(images: []const types.ToolImage, stored: ?*const StoredImages) bool {
|
||||
for (images) |image| {
|
||||
if (image.droppedFromSession() or (stored != null and image.name != null)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Whether writing `checkpoint` names an image; with `delta`, only the
|
||||
/// execution items after it, which is all a delta progress writes.
|
||||
pub fn checkpointNamesImages(checkpoint: RecoveryCheckpoint, delta: ?ExecutionCounts, stored: ?*const StoredImages) bool {
|
||||
if (delta == null and namesImages(checkpoint.user, stored)) return true;
|
||||
return executionNamesImages(checkpoint.execution, delta orelse .{}, stored);
|
||||
}
|
||||
|
||||
/// The images a libfx session with an image store keeps: its newest, at most
|
||||
/// `max_images` of them holding at most `max_bytes`. An open stages these from
|
||||
/// the host's store, with the open turn's, which the window leaves out. Older
|
||||
/// images leave the session and keep only their names, so a session never
|
||||
/// grows past what it can reopen.
|
||||
pub const ImageWindow = struct {
|
||||
max_images: usize = 4096,
|
||||
max_bytes: usize = 512 * 1024 * 1024,
|
||||
|
||||
/// How many of the oldest images leave the window. `sizes` holds each
|
||||
/// image's bytes, oldest first, or null for one that already left, which
|
||||
/// keeps it and every older one out. Once an image does not fit, it and
|
||||
/// every older one leave.
|
||||
pub fn oldestOutside(self: ImageWindow, sizes: []const ?usize) usize {
|
||||
var kept: usize = 0;
|
||||
var bytes: usize = 0;
|
||||
var start = sizes.len;
|
||||
while (start > 0) : (start -= 1) {
|
||||
const size = sizes[start - 1] orelse break;
|
||||
if (kept == self.max_images or size > self.max_bytes - bytes) break;
|
||||
kept += 1;
|
||||
bytes += size;
|
||||
}
|
||||
return start;
|
||||
}
|
||||
};
|
||||
|
||||
/// Writes images the host stored as `inline_ref` names. Every other image
|
||||
/// keeps its bytes: in `blobs`, a libfx kernel checkpoint's blob section,
|
||||
/// when given, else inline.
|
||||
const ImageNames = struct {
|
||||
stored: *const StoredImages,
|
||||
blobs: ?ImageBlobs = null,
|
||||
};
|
||||
|
||||
/// Images a libfx host staged from its store for an open, by name. Owns the
|
||||
/// bytes.
|
||||
pub const StagedImages = struct {
|
||||
images: std.array_hash_map.Auto(ImageName, []u8) = .empty,
|
||||
bytes: usize = 0,
|
||||
|
||||
pub fn deinit(self: *StagedImages, alloc: Allocator) void {
|
||||
for (self.images.values()) |bytes| alloc.free(bytes);
|
||||
self.images.deinit(alloc);
|
||||
self.* = undefined;
|
||||
}
|
||||
|
||||
/// Stages `bytes`, `alloc`-owned, as `name`. A staged image must be
|
||||
/// exactly the bytes its name is the hash of. On success it owns `bytes`,
|
||||
/// freeing them when it already holds the name; on error the caller
|
||||
/// still owns them.
|
||||
pub fn add(self: *StagedImages, alloc: Allocator, name: ImageName, bytes: []u8) error{ OutOfMemory, ImageNameMismatch }!void {
|
||||
if (!std.mem.eql(u8, &imageNameOf(bytes), &name)) return error.ImageNameMismatch;
|
||||
if (self.images.contains(name)) {
|
||||
alloc.free(bytes);
|
||||
return;
|
||||
}
|
||||
try self.images.put(alloc, name, bytes);
|
||||
self.bytes += bytes.len;
|
||||
}
|
||||
|
||||
pub fn clear(self: *StagedImages, alloc: Allocator) void {
|
||||
for (self.images.values()) |bytes| alloc.free(bytes);
|
||||
self.images.clearRetainingCapacity();
|
||||
self.bytes = 0;
|
||||
}
|
||||
};
|
||||
|
||||
/// Resolves `inline_ref` names to staged bytes while a libfx journal or
|
||||
/// checkpoint is read. A name it cannot resolve is recorded in `missing` with
|
||||
/// the size its record gives, and parses as an image without bytes, so one
|
||||
/// read names every image an open needs; a caller that needs any of them
|
||||
/// discards what it read.
|
||||
pub const ImageResolver = struct {
|
||||
alloc: Allocator,
|
||||
staged: *const StagedImages,
|
||||
missing: std.array_hash_map.Auto(ImageName, usize) = .empty,
|
||||
/// The names it resolved, which the store therefore holds.
|
||||
resolved: std.array_hash_map.Auto(ImageName, void) = .empty,
|
||||
|
||||
pub fn deinit(self: *ImageResolver) void {
|
||||
self.missing.deinit(self.alloc);
|
||||
self.resolved.deinit(self.alloc);
|
||||
self.* = undefined;
|
||||
}
|
||||
|
||||
/// A copy of the bytes named `name`, owned by `alloc`, or null when none
|
||||
/// were staged. `size` is the image's size as its record gives it.
|
||||
fn resolve(self: *ImageResolver, alloc: Allocator, name: ImageName, size: usize) Allocator.Error!?[]u8 {
|
||||
const bytes = self.staged.images.get(name) orelse {
|
||||
try self.missing.put(self.alloc, name, size);
|
||||
return null;
|
||||
};
|
||||
try self.resolved.put(self.alloc, name, {});
|
||||
return try alloc.dupe(u8, bytes);
|
||||
}
|
||||
};
|
||||
|
||||
/// Where a parse finds image bytes kept outside the image object.
|
||||
pub const ImageSources = struct {
|
||||
blobs: ?*ImageBlobReader = null,
|
||||
names: ?*ImageResolver = null,
|
||||
};
|
||||
|
||||
pub fn writeHistoryTurn(writer: *std.Io.Writer, turn: session.HistoryTurn) !void {
|
||||
return writeHistoryTurnTo(writer, turn, {});
|
||||
}
|
||||
@@ -575,8 +765,15 @@ pub fn writeHistoryTurnWithImageBlobs(
|
||||
return writeHistoryTurnTo(writer, turn, image_blobs);
|
||||
}
|
||||
|
||||
/// `image_blobs` is `void` to embed inline image bytes or `ImageBlobs` to
|
||||
/// collect them. The choice is comptime so durable writers keep their error set.
|
||||
/// Like `writeHistoryTurn`, but names the images `names.stored` holds
|
||||
/// instead of writing their bytes.
|
||||
pub fn writeHistoryTurnNamingImages(writer: *std.Io.Writer, turn: session.HistoryTurn, names: ImageNames) !void {
|
||||
return writeHistoryTurnTo(writer, turn, names);
|
||||
}
|
||||
|
||||
/// `image_blobs` is `void` to embed inline image bytes, `ImageBlobs` to
|
||||
/// collect them, or `ImageNames` to name stored ones. The choice is comptime
|
||||
/// so durable writers keep their error set.
|
||||
fn writeHistoryTurnTo(writer: *std.Io.Writer, turn: session.HistoryTurn, image_blobs: anytype) !void {
|
||||
switch (turn) {
|
||||
.compacted_summary => |entry| {
|
||||
@@ -596,7 +793,7 @@ fn writeHistoryTurnTo(writer: *std.Io.Writer, turn: session.HistoryTurn, image_b
|
||||
try writer.writeAll(",\"provider_replay\":");
|
||||
try std.json.Stringify.value(entry.provider_replay, .{}, writer);
|
||||
try writer.writeAll(",\"execution\":");
|
||||
try writeExecutionMemoryJson(writer, entry.execution, .durable);
|
||||
try writeExecutionMemoryJsonFrom(writer, entry.execution, .durable, .{}, image_blobs);
|
||||
try writer.writeByte('}');
|
||||
},
|
||||
.interrupted => |entry| {
|
||||
@@ -623,7 +820,7 @@ fn writeHistoryTurnTo(writer: *std.Io.Writer, turn: session.HistoryTurn, image_b
|
||||
}
|
||||
if (hasDurableExecutionMemory(entry.execution)) {
|
||||
try writer.writeAll(",\"execution\":");
|
||||
try writeExecutionMemoryJson(writer, entry.execution, .durable);
|
||||
try writeExecutionMemoryJsonFrom(writer, entry.execution, .durable, .{}, image_blobs);
|
||||
}
|
||||
if (entry.cancelled_command) |presentation| {
|
||||
try writer.writeAll(",\"cancelled_command\":");
|
||||
@@ -667,6 +864,16 @@ pub fn parseHistoryTurnWithImageBlobs(
|
||||
alloc: Allocator,
|
||||
value: std.json.Value,
|
||||
image_blobs: ?*ImageBlobReader,
|
||||
) !session.HistoryTurn {
|
||||
return parseHistoryTurnWithImages(alloc, value, .{ .blobs = image_blobs });
|
||||
}
|
||||
|
||||
/// Like `parseHistoryTurn`, but reads the image bytes a libfx journal or
|
||||
/// checkpoint keeps outside each image from `sources`.
|
||||
pub fn parseHistoryTurnWithImages(
|
||||
alloc: Allocator,
|
||||
value: std.json.Value,
|
||||
sources: ImageSources,
|
||||
) !session.HistoryTurn {
|
||||
const kind = try requireString(try requireObject(value), "kind");
|
||||
if (std.mem.eql(u8, kind, "compacted_summary")) {
|
||||
@@ -724,11 +931,11 @@ pub fn parseHistoryTurnWithImageBlobs(
|
||||
if (std.mem.eql(u8, kind, "assistant")) {
|
||||
const shape = try exactVariantObject(value, &.{ "kind", "user", "assistant", "execution" }, &.{ "kind", "user", "assistant", "execution", "provider_replay" });
|
||||
const object = shape.object;
|
||||
const user = try parseUserTurn(alloc, object.get("user") orelse return error.InvalidSessionFormat, image_blobs);
|
||||
const user = try parseUserTurn(alloc, object.get("user") orelse return error.InvalidSessionFormat, sources);
|
||||
errdefer session.freeUserTurn(alloc, user);
|
||||
const assistant = try parseRequiredDurableBytes(alloc, object, "assistant");
|
||||
errdefer mem_utils.free(alloc, assistant);
|
||||
const execution = try parseExecutionMemory(alloc, object.get("execution") orelse return error.InvalidSessionFormat);
|
||||
const execution = try parseExecutionMemoryWith(alloc, object.get("execution") orelse return error.InvalidSessionFormat, sources);
|
||||
errdefer session.freeExecutionMemory(alloc, execution);
|
||||
const replay = try parseProviderReplay(alloc, object.get("provider_replay") orelse .null);
|
||||
return .{ .assistant = .{
|
||||
@@ -745,7 +952,7 @@ pub fn parseHistoryTurnWithImageBlobs(
|
||||
&.{ "kind", "user", "log_path", "expect_url", "url", "background_record_id", "assistant", "execution" },
|
||||
);
|
||||
const object = shape.object;
|
||||
const user = try parseUserTurn(alloc, object.get("user") orelse return error.InvalidSessionFormat, image_blobs);
|
||||
const user = try parseUserTurn(alloc, object.get("user") orelse return error.InvalidSessionFormat, sources);
|
||||
errdefer session.freeUserTurn(alloc, user);
|
||||
const legacy_assistant = if (shape.extended)
|
||||
try parseOptionalDurableBytes(alloc, object.get("assistant") orelse return error.InvalidSessionFormat)
|
||||
@@ -753,7 +960,7 @@ pub fn parseHistoryTurnWithImageBlobs(
|
||||
null;
|
||||
defer if (legacy_assistant) |owned| mem_utils.free(alloc, owned);
|
||||
const execution = if (shape.extended)
|
||||
try parseExecutionMemory(alloc, object.get("execution") orelse return error.InvalidSessionFormat)
|
||||
try parseExecutionMemoryWith(alloc, object.get("execution") orelse return error.InvalidSessionFormat, sources)
|
||||
else
|
||||
session.ExecutionMemory{};
|
||||
errdefer session.freeExecutionMemory(alloc, execution);
|
||||
@@ -788,7 +995,7 @@ pub fn parseHistoryTurnWithImageBlobs(
|
||||
has_terminal_reason,
|
||||
has_cancellation_origin,
|
||||
);
|
||||
const user = try parseUserTurn(alloc, object.get("user") orelse return error.InvalidSessionFormat, image_blobs);
|
||||
const user = try parseUserTurn(alloc, object.get("user") orelse return error.InvalidSessionFormat, sources);
|
||||
errdefer session.freeUserTurn(alloc, user);
|
||||
const assistant = try parseOptionalDurableBytes(alloc, object.get("assistant") orelse return error.InvalidSessionFormat);
|
||||
errdefer if (assistant) |owned| mem_utils.free(alloc, owned);
|
||||
@@ -809,7 +1016,7 @@ pub fn parseHistoryTurnWithImageBlobs(
|
||||
else
|
||||
.turn;
|
||||
const execution = if (has_execution)
|
||||
try parseExecutionMemory(alloc, object.get("execution") orelse return error.InvalidSessionFormat)
|
||||
try parseExecutionMemoryWith(alloc, object.get("execution") orelse return error.InvalidSessionFormat, sources)
|
||||
else
|
||||
session.ExecutionMemory{};
|
||||
errdefer session.freeExecutionMemory(alloc, execution);
|
||||
@@ -1068,7 +1275,13 @@ fn parsePermissionState(
|
||||
}
|
||||
|
||||
pub fn writeRecoveryCheckpoint(writer: *std.Io.Writer, checkpoint: RecoveryCheckpoint) !void {
|
||||
return writeRecoveryCheckpointFrom(writer, checkpoint, null);
|
||||
return writeRecoveryCheckpointFrom(writer, checkpoint, null, {});
|
||||
}
|
||||
|
||||
/// Like `writeRecoveryCheckpoint`, but names the images `names.stored` holds
|
||||
/// instead of writing their bytes.
|
||||
pub fn writeRecoveryCheckpointNamingImages(writer: *std.Io.Writer, checkpoint: RecoveryCheckpoint, names: ImageNames) !void {
|
||||
return writeRecoveryCheckpointFrom(writer, checkpoint, null, names);
|
||||
}
|
||||
|
||||
/// How many items of each list in an open turn's execution an earlier
|
||||
@@ -1083,10 +1296,16 @@ pub const ExecutionCounts = struct {
|
||||
/// execution items after `from`: what a progress adds to the one before it,
|
||||
/// which holds the rest.
|
||||
pub fn writeRecoveryCheckpointDelta(writer: *std.Io.Writer, checkpoint: RecoveryCheckpoint, from: ExecutionCounts) !void {
|
||||
return writeRecoveryCheckpointFrom(writer, checkpoint, from);
|
||||
return writeRecoveryCheckpointFrom(writer, checkpoint, from, {});
|
||||
}
|
||||
|
||||
fn writeRecoveryCheckpointFrom(writer: *std.Io.Writer, checkpoint: RecoveryCheckpoint, delta: ?ExecutionCounts) !void {
|
||||
/// Like `writeRecoveryCheckpointDelta`, but names the tool images the host
|
||||
/// stored instead of writing their bytes.
|
||||
pub fn writeRecoveryCheckpointDeltaNamingImages(writer: *std.Io.Writer, checkpoint: RecoveryCheckpoint, from: ExecutionCounts, names: ImageNames) !void {
|
||||
return writeRecoveryCheckpointFrom(writer, checkpoint, from, names);
|
||||
}
|
||||
|
||||
fn writeRecoveryCheckpointFrom(writer: *std.Io.Writer, checkpoint: RecoveryCheckpoint, delta: ?ExecutionCounts, image_mode: anytype) !void {
|
||||
if (checkpoint.disposition == .history_only) return error.InvalidDurableField;
|
||||
if (delta) |from| {
|
||||
const execution = checkpoint.execution;
|
||||
@@ -1100,12 +1319,12 @@ fn writeRecoveryCheckpointFrom(writer: *std.Io.Writer, checkpoint: RecoveryCheck
|
||||
});
|
||||
if (delta == null) {
|
||||
try writer.writeAll(",\"user\":");
|
||||
try writeUserTurn(writer, checkpoint.user, {});
|
||||
try writeUserTurn(writer, checkpoint.user, image_mode);
|
||||
}
|
||||
try writer.writeAll(",\"assistant_source\":");
|
||||
try writeDurableBytes(writer, checkpoint.assistant_source);
|
||||
try writer.writeAll(",\"execution\":");
|
||||
try writeExecutionMemoryJsonFrom(writer, checkpoint.execution, .durable, delta orelse .{});
|
||||
try writeExecutionMemoryJsonFrom(writer, checkpoint.execution, .durable, delta orelse .{}, image_mode);
|
||||
try writer.writeAll(",\"cause\":");
|
||||
try writeJsonString(writer, @tagName(checkpoint.cause));
|
||||
try writer.writeAll(",\"action\":");
|
||||
@@ -1346,6 +1565,12 @@ fn decodeStateImpl(alloc: Allocator, source: *std.Io.Reader, limits: DecodeLimit
|
||||
}
|
||||
|
||||
pub fn parseRecoveryCheckpoint(alloc: Allocator, value: std.json.Value) !RecoveryCheckpoint {
|
||||
return parseRecoveryCheckpointWithImages(alloc, value, .{});
|
||||
}
|
||||
|
||||
/// Like `parseRecoveryCheckpoint`, but reads the image bytes a libfx journal
|
||||
/// keeps outside each image from `sources`.
|
||||
pub fn parseRecoveryCheckpointWithImages(alloc: Allocator, value: std.json.Value, sources: ImageSources) !RecoveryCheckpoint {
|
||||
const raw_object = try requireObject(value);
|
||||
const version = try requireU64(raw_object, "version");
|
||||
const legacy_route = raw_object.contains("route_identity");
|
||||
@@ -1384,11 +1609,11 @@ pub fn parseRecoveryCheckpoint(alloc: Allocator, value: std.json.Value) !Recover
|
||||
}),
|
||||
else => return error.InvalidDurableField,
|
||||
};
|
||||
const user = try parseUserTurn(alloc, object.get("user") orelse return error.InvalidSessionFormat, null);
|
||||
const user = try parseUserTurn(alloc, object.get("user") orelse return error.InvalidSessionFormat, sources);
|
||||
errdefer session.freeUserTurn(alloc, user);
|
||||
const assistant_source = try parseDurableBytes(alloc, object.get("assistant_source") orelse return error.InvalidSessionFormat);
|
||||
errdefer alloc.free(assistant_source);
|
||||
const execution = try parseExecutionMemory(alloc, object.get("execution") orelse return error.InvalidSessionFormat);
|
||||
const execution = try parseExecutionMemoryWith(alloc, object.get("execution") orelse return error.InvalidSessionFormat, sources);
|
||||
errdefer session.freeExecutionMemory(alloc, execution);
|
||||
const authority = if (legacy_route) TurnAuthority{
|
||||
.provider = .gateway,
|
||||
@@ -1544,10 +1769,15 @@ fn parseTurnAuthority(alloc: Allocator, value: std.json.Value) !TurnAuthority {
|
||||
}
|
||||
|
||||
fn writeUserTurn(writer: *std.Io.Writer, user: session.UserTurn, image_blobs: anytype) !void {
|
||||
const collect_blobs = comptime switch (@TypeOf(image_blobs)) {
|
||||
void => false,
|
||||
ImageBlobs => true,
|
||||
else => @compileError("image_blobs must be void or ImageBlobs"),
|
||||
const Mode = @TypeOf(image_blobs);
|
||||
comptime switch (Mode) {
|
||||
void, ImageBlobs, ImageNames => {},
|
||||
else => @compileError("image_blobs must be void, ImageBlobs or ImageNames"),
|
||||
};
|
||||
const blobs: ?ImageBlobs = switch (Mode) {
|
||||
ImageBlobs => image_blobs,
|
||||
ImageNames => image_blobs.blobs,
|
||||
else => null,
|
||||
};
|
||||
try writer.writeAll("{\"text\":");
|
||||
try writeDurableBytes(writer, user.text);
|
||||
@@ -1563,15 +1793,24 @@ fn writeUserTurn(writer: *std.Io.Writer, user: session.UserTurn, image_blobs: an
|
||||
try writer.writeAll(",\"snapshot_sha256\":");
|
||||
try writeOptionalDurableBytes(writer, image.snapshot_sha256);
|
||||
// Inline image bytes appear only for sessions without a filesystem
|
||||
// snapshot backend, so durable sessions keep their exact format.
|
||||
// snapshot backend, so durable sessions keep their exact format. An
|
||||
// image the host stored is named instead; its name is the digest,
|
||||
// and its size lets an open choose the images it keeps unread.
|
||||
if (image.inline_data) |inline_data| {
|
||||
if (collect_blobs) {
|
||||
try writer.print(",\"inline_blob\":{d}", .{image_blobs.items.items.len});
|
||||
try image_blobs.items.append(image_blobs.alloc, inline_data);
|
||||
if (Mode == ImageNames and image_blobs.stored.holds(image)) {
|
||||
try writer.writeAll(",\"inline_ref\":");
|
||||
try writeJsonString(writer, image.snapshot_sha256.?);
|
||||
try writer.print(",\"inline_size\":{d}", .{inline_data.len});
|
||||
} else if (blobs) |collected| {
|
||||
try writer.print(",\"inline_blob\":{d}", .{collected.items.items.len});
|
||||
try collected.items.append(collected.alloc, inline_data);
|
||||
} else {
|
||||
try writer.writeAll(",\"inline_data\":");
|
||||
try writeDurableBytes(writer, inline_data);
|
||||
}
|
||||
} else if (image.droppedFromSession()) {
|
||||
// An image the session dropped keeps only its digest.
|
||||
try writer.writeAll(",\"dropped\":true");
|
||||
}
|
||||
if (image.source_ref) |source_ref| {
|
||||
if (!image_data.validSourceRef(source_ref)) return error.InvalidSessionFormat;
|
||||
@@ -1608,16 +1847,19 @@ pub noinline fn writeExecutionMemoryJson(
|
||||
execution: session.ExecutionMemory,
|
||||
format: ExecutionMemoryJsonFormat,
|
||||
) !void {
|
||||
return writeExecutionMemoryJsonFrom(writer, execution, format, .{});
|
||||
return writeExecutionMemoryJsonFrom(writer, execution, format, .{}, {});
|
||||
}
|
||||
|
||||
/// `writeExecutionMemoryJson` with each list starting after `from` items.
|
||||
/// The caller checks that every list holds at least that many.
|
||||
/// The caller checks that every list holds at least that many. `image_mode`
|
||||
/// is as for `writeHistoryTurnTo`; only `ImageNames` changes how tool images
|
||||
/// are written.
|
||||
fn writeExecutionMemoryJsonFrom(
|
||||
writer: *std.Io.Writer,
|
||||
execution: session.ExecutionMemory,
|
||||
format: ExecutionMemoryJsonFormat,
|
||||
from: ExecutionCounts,
|
||||
image_mode: anytype,
|
||||
) !void {
|
||||
std.debug.assert(from.tool_steps <= execution.tool_steps.len and from.files <= execution.files.len and from.steering <= execution.steering.len);
|
||||
try writer.writeAll(if (format == .durable)
|
||||
@@ -1626,7 +1868,7 @@ fn writeExecutionMemoryJsonFrom(
|
||||
"{\"schema_version\":3,\"tool_steps\":[");
|
||||
for (execution.tool_steps[from.tool_steps..], 0..) |step, i| {
|
||||
if (i > 0) try writer.writeByte(',');
|
||||
try writeExecutionToolStep(writer, step, format);
|
||||
try writeExecutionToolStep(writer, step, format, image_mode);
|
||||
}
|
||||
try writer.writeAll("],\"files\":[");
|
||||
for (execution.files[from.files..], 0..) |file, i| {
|
||||
@@ -1651,7 +1893,7 @@ fn writeExecutionMemoryJsonFrom(
|
||||
try writer.writeByte('}');
|
||||
}
|
||||
|
||||
fn writeExecutionToolStep(writer: *std.Io.Writer, step: session.ToolExecutionStep, format: ExecutionMemoryJsonFormat) !void {
|
||||
fn writeExecutionToolStep(writer: *std.Io.Writer, step: session.ToolExecutionStep, format: ExecutionMemoryJsonFormat, image_mode: anytype) !void {
|
||||
try writer.writeAll("{\"assistant\":");
|
||||
try writeOptionalExecutionBytes(writer, step.assistant, format);
|
||||
if (format == .durable) {
|
||||
@@ -1666,7 +1908,7 @@ fn writeExecutionToolStep(writer: *std.Io.Writer, step: session.ToolExecutionSte
|
||||
try writer.writeAll("],\"tool_results\":[");
|
||||
for (step.tool_results, 0..) |result, result_index| {
|
||||
if (result_index > 0) try writer.writeByte(',');
|
||||
try writeExecutionToolResult(writer, result, format);
|
||||
try writeExecutionToolResult(writer, result, format, image_mode);
|
||||
}
|
||||
try writer.writeAll("]}");
|
||||
}
|
||||
@@ -1740,7 +1982,7 @@ pub fn executionHashes(alloc: Allocator, checkpoint: RecoveryCheckpoint) !Execut
|
||||
for (results) |*result| result.created_at_ms = 0;
|
||||
var stable = step;
|
||||
stable.tool_results = results;
|
||||
try writeExecutionToolStep(&out.writer, stable, .durable);
|
||||
try writeExecutionToolStep(&out.writer, stable, .durable, {});
|
||||
hash.* = std.hash.Wyhash.hash(0, out.written());
|
||||
}
|
||||
const files = try alloc.alloc(u64, execution.files.len);
|
||||
@@ -1824,6 +2066,7 @@ fn writeExecutionToolResult(
|
||||
writer: *std.Io.Writer,
|
||||
result: session.PersistedToolResult,
|
||||
format: ExecutionMemoryJsonFormat,
|
||||
image_mode: anytype,
|
||||
) !void {
|
||||
if (format == .durable and
|
||||
result.review_feedback and
|
||||
@@ -1891,7 +2134,7 @@ fn writeExecutionToolResult(
|
||||
try writeDurableBytes(writer, handle);
|
||||
} else if (result.tool_images.len > 0) {
|
||||
try writer.writeAll(",\"tool_images\":");
|
||||
writePersistedToolImages(writer, result.tool_images) catch |err| switch (err) {
|
||||
writeToolImagesTo(writer, result.tool_images, @TypeOf(image_mode) == ImageNames) catch |err| switch (err) {
|
||||
error.InvalidSourceRef, error.InvalidImage => return error.InvalidSessionFormat,
|
||||
else => |write_err| return write_err,
|
||||
};
|
||||
@@ -2095,7 +2338,7 @@ fn writeExecutionFileEvidence(
|
||||
try writer.writeByte('}');
|
||||
}
|
||||
|
||||
fn parseUserTurn(alloc: Allocator, value: std.json.Value, image_blobs: ?*ImageBlobReader) !session.UserTurn {
|
||||
fn parseUserTurn(alloc: Allocator, value: std.json.Value, sources: ImageSources) !session.UserTurn {
|
||||
const source = try requireObject(value);
|
||||
const object = if (source.count() == 2)
|
||||
try exactObject(value, &.{ "text", "images" })
|
||||
@@ -2137,7 +2380,8 @@ fn parseUserTurn(alloc: Allocator, value: std.json.Value, image_blobs: ?*ImageBl
|
||||
image.get("snapshot_sha256") orelse .null,
|
||||
);
|
||||
errdefer if (snapshot_sha256) |sha256_bytes| mem_utils.free(alloc, sha256_bytes);
|
||||
const inline_data = try parseInlineImageBytes(alloc, image, image_blobs);
|
||||
const inline_image = try parseInlineImageBytes(alloc, image, sources);
|
||||
const inline_data = inline_image.bytes;
|
||||
errdefer if (inline_data) |inline_bytes| mem_utils.free(alloc, inline_bytes);
|
||||
const source_ref = if (image.get("source_ref")) |_| source: {
|
||||
const source_bytes = try requireString(image, "source_ref");
|
||||
@@ -2145,7 +2389,22 @@ fn parseUserTurn(alloc: Allocator, value: std.json.Value, image_blobs: ?*ImageBl
|
||||
break :source try alloc.dupe(u8, source_bytes);
|
||||
} else null;
|
||||
errdefer if (source_ref) |source_bytes| alloc.free(source_bytes);
|
||||
if (inline_data) |inline_bytes| {
|
||||
const dropped = if (image.get("dropped")) |dropped_value| switch (dropped_value) {
|
||||
.bool => |flag| if (flag) true else return error.InvalidSessionFormat,
|
||||
else => return error.InvalidSessionFormat,
|
||||
} else false;
|
||||
if (dropped) {
|
||||
// An image the session dropped keeps only the digest naming it.
|
||||
if (inline_image.name != null or inline_data != null or snapshot_path != null) return error.InvalidSessionFormat;
|
||||
_ = imageName(snapshot_sha256 orelse return error.InvalidSessionFormat) orelse return error.InvalidSessionFormat;
|
||||
} else if (inline_image.name) |name| {
|
||||
// A named image is the host's stored copy of exactly the bytes
|
||||
// its digest names; with none staged, `sources` records it.
|
||||
if (snapshot_path != null) return error.InvalidSessionFormat;
|
||||
const digest = snapshot_sha256 orelse return error.InvalidSessionFormat;
|
||||
if (!std.mem.eql(u8, digest, &name)) return error.InvalidSessionFormat;
|
||||
if (inline_data) |inline_bytes| try validateInlineImageBytes(inline_bytes, media_type);
|
||||
} else if (inline_data) |inline_bytes| {
|
||||
// Inline images carry their own bytes: no snapshot file, but the
|
||||
// digest stays so request-time verification is unchanged.
|
||||
if (snapshot_path != null or snapshot_sha256 == null) return error.InvalidSessionFormat;
|
||||
@@ -2165,19 +2424,43 @@ fn parseUserTurn(alloc: Allocator, value: std.json.Value, image_blobs: ?*ImageBl
|
||||
return .{ .text = text, .images = images, .work_id = work_id };
|
||||
}
|
||||
|
||||
/// Reads an inline image's bytes from `inline_data` or, inside a libfx kernel
|
||||
/// checkpoint, from the raw blob named by `inline_blob`. Caller owns the result.
|
||||
/// An image's bytes as a parse found them, and its name when the object
|
||||
/// named it. A named image with no bytes was not staged.
|
||||
const InlineImage = struct {
|
||||
bytes: ?[]u8 = null,
|
||||
name: ?ImageName = null,
|
||||
};
|
||||
|
||||
/// Reads an inline image's bytes from `inline_data`, from the raw blob of a
|
||||
/// libfx kernel checkpoint named by `inline_blob`, or from the host's staged
|
||||
/// copy named by `inline_ref`. Caller owns the bytes.
|
||||
fn parseInlineImageBytes(
|
||||
alloc: Allocator,
|
||||
image: std.json.ObjectMap,
|
||||
image_blobs: ?*ImageBlobReader,
|
||||
) !?[]u8 {
|
||||
sources: ImageSources,
|
||||
) !InlineImage {
|
||||
if (image.get("inline_ref")) |ref_value| {
|
||||
const resolver = sources.names orelse return error.InvalidSessionFormat;
|
||||
if (image.get("inline_data") != null or image.get("inline_blob") != null or ref_value != .string) {
|
||||
return error.InvalidSessionFormat;
|
||||
}
|
||||
const name = imageName(ref_value.string) orelse return error.InvalidSessionFormat;
|
||||
const size = try requireUsize(image, "inline_size");
|
||||
if (size == 0 or size > image_attachments.max_image_bytes) return error.InvalidSessionFormat;
|
||||
const bytes = try resolver.resolve(alloc, name, size);
|
||||
if (bytes) |staged| if (staged.len != size) {
|
||||
alloc.free(staged);
|
||||
return error.InvalidSessionFormat;
|
||||
};
|
||||
return .{ .bytes = bytes, .name = name };
|
||||
}
|
||||
if (image.get("inline_size") != null) return error.InvalidSessionFormat;
|
||||
const blob_value = image.get("inline_blob") orelse
|
||||
return parseOptionalDurableBytes(alloc, image.get("inline_data") orelse .null);
|
||||
const blobs = image_blobs orelse return error.InvalidSessionFormat;
|
||||
return .{ .bytes = try parseOptionalDurableBytes(alloc, image.get("inline_data") orelse .null) };
|
||||
const blobs = sources.blobs orelse return error.InvalidSessionFormat;
|
||||
if (image.get("inline_data") != null or blob_value != .integer) return error.InvalidSessionFormat;
|
||||
const index = std.math.cast(usize, blob_value.integer) orelse return error.InvalidSessionFormat;
|
||||
return try alloc.dupe(u8, try blobs.take(index));
|
||||
return .{ .bytes = try alloc.dupe(u8, try blobs.take(index)) };
|
||||
}
|
||||
|
||||
fn imageAttachmentObject(value: std.json.Value) !std.json.ObjectMap {
|
||||
@@ -2197,6 +2480,9 @@ fn imageAttachmentObject(value: std.json.Value) !std.json.ObjectMap {
|
||||
std.mem.eql(u8, entry.key_ptr.*, "snapshot_sha256") or
|
||||
std.mem.eql(u8, entry.key_ptr.*, "inline_data") or
|
||||
std.mem.eql(u8, entry.key_ptr.*, "inline_blob") or
|
||||
std.mem.eql(u8, entry.key_ptr.*, "inline_ref") or
|
||||
std.mem.eql(u8, entry.key_ptr.*, "inline_size") or
|
||||
std.mem.eql(u8, entry.key_ptr.*, "dropped") or
|
||||
std.mem.eql(u8, entry.key_ptr.*, "source_ref"))
|
||||
{
|
||||
continue;
|
||||
@@ -2222,6 +2508,11 @@ fn validateInlineImageBytes(bytes: []const u8, media_type: []const u8) !void {
|
||||
}
|
||||
|
||||
fn parseExecutionMemory(alloc: Allocator, value: std.json.Value) !session.ExecutionMemory {
|
||||
return parseExecutionMemoryWith(alloc, value, .{});
|
||||
}
|
||||
|
||||
/// `parseExecutionMemory`, reading named tool images from `sources`.
|
||||
fn parseExecutionMemoryWith(alloc: Allocator, value: std.json.Value, sources: ImageSources) !session.ExecutionMemory {
|
||||
const source = try requireObject(value);
|
||||
const schema_version = try requireU64(source, "schema_version");
|
||||
const object = switch (schema_version) {
|
||||
@@ -2235,6 +2526,7 @@ fn parseExecutionMemory(alloc: Allocator, value: std.json.Value) !session.Execut
|
||||
alloc,
|
||||
object.get("tool_steps") orelse return error.InvalidSessionFormat,
|
||||
schema_version,
|
||||
sources,
|
||||
);
|
||||
errdefer session.freeExecutionMemory(alloc, .{ .tool_steps = tool_steps });
|
||||
const files = try parseFiles(alloc, object.get("files") orelse return error.InvalidSessionFormat);
|
||||
@@ -2352,6 +2644,7 @@ fn parseToolSteps(
|
||||
alloc: Allocator,
|
||||
value: std.json.Value,
|
||||
schema_version: u64,
|
||||
sources: ImageSources,
|
||||
) ![]session.ToolExecutionStep {
|
||||
if (value != .array) return error.InvalidSessionFormat;
|
||||
if (value.array.items.len == 0) return &.{};
|
||||
@@ -2377,6 +2670,7 @@ fn parseToolSteps(
|
||||
alloc,
|
||||
object.get("tool_results") orelse return error.InvalidSessionFormat,
|
||||
schema_version,
|
||||
sources,
|
||||
);
|
||||
errdefer session.freePersistedToolResults(alloc, tool_results);
|
||||
try session.repairPersistedToolArguments(alloc, tool_calls, tool_results, .schema_v3);
|
||||
@@ -2480,6 +2774,7 @@ fn parseToolResults(
|
||||
alloc: Allocator,
|
||||
value: std.json.Value,
|
||||
schema_version: u64,
|
||||
sources: ImageSources,
|
||||
) ![]session.PersistedToolResult {
|
||||
if (value != .array) return error.InvalidSessionFormat;
|
||||
if (value.array.items.len == 0) return &.{};
|
||||
@@ -2503,7 +2798,7 @@ fn parseToolResults(
|
||||
}
|
||||
};
|
||||
for (value.array.items, 0..) |result_value, i| {
|
||||
results[i] = try parseToolResult(alloc, result_value, schema_version);
|
||||
results[i] = try parseToolResultWith(alloc, result_value, schema_version, sources);
|
||||
parsed_count += 1;
|
||||
}
|
||||
return results;
|
||||
@@ -2513,6 +2808,15 @@ fn parseToolResult(
|
||||
alloc: Allocator,
|
||||
value: std.json.Value,
|
||||
schema_version: u64,
|
||||
) !session.PersistedToolResult {
|
||||
return parseToolResultWith(alloc, value, schema_version, .{});
|
||||
}
|
||||
|
||||
fn parseToolResultWith(
|
||||
alloc: Allocator,
|
||||
value: std.json.Value,
|
||||
schema_version: u64,
|
||||
sources: ImageSources,
|
||||
) !session.PersistedToolResult {
|
||||
const v1_keys = &.{
|
||||
"tool_call_id",
|
||||
@@ -2630,7 +2934,7 @@ fn parseToolResult(
|
||||
result_shape.extended,
|
||||
);
|
||||
errdefer presentations.deinit(alloc);
|
||||
var images = try parseToolResultImages(alloc, object, &text.output);
|
||||
var images = try parseToolResultImages(alloc, object, &text.output, sources);
|
||||
errdefer images.deinit(alloc);
|
||||
return .{
|
||||
.tool_images = images.items,
|
||||
@@ -2775,16 +3079,41 @@ fn parseToolResultPresentations(
|
||||
|
||||
/// Writes typed image blocks with persistence-owned source_ref spelling.
|
||||
pub fn writePersistedToolImages(writer: *std.Io.Writer, images: []const types.ToolImage) !void {
|
||||
return writeToolImagesTo(writer, images, false);
|
||||
}
|
||||
|
||||
/// `writePersistedToolImages`, but with `naming`, an image the host stored is
|
||||
/// written as its name (`inline_ref`) and decoded size instead of its bytes.
|
||||
/// An image the session dropped is written as its name alone either way.
|
||||
/// Writes a tool result's `images` as a list: with `naming`, each the host
|
||||
/// stored by its name, and every other with its bytes; one the session
|
||||
/// dropped by its name alone. `parseToolImages` reads it back.
|
||||
pub fn writeToolImages(writer: *std.Io.Writer, images: []const types.ToolImage, naming: bool) !void {
|
||||
return writeToolImagesTo(writer, images, naming);
|
||||
}
|
||||
|
||||
fn writeToolImagesTo(writer: *std.Io.Writer, images: []const types.ToolImage, naming: bool) !void {
|
||||
try writer.writeByte('[');
|
||||
for (images, 0..) |image, index| {
|
||||
if (index > 0) try writer.writeByte(',');
|
||||
if (image.source_ref) |source_ref| {
|
||||
if (!image_data.validSourceRef(source_ref)) return error.InvalidSourceRef;
|
||||
} else if (image.data.len == 0) return error.InvalidImage;
|
||||
} else if (image.data.len == 0 and image.name == null) return error.InvalidImage;
|
||||
try writer.writeAll("{\"type\":\"image\",\"mimeType\":");
|
||||
try writeJsonString(writer, image.mime_type);
|
||||
try writer.writeAll(",\"data\":");
|
||||
try writeJsonString(writer, image.data);
|
||||
if (image.droppedFromSession()) {
|
||||
try writer.writeAll(",\"inline_ref\":");
|
||||
try writeJsonString(writer, &image.name.?);
|
||||
try writer.writeAll(",\"dropped\":true");
|
||||
} else if (naming and image.name != null) {
|
||||
const size = std.base64.standard.Decoder.calcSizeForSlice(image.data) catch return error.InvalidImage;
|
||||
try writer.writeAll(",\"inline_ref\":");
|
||||
try writeJsonString(writer, &image.name.?);
|
||||
try writer.print(",\"inline_size\":{d}", .{size});
|
||||
} else {
|
||||
try writer.writeAll(",\"data\":");
|
||||
try writeJsonString(writer, image.data);
|
||||
}
|
||||
if (image.source_ref) |source_ref| {
|
||||
try writer.writeAll(",\"source_ref\":");
|
||||
try writeJsonString(writer, source_ref);
|
||||
@@ -2825,6 +3154,106 @@ pub fn parsePersistedToolImages(alloc: Allocator, content: []const std.json.Valu
|
||||
return image_data.parseToolImages(alloc, normalized[0..initialized]);
|
||||
}
|
||||
|
||||
/// `parsePersistedToolImages`, but an image written by name (`inline_ref`)
|
||||
/// reads its bytes from the host's staged copy in `sources.names`. One the
|
||||
/// session dropped, or one an open has yet to stage, parses with its name and
|
||||
/// without bytes; the open's `missing` tells the two apart. Caller owns the
|
||||
/// images.
|
||||
/// Reads a list `writeToolImages` wrote. Caller owns the images. A name
|
||||
/// `sources.names` has no bytes for reads as an image without bytes, and the
|
||||
/// resolver records it as missing.
|
||||
pub fn parseToolImages(alloc: Allocator, content: []const std.json.Value, sources: ImageSources) ![]types.ToolImage {
|
||||
return parseToolImagesWith(alloc, content, sources);
|
||||
}
|
||||
|
||||
fn parseToolImagesWith(alloc: Allocator, content: []const std.json.Value, sources: ImageSources) ![]types.ToolImage {
|
||||
for (content) |item| {
|
||||
if (item == .object and item.object.contains("inline_ref")) break;
|
||||
} else return parsePersistedToolImages(alloc, content);
|
||||
if (content.len > image_data.max_tool_images) return error.ImageLimitExceeded;
|
||||
var images: std.ArrayList(types.ToolImage) = .empty;
|
||||
defer {
|
||||
for (images.items) |image| freeToolImage(alloc, image);
|
||||
images.deinit(alloc);
|
||||
}
|
||||
for (content) |item| {
|
||||
if (item != .object) return error.InvalidImage;
|
||||
if (!item.object.contains("inline_ref")) {
|
||||
// An image written inline keeps its own admission; an unsupported
|
||||
// one parses as none, which the caller notes.
|
||||
const inline_images = try parsePersistedToolImages(alloc, &[_]std.json.Value{item});
|
||||
images.ensureUnusedCapacity(alloc, inline_images.len) catch |err| {
|
||||
types.freeToolImages(alloc, inline_images);
|
||||
return err;
|
||||
};
|
||||
images.appendSliceAssumeCapacity(inline_images);
|
||||
alloc.free(inline_images);
|
||||
continue;
|
||||
}
|
||||
const image = try parseNamedToolImage(alloc, item.object, sources);
|
||||
images.append(alloc, image) catch |err| {
|
||||
freeToolImage(alloc, image);
|
||||
return err;
|
||||
};
|
||||
}
|
||||
return images.toOwnedSlice(alloc);
|
||||
}
|
||||
|
||||
fn parseNamedToolImage(alloc: Allocator, object: std.json.ObjectMap, sources: ImageSources) !types.ToolImage {
|
||||
var keys = object.iterator();
|
||||
while (keys.next()) |entry| {
|
||||
for ([_][]const u8{ "type", "mimeType", "inline_ref", "inline_size", "dropped", "source_ref" }) |known| {
|
||||
if (std.mem.eql(u8, entry.key_ptr.*, known)) break;
|
||||
} else return error.InvalidSessionFormat;
|
||||
}
|
||||
if (!std.mem.eql(u8, try requireString(object, "type"), "image")) return error.InvalidSessionFormat;
|
||||
const mime_type = try requireString(object, "mimeType");
|
||||
if (!image_data.supportedMediaType(mime_type)) return error.InvalidSessionFormat;
|
||||
const name = imageName(try requireString(object, "inline_ref")) orelse return error.InvalidSessionFormat;
|
||||
const source_ref: ?[]const u8 = if (object.get("source_ref")) |value| ref: {
|
||||
if (value != .string or !image_data.validSourceRef(value.string)) return error.InvalidSourceRef;
|
||||
break :ref value.string;
|
||||
} else null;
|
||||
if (object.get("dropped")) |value| {
|
||||
if (value != .bool or !value.bool or object.contains("inline_size")) return error.InvalidSessionFormat;
|
||||
return ownedToolImage(alloc, try alloc.dupe(u8, ""), mime_type, source_ref, name);
|
||||
}
|
||||
const resolver = sources.names orelse return error.InvalidSessionFormat;
|
||||
const size = try requireUsize(object, "inline_size");
|
||||
if (size == 0 or size > image_data.max_encoded_image_bytes) return error.InvalidSessionFormat;
|
||||
const resolved = try resolver.resolve(alloc, name, size);
|
||||
const bytes = resolved orelse return ownedToolImage(alloc, try alloc.dupe(u8, ""), mime_type, source_ref, name);
|
||||
defer alloc.free(bytes);
|
||||
if (bytes.len != size) return error.InvalidSessionFormat;
|
||||
const encoded = try alloc.alloc(u8, std.base64.standard.Encoder.calcSize(bytes.len));
|
||||
_ = std.base64.standard.Encoder.encode(encoded, bytes);
|
||||
// The same admission as an image written inline.
|
||||
image_data.validateImage(alloc, encoded, mime_type) catch |err| {
|
||||
alloc.free(encoded);
|
||||
return err;
|
||||
};
|
||||
return ownedToolImage(alloc, encoded, mime_type, source_ref, name);
|
||||
}
|
||||
|
||||
/// A tool image owning `data`, which it takes, and copies of the rest.
|
||||
fn ownedToolImage(alloc: Allocator, data: []u8, mime_type: []const u8, source_ref: ?[]const u8, name: ImageName) Allocator.Error!types.ToolImage {
|
||||
errdefer alloc.free(data);
|
||||
const owned_type = try alloc.dupe(u8, mime_type);
|
||||
errdefer alloc.free(owned_type);
|
||||
return .{
|
||||
.data = data,
|
||||
.mime_type = owned_type,
|
||||
.source_ref = if (source_ref) |value| try alloc.dupe(u8, value) else null,
|
||||
.name = name,
|
||||
};
|
||||
}
|
||||
|
||||
fn freeToolImage(alloc: Allocator, image: types.ToolImage) void {
|
||||
alloc.free(image.data);
|
||||
alloc.free(image.mime_type);
|
||||
if (image.source_ref) |source_ref| alloc.free(source_ref);
|
||||
}
|
||||
|
||||
const ParsedToolResultImages = struct {
|
||||
handle: ?[]u8,
|
||||
items: []types.ToolImage,
|
||||
@@ -2840,6 +3269,7 @@ fn parseToolResultImages(
|
||||
alloc: Allocator,
|
||||
object: std.json.ObjectMap,
|
||||
output: *[]u8,
|
||||
sources: ImageSources,
|
||||
) !ParsedToolResultImages {
|
||||
const handle = if (object.get("tool_image_handle")) |value|
|
||||
try parseOptionalDurableBytes(alloc, value)
|
||||
@@ -2848,9 +3278,11 @@ fn parseToolResultImages(
|
||||
errdefer if (handle) |owned| mem_utils.free(alloc, owned);
|
||||
const items = if (object.get("tool_images")) |images| items: {
|
||||
if (images != .array) return error.InvalidSessionFormat;
|
||||
const parsed = parsePersistedToolImages(alloc, images.array.items) catch |err| {
|
||||
const parsed = parseToolImagesWith(alloc, images.array.items, sources) catch |err| {
|
||||
if (err == error.OutOfMemory) return error.OutOfMemory;
|
||||
if (err == error.InvalidSourceRef) return error.InvalidSessionFormat;
|
||||
// A named image that cannot be read is a format error, not an
|
||||
// image to leave out: the record says the store holds it.
|
||||
if (err == error.InvalidSourceRef or err == error.InvalidSessionFormat) return error.InvalidSessionFormat;
|
||||
const notice = try alloc.print(
|
||||
"{s}\n[Saved tool image unavailable: {s}]",
|
||||
.{ output.*, @errorName(err) },
|
||||
@@ -3552,7 +3984,7 @@ test "durable tool image validation preserves the session format error contract"
|
||||
.provider_native = false,
|
||||
.created_at_ms = 0,
|
||||
.tool_images = &images,
|
||||
}, .durable));
|
||||
}, .durable, {}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4054,7 +4486,7 @@ test "review feedback durable codec rejects invalid provenance and unknown field
|
||||
result.review_feedback = true;
|
||||
var encoded: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer encoded.deinit();
|
||||
try writeExecutionToolResult(&encoded.writer, result, .durable);
|
||||
try writeExecutionToolResult(&encoded.writer, result, .durable, {});
|
||||
var parsed = try std.json.parseFromSlice(std.json.Value, alloc, encoded.written(), .{});
|
||||
defer parsed.deinit();
|
||||
const cases = [_]struct { status: []const u8, provider_native: bool, marker: std.json.Value }{
|
||||
@@ -4080,7 +4512,7 @@ test "review feedback durable codec rejects invalid provenance and unknown field
|
||||
invalid.provider_native = native;
|
||||
try std.testing.expectError(
|
||||
error.InvalidSessionFormat,
|
||||
writeExecutionToolResult(&encoded.writer, invalid, .durable),
|
||||
writeExecutionToolResult(&encoded.writer, invalid, .durable, {}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4092,7 +4524,7 @@ test "review feedback defaults old durable results without inspecting output" {
|
||||
result.output = @constCast("Security review held this action.");
|
||||
var encoded: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer encoded.deinit();
|
||||
try writeExecutionToolResult(&encoded.writer, result, .durable);
|
||||
try writeExecutionToolResult(&encoded.writer, result, .durable, {});
|
||||
var parsed = try std.json.parseFromSlice(std.json.Value, alloc, encoded.written(), .{});
|
||||
defer parsed.deinit();
|
||||
try std.testing.expect(parsed.value.object.swapRemove("review_feedback"));
|
||||
@@ -5088,6 +5520,42 @@ test "inline image bytes round trip through the history turn codec" {
|
||||
}
|
||||
}
|
||||
|
||||
test "the image window keeps the newest images that fit, and drops every older one" {
|
||||
const window: ImageWindow = .{ .max_images = 3, .max_bytes = 100 };
|
||||
try std.testing.expectEqual(@as(usize, 0), window.oldestOutside(&.{}));
|
||||
try std.testing.expectEqual(@as(usize, 0), window.oldestOutside(&.{ 10, 20, 30 }));
|
||||
// Three images at most: the oldest of four leaves.
|
||||
try std.testing.expectEqual(@as(usize, 1), window.oldestOutside(&.{ 10, 20, 30, 40 }));
|
||||
// 100 bytes at most: 60 does not fit beside 40 and 30, so it and the
|
||||
// 5 before it leave, though 5 alone would fit.
|
||||
try std.testing.expectEqual(@as(usize, 2), window.oldestOutside(&.{ 5, 60, 30, 40 }));
|
||||
// An image already dropped keeps every older one out.
|
||||
try std.testing.expectEqual(@as(usize, 2), window.oldestOutside(&.{ 10, null, 30, 40 }));
|
||||
// An image larger than the whole window leaves too.
|
||||
try std.testing.expectEqual(@as(usize, 1), window.oldestOutside(&.{101}));
|
||||
}
|
||||
|
||||
test "a dropped image parses only as a name without bytes" {
|
||||
const alloc = std.testing.allocator;
|
||||
const digest = "\"" ++ text_utils.repeat("a", image_name_len) ++ "\"";
|
||||
const prefix = "{\"text\":\"[Image #1]\",\"images\":[{\"id\":1,\"path\":\"inline://image-1\",\"media_type\":\"image/png\",\"snapshot_path\":null,\"snapshot_sha256\":";
|
||||
var parsed = try std.json.parseFromSlice(std.json.Value, alloc, prefix ++ digest ++ ",\"dropped\":true}]}", .{});
|
||||
defer parsed.deinit();
|
||||
const user = try parseUserTurn(alloc, parsed.value, .{});
|
||||
defer types.freeUserTurn(alloc, user);
|
||||
try std.testing.expect(user.images[0].droppedFromSession());
|
||||
for ([_][]const u8{
|
||||
prefix ++ digest ++ ",\"dropped\":false}]}",
|
||||
prefix ++ "\"not-a-digest\",\"dropped\":true}]}",
|
||||
prefix ++ "null,\"dropped\":true}]}",
|
||||
prefix ++ digest ++ ",\"dropped\":true,\"inline_data\":\"iVBORw0KGgo=\"}]}",
|
||||
}) |json| {
|
||||
var invalid = try std.json.parseFromSlice(std.json.Value, alloc, json, .{});
|
||||
defer invalid.deinit();
|
||||
try std.testing.expectError(error.InvalidSessionFormat, parseUserTurn(alloc, invalid.value, .{}));
|
||||
}
|
||||
}
|
||||
|
||||
test "inline image parsing rejects missing digest and mismatched bytes" {
|
||||
const alloc = std.testing.allocator;
|
||||
const png = "\x89PNG\r\n\x1a\ninline-bytes";
|
||||
@@ -5646,13 +6114,13 @@ test "persisted image source refs reject malformed metadata and preserve the byt
|
||||
for (invalid) |value| {
|
||||
user_ref.* = value;
|
||||
tool_ref.* = value;
|
||||
try std.testing.expectError(error.InvalidSessionFormat, parseUserTurn(alloc, user.value, null));
|
||||
try std.testing.expectError(error.InvalidSessionFormat, parseUserTurn(alloc, user.value, .{}));
|
||||
try std.testing.expectError(error.InvalidSourceRef, parsePersistedToolImages(alloc, tools.value.array.items));
|
||||
}
|
||||
const boundary = text_utils.repeat("é", 256);
|
||||
user_ref.* = .{ .string = boundary };
|
||||
tool_ref.* = .{ .string = boundary };
|
||||
const decoded_user = try parseUserTurn(alloc, user.value, null);
|
||||
const decoded_user = try parseUserTurn(alloc, user.value, .{});
|
||||
defer session.freeUserTurn(alloc, decoded_user);
|
||||
const decoded_tools = try parsePersistedToolImages(alloc, tools.value.array.items);
|
||||
defer types.freeToolImages(alloc, decoded_tools);
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
//! Local wall-clock readings of timestamps, for text a person reads. fx
|
||||
//! stores and serializes times in UTC; display reads them in the user's time
|
||||
//! zone, from the C library's rules for `TZ` or the system default.
|
||||
|
||||
const builtin = @import("builtin");
|
||||
const std = @import("std");
|
||||
|
||||
/// 9999-12-31 23:59:59.999 UTC, the last time a four-digit year can show.
|
||||
pub const max_timestamp_ms: i64 = 253_402_300_799_999;
|
||||
|
||||
/// Longest zone abbreviation shown, such as `PDT` or `+0545`.
|
||||
const max_zone_name_len = 10;
|
||||
|
||||
/// A fixed offset from UTC and its abbreviation.
|
||||
pub const Zone = struct {
|
||||
/// Seconds east of UTC.
|
||||
offset_s: i32,
|
||||
/// The abbreviation, such as `PDT`.
|
||||
name: []const u8,
|
||||
|
||||
pub const utc: Zone = .{ .offset_s = 0, .name = "UTC" };
|
||||
};
|
||||
|
||||
/// The zone test builds read local time in, in place of the machine's, so
|
||||
/// rendered text is the same wherever tests run. A test that changes it
|
||||
/// restores `Zone.utc` before it returns.
|
||||
pub var test_zone: if (builtin.is_test) Zone else void = if (builtin.is_test) Zone.utc else {};
|
||||
|
||||
/// A timestamp's calendar date and clock time in one zone.
|
||||
pub const Reading = struct {
|
||||
year: std.time.epoch.Year,
|
||||
month: std.time.epoch.Month,
|
||||
/// Day of the month, from 1.
|
||||
day: u5,
|
||||
hour: u5,
|
||||
minute: u6,
|
||||
second: u6,
|
||||
millisecond: u10,
|
||||
zone_name_buf: [max_zone_name_len]u8,
|
||||
zone_name_len: u8,
|
||||
|
||||
/// The zone's abbreviation, borrowed from this reading.
|
||||
pub fn zoneName(self: *const Reading) []const u8 {
|
||||
return self.zone_name_buf[0..self.zone_name_len];
|
||||
}
|
||||
|
||||
/// `2026-10-16 13:31:49.123 PDT`.
|
||||
pub fn format(self: Reading, writer: *std.Io.Writer) std.Io.Writer.Error!void {
|
||||
try writer.print("{d:0>4}-{d:0>2}-{d:0>2} {d:0>2}:{d:0>2}:{d:0>2}.{d:0>3} {s}", .{
|
||||
self.year,
|
||||
self.month.numeric(),
|
||||
self.day,
|
||||
self.hour,
|
||||
self.minute,
|
||||
self.second,
|
||||
self.millisecond,
|
||||
self.zoneName(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/// Reads `timestamp_ms` in the local time zone in effect at that moment, so
|
||||
/// daylight saving time follows each timestamp's own date. Returns null
|
||||
/// outside 1970 through 9999.
|
||||
pub fn local(timestamp_ms: i64) ?Reading {
|
||||
if (timestamp_ms < 0 or timestamp_ms > max_timestamp_ms) return null;
|
||||
return inZone(timestamp_ms, localZoneAt(@divFloor(timestamp_ms, std.time.ms_per_s)));
|
||||
}
|
||||
|
||||
/// Reads `timestamp_ms` in `requested`, or in UTC when that zone's offset is
|
||||
/// a day or more or the shifted time would leave the supported range.
|
||||
/// Returns null outside 1970 through 9999.
|
||||
fn inZone(timestamp_ms: i64, requested: Zone) ?Reading {
|
||||
if (timestamp_ms < 0 or timestamp_ms > max_timestamp_ms) return null;
|
||||
var zone = requested;
|
||||
var local_ms = timestamp_ms + @as(i64, zone.offset_s) * std.time.ms_per_s;
|
||||
if (@abs(zone.offset_s) >= std.time.s_per_day or local_ms < 0 or local_ms > max_timestamp_ms) {
|
||||
zone = .utc;
|
||||
local_ms = timestamp_ms;
|
||||
}
|
||||
const epoch_seconds: std.time.epoch.EpochSeconds = .{
|
||||
.secs = @intCast(@divFloor(local_ms, std.time.ms_per_s)),
|
||||
};
|
||||
const day = epoch_seconds.getDaySeconds();
|
||||
const year_day = epoch_seconds.getEpochDay().calculateYearDay();
|
||||
const month_day = year_day.calculateMonthDay();
|
||||
var reading: Reading = .{
|
||||
.year = year_day.year,
|
||||
.month = month_day.month,
|
||||
.day = month_day.day_index + 1,
|
||||
.hour = day.getHoursIntoDay(),
|
||||
.minute = day.getMinutesIntoHour(),
|
||||
.second = day.getSecondsIntoMinute(),
|
||||
.millisecond = @intCast(@mod(local_ms, std.time.ms_per_s)),
|
||||
.zone_name_buf = undefined,
|
||||
.zone_name_len = 0,
|
||||
};
|
||||
reading.zone_name_len = @intCast(writeZoneName(zone, &reading.zone_name_buf).len);
|
||||
return reading;
|
||||
}
|
||||
|
||||
/// Writes `zone`'s abbreviation into `buf` when it is a short plain token,
|
||||
/// and otherwise its offset in the `+HHMM` form tzdata uses for zones
|
||||
/// without a name. `TZ` comes from the environment, so its abbreviation never
|
||||
/// reaches the terminal unchecked. `zone.offset_s` is under a day.
|
||||
fn writeZoneName(zone: Zone, buf: *[max_zone_name_len]u8) []const u8 {
|
||||
if (isPlainZoneName(zone.name)) {
|
||||
@memcpy(buf[0..zone.name.len], zone.name);
|
||||
return buf[0..zone.name.len];
|
||||
}
|
||||
const minutes = @abs(zone.offset_s) / std.time.s_per_min;
|
||||
buf[0] = if (zone.offset_s < 0) '-' else '+';
|
||||
buf[1..3].* = std.fmt.digits2(@intCast(minutes / 60));
|
||||
buf[3..5].* = std.fmt.digits2(@intCast(minutes % 60));
|
||||
return buf[0..5];
|
||||
}
|
||||
|
||||
fn isPlainZoneName(name: []const u8) bool {
|
||||
if (name.len == 0 or name.len > max_zone_name_len) return false;
|
||||
for (name) |byte| {
|
||||
if (!std.ascii.isAlphanumeric(byte) and byte != '+' and byte != '-') return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// C `struct tm` as macOS, glibc, and musl lay it out. wasi-libc adds a
|
||||
/// trailing field, so WASI builds never pass this to the C library.
|
||||
const Tm = extern struct {
|
||||
sec: c_int,
|
||||
min: c_int,
|
||||
hour: c_int,
|
||||
mday: c_int,
|
||||
mon: c_int,
|
||||
year: c_int,
|
||||
wday: c_int,
|
||||
yday: c_int,
|
||||
isdst: c_int,
|
||||
gmtoff: c_long,
|
||||
zone: ?[*:0]const u8,
|
||||
};
|
||||
|
||||
extern "c" fn localtime_r(timer: *const std.c.time_t, result: *Tm) ?*Tm;
|
||||
|
||||
/// The local zone in effect at `epoch_s`, or UTC when the C library cannot
|
||||
/// convert the time. WASI has no time zone data, so it reads UTC. The name
|
||||
/// borrows C library storage, so `local` copies it into its reading straight
|
||||
/// away.
|
||||
fn localZoneAt(epoch_s: i64) Zone {
|
||||
if (builtin.is_test) return test_zone;
|
||||
if (!builtin.link_libc or builtin.target.os.tag == .wasi) return .utc;
|
||||
const timer = std.math.cast(std.c.time_t, epoch_s) orelse return .utc;
|
||||
var tm: Tm = undefined;
|
||||
if (localtime_r(&timer, &tm) == null) return .utc;
|
||||
const offset_s = std.math.cast(i32, tm.gmtoff) orelse return .utc;
|
||||
return .{
|
||||
.offset_s = offset_s,
|
||||
.name = if (tm.zone) |name| std.mem.span(name) else "",
|
||||
};
|
||||
}
|
||||
|
||||
const ist: Zone = .{ .offset_s = 5 * 3600 + 30 * 60, .name = "IST" };
|
||||
|
||||
fn expectReading(expected: []const u8, reading: ?Reading) !void {
|
||||
var buf: [64]u8 = undefined;
|
||||
try std.testing.expectEqualStrings(expected, try std.mem.print(&buf, "{f}", .{reading.?}));
|
||||
}
|
||||
|
||||
test "local time reads a timestamp in the given zone" {
|
||||
try expectReading("2024-01-02 03:04:05.006 UTC", inZone(1_704_164_645_006, .utc));
|
||||
// East of UTC rolls the clock forward.
|
||||
try expectReading("2024-01-02 08:34:05.006 IST", inZone(1_704_164_645_006, ist));
|
||||
// West of UTC crosses back into the previous day and year.
|
||||
try expectReading(
|
||||
"2023-12-31 22:04:05.006 EST",
|
||||
inZone(1_704_078_245_006, .{ .offset_s = -5 * 3600, .name = "EST" }),
|
||||
);
|
||||
}
|
||||
|
||||
test "local time falls back to UTC when the zone cannot apply" {
|
||||
// Shifting back from the epoch would leave the calendar range.
|
||||
try expectReading("1970-01-01 00:00:00.002 UTC", inZone(2, .{ .offset_s = -7 * 3600, .name = "PDT" }));
|
||||
try expectReading(
|
||||
"2024-01-02 03:04:05.006 UTC",
|
||||
inZone(1_704_164_645_006, .{ .offset_s = std.time.s_per_day, .name = "BAD" }),
|
||||
);
|
||||
try std.testing.expect(inZone(-1, .utc) == null);
|
||||
try std.testing.expect(inZone(max_timestamp_ms + 1, .utc) == null);
|
||||
try std.testing.expect(local(-1) == null);
|
||||
try std.testing.expect(local(max_timestamp_ms + 1) == null);
|
||||
}
|
||||
|
||||
test "local time shows a zone without a plain name as its offset" {
|
||||
try expectReading("2024-01-02 08:49:05.006 +0545", inZone(1_704_164_645_006, .{
|
||||
.offset_s = 5 * 3600 + 45 * 60,
|
||||
.name = "+0545",
|
||||
}));
|
||||
try expectReading("2024-01-02 08:34:05.006 +0530", inZone(1_704_164_645_006, .{
|
||||
.offset_s = ist.offset_s,
|
||||
.name = "",
|
||||
}));
|
||||
try expectReading("2024-01-01 17:34:05.006 -0930", inZone(1_704_164_645_006, .{
|
||||
.offset_s = -(9 * 3600 + 30 * 60),
|
||||
.name = "\x1b[2J",
|
||||
}));
|
||||
try expectReading("2024-01-02 03:04:05.006 +0000", inZone(1_704_164_645_006, .{
|
||||
.offset_s = 0,
|
||||
.name = "ABCDEFGHIJK",
|
||||
}));
|
||||
}
|
||||
|
||||
test "local time follows the test zone" {
|
||||
try expectReading("2024-01-02 03:04:05.006 UTC", local(1_704_164_645_006));
|
||||
test_zone = ist;
|
||||
defer test_zone = .utc;
|
||||
try expectReading("2024-01-02 08:34:05.006 IST", local(1_704_164_645_006));
|
||||
}
|
||||
@@ -246,6 +246,10 @@ pub const ToolLifecycleEvent = union(enum) {
|
||||
activity_kind: ToolActivityKind,
|
||||
arguments_json: ?[]const u8 = null,
|
||||
place_after_current_transcript: bool = false,
|
||||
/// The call's `ToolCall.resolved_display_target`, for hosts that
|
||||
/// title a call when it starts. Borrowed for this push only; copies
|
||||
/// omit it.
|
||||
resolved_display_target: ?[]const u8 = null,
|
||||
},
|
||||
progress: struct {
|
||||
id: ToolLifecycleId,
|
||||
@@ -333,6 +337,9 @@ pub const ModelRecoveryAction = enum {
|
||||
/// Silence detected; checking liveness before doing anything else.
|
||||
checking_liveness,
|
||||
paused,
|
||||
/// The final reply finished before its turn was saved as done; recovery
|
||||
/// delivers that saved reply without another model request.
|
||||
delivering_response,
|
||||
};
|
||||
|
||||
pub const ModelRecoveryRequiredAction = enum {
|
||||
@@ -551,6 +558,7 @@ pub const RouteRecoveryStatus = struct {
|
||||
.waiting_for_connectivity => "waiting for connection",
|
||||
.checking_liveness => "checking the connection",
|
||||
.paused => "recovery paused",
|
||||
.delivering_response => "delivering saved response",
|
||||
};
|
||||
// Connectivity waits and liveness probes are un-budgeted: no attempt
|
||||
// counter and no raw error names, just the honest current state.
|
||||
@@ -953,6 +961,10 @@ pub const ToolCall = struct {
|
||||
provenance: ToolExecutionProvenance = .fx_local,
|
||||
/// Borrowed only for this action. Copies and durable/provider encodings omit it.
|
||||
resolved_skill: ?*const skill_contract.PreparedSkill = null,
|
||||
/// What this action works on, resolved from earlier results, such as the
|
||||
/// command whose saved output a `read_tool_result` call reads. Borrowed
|
||||
/// only for this action. Copies and durable/provider encodings omit it.
|
||||
resolved_display_target: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
pub const WebSearchProgress = union(enum) {
|
||||
@@ -1306,11 +1318,23 @@ pub const ExecutionMemory = struct {
|
||||
|
||||
/// Image bytes returned by a tool; never a grant to read a user file.
|
||||
pub const ToolImage = struct {
|
||||
/// Owned encoded bytes; empty only when a valid source_ref is present.
|
||||
/// Owned encoded bytes; empty only when a valid source_ref is present, or
|
||||
/// when the session dropped the image (`droppedFromSession`).
|
||||
data: []u8,
|
||||
mime_type: []u8,
|
||||
/// Owned opaque host reference, never filesystem or network authority.
|
||||
source_ref: ?[]u8 = null,
|
||||
/// The image's name in a libfx host's store, the lowercase hex SHA-256
|
||||
/// of its decoded bytes, when the host stored them before it sent the
|
||||
/// result. Writers that name stored images write this instead of `data`.
|
||||
name: ?[64]u8 = null,
|
||||
|
||||
/// Whether the session no longer holds this image: a libfx session with
|
||||
/// an image store keeps only its newest images, and an older one keeps
|
||||
/// its name, without its bytes.
|
||||
pub fn droppedFromSession(self: ToolImage) bool {
|
||||
return self.data.len == 0 and self.name != null;
|
||||
}
|
||||
};
|
||||
|
||||
pub fn freeToolImages(alloc: std.mem.Allocator, images: []const ToolImage) void {
|
||||
@@ -1342,6 +1366,7 @@ pub fn dupeToolImages(alloc: std.mem.Allocator, images: []const ToolImage) ![]To
|
||||
.data = data,
|
||||
.mime_type = mime_type,
|
||||
.source_ref = if (image.source_ref) |source_ref| try alloc.dupe(u8, source_ref) else null,
|
||||
.name = image.name,
|
||||
};
|
||||
initialized += 1;
|
||||
}
|
||||
@@ -1494,6 +1519,13 @@ pub const ImageAttachment = struct {
|
||||
inline_data: ?[]u8 = null,
|
||||
/// Owned opaque host reference, never filesystem or network authority.
|
||||
source_ref: ?[]u8 = null,
|
||||
|
||||
/// Whether the session no longer holds this image: a libfx session with
|
||||
/// an image store keeps only its newest images, and an older one keeps
|
||||
/// its digest, which names it in the host's store, without its bytes.
|
||||
pub fn droppedFromSession(self: ImageAttachment) bool {
|
||||
return self.inline_data == null and self.snapshot_path == null and self.snapshot_sha256 != null;
|
||||
}
|
||||
};
|
||||
|
||||
pub const UserTurn = struct {
|
||||
@@ -3080,12 +3112,19 @@ test "dupeToolCall preserves argument integrity" {
|
||||
try std.testing.expectEqual(ToolArgumentIntegrity.malformed_json, copy.argument_integrity);
|
||||
}
|
||||
|
||||
test "dupeToolCall drops action scoped skill bindings" {
|
||||
test "dupeToolCall drops action scoped bindings" {
|
||||
const skill: skill_contract.PreparedSkill = .{ .skill = .{ .name = "workflow", .description = "", .path = "/skills/workflow", .source = .global_fx } };
|
||||
const source: ToolCall = .{ .id = "skill", .name = "skill", .arguments_json = "{\"location\":\"/skills/workflow\"}", .resolved_skill = &skill };
|
||||
const source: ToolCall = .{
|
||||
.id = "skill",
|
||||
.name = "skill",
|
||||
.arguments_json = "{\"location\":\"/skills/workflow\"}",
|
||||
.resolved_skill = &skill,
|
||||
.resolved_display_target = "more output from zig build test",
|
||||
};
|
||||
const copy = try dupeToolCall(std.testing.allocator, source);
|
||||
defer freeToolCall(std.testing.allocator, copy);
|
||||
try std.testing.expect(copy.resolved_skill == null);
|
||||
try std.testing.expect(copy.resolved_display_target == null);
|
||||
try std.testing.expectEqualStrings(source.arguments_json, copy.arguments_json);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ pub const ParsedCommand = union(enum) {
|
||||
reset_session,
|
||||
resume_session,
|
||||
rename_session: []const u8,
|
||||
fork_session,
|
||||
help,
|
||||
login,
|
||||
logout: []const u8,
|
||||
@@ -51,6 +52,7 @@ pub const CommandHandlers = struct {
|
||||
new_session: *const fn (ctx: *anyopaque) anyerror!void,
|
||||
reset_session: *const fn (ctx: *anyopaque) anyerror!void,
|
||||
resume_session: *const fn (ctx: *anyopaque) anyerror!void,
|
||||
fork_session: *const fn (ctx: *anyopaque) anyerror!void,
|
||||
show_help: *const fn (ctx: *anyopaque) anyerror!void,
|
||||
login: *const fn (ctx: *anyopaque) anyerror!void,
|
||||
logout: *const fn (ctx: *anyopaque, rest: []const u8) anyerror!void,
|
||||
@@ -97,6 +99,7 @@ fn parsedCommand(kind: SlashKind, payload: []const u8) ParsedCommand {
|
||||
.reset_session => .reset_session,
|
||||
.resume_session => .resume_session,
|
||||
.rename_session => .{ .rename_session = payload },
|
||||
.fork_session => .fork_session,
|
||||
.help => .help,
|
||||
.login => .login,
|
||||
.logout => .{ .logout = payload },
|
||||
@@ -151,6 +154,7 @@ pub fn route(registry: SlashRegistry, handlers: *const CommandHandlers, cmd: []c
|
||||
.reset_session => try handlers.reset_session(handlers.ctx),
|
||||
.resume_session => try handlers.resume_session(handlers.ctx),
|
||||
.rename_session => |rest| try handlers.rename_session(handlers.ctx, rest),
|
||||
.fork_session => try handlers.fork_session(handlers.ctx),
|
||||
.help => try handlers.show_help(handlers.ctx),
|
||||
.login => try handlers.login(handlers.ctx),
|
||||
.logout => |rest| try handlers.logout(handlers.ctx, rest),
|
||||
@@ -240,6 +244,11 @@ test "parse recognizes interactive resume" {
|
||||
try std.testing.expectEqual(ParsedCommand.resume_session, parse(testSlashRegistry(), "/resume"));
|
||||
}
|
||||
|
||||
test "parse recognizes fork without a payload" {
|
||||
try std.testing.expectEqual(ParsedCommand.fork_session, parse(testSlashRegistry(), "/fork"));
|
||||
try std.testing.expectEqual(ParsedCommand.unknown, parse(testSlashRegistry(), "/fork 2"));
|
||||
}
|
||||
|
||||
test "parse recognizes logout" {
|
||||
switch (parse(testSlashRegistry(), "/logout")) {
|
||||
.unknown => return error.TestExpectedLogoutCommand,
|
||||
@@ -433,6 +442,10 @@ fn recordResumeSession(ctx: *anyopaque) anyerror!void {
|
||||
testContext(ctx).called = "resume";
|
||||
}
|
||||
|
||||
fn recordForkSession(ctx: *anyopaque) anyerror!void {
|
||||
testContext(ctx).called = "fork";
|
||||
}
|
||||
|
||||
fn recordModel(ctx: *anyopaque, value: []const u8) anyerror!void {
|
||||
const test_context = testContext(ctx);
|
||||
test_context.called = "model";
|
||||
@@ -476,6 +489,7 @@ fn testHandlers(ctx: *TestContext) CommandHandlers {
|
||||
.new_session = unexpectedNoPayload,
|
||||
.reset_session = unexpectedNoPayload,
|
||||
.resume_session = unexpectedNoPayload,
|
||||
.fork_session = unexpectedNoPayload,
|
||||
.show_help = unexpectedNoPayload,
|
||||
.login = unexpectedNoPayload,
|
||||
.logout = unexpectedPayload,
|
||||
@@ -543,6 +557,16 @@ test "route calls interactive resume handler" {
|
||||
try std.testing.expectEqualStrings("resume", ctx.called);
|
||||
}
|
||||
|
||||
test "route calls fork handler" {
|
||||
var ctx: TestContext = .{};
|
||||
var handlers = testHandlers(&ctx);
|
||||
handlers.fork_session = recordForkSession;
|
||||
|
||||
try route(testSlashRegistry(), &handlers, "/fork");
|
||||
|
||||
try std.testing.expectEqualStrings("fork", ctx.called);
|
||||
}
|
||||
|
||||
test "route forwards borrowed payload slice" {
|
||||
var ctx: TestContext = .{};
|
||||
var handlers = testHandlers(&ctx);
|
||||
|
||||
@@ -43,6 +43,7 @@ pub const SlashKind = enum {
|
||||
reset_session,
|
||||
resume_session,
|
||||
rename_session,
|
||||
fork_session,
|
||||
help,
|
||||
login,
|
||||
logout,
|
||||
@@ -1827,7 +1828,7 @@ test "slash completion categories follow canonical entries" {
|
||||
test "help catalog groups visible commands and searches all command metadata" {
|
||||
const registry = testSlashRegistry();
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 36), helpCatalogCount(registry, ""));
|
||||
try std.testing.expectEqual(@as(usize, 37), helpCatalogCount(registry, ""));
|
||||
try std.testing.expectEqualStrings("/help", helpCatalogSpecAt(registry, "", 0).?.command);
|
||||
try std.testing.expectEqual(@as(usize, 5), helpCatalogCategoryCount(registry, "", .general));
|
||||
try std.testing.expectEqual(@as(usize, 3), helpCatalogCount(registry, "appearance"));
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
const std = @import("std");
|
||||
const result_store = @import("../session/result_store.zig");
|
||||
const command_replay_store = @import("../session/command_replay_store.zig");
|
||||
const compactor = @import("../compactor/compactor.zig");
|
||||
const text_utils = @import("../shared/text_utils.zig");
|
||||
const tool_dispatch = @import("tool_dispatch.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
/// Longest search text a row repeats, after terminal-safe encoding.
|
||||
const max_query_label_bytes = 60;
|
||||
|
||||
/// A handle as `validate` resolves it: without surrounding whitespace, plus
|
||||
/// the `.txt` a stored result or image handle may omit.
|
||||
const HandleNormalization = struct {
|
||||
trimmed: []const u8,
|
||||
suffix: []const u8,
|
||||
};
|
||||
|
||||
pub fn classifyHandleNormalization(handle: []const u8) HandleNormalization {
|
||||
const trimmed = std.mem.trim(u8, handle, " \t\r\n");
|
||||
const suffix = if ((std.mem.startsWith(u8, trimmed, "result-") or result_store.isImageHandle(trimmed)) and
|
||||
std.mem.findScalar(u8, trimmed, '.') == null)
|
||||
".txt"
|
||||
else
|
||||
"";
|
||||
return .{ .trimmed = trimmed, .suffix = suffix };
|
||||
}
|
||||
|
||||
/// What a call asks for, read the way the tool's `decode` and `validate` read it,
|
||||
/// so its row can say what the agent is reading.
|
||||
const RequestView = struct {
|
||||
kind: Kind,
|
||||
/// The handle without surrounding whitespace. Empty for a history search.
|
||||
handle: []const u8 = "",
|
||||
query: []const u8 = "",
|
||||
|
||||
const Kind = enum { range, query, images, record, history_search };
|
||||
};
|
||||
|
||||
fn requestView(args: std.json.ObjectMap) ?RequestView {
|
||||
const object = if (args.get("request")) |request| switch (request) {
|
||||
.object => |value| value,
|
||||
else => return null,
|
||||
} else args;
|
||||
if (object.get("search") != null) return .{ .kind = .history_search };
|
||||
const handle = switch (object.get("handle") orelse return null) {
|
||||
.string => |value| std.mem.trim(u8, value, " \t\r\n"),
|
||||
else => return null,
|
||||
};
|
||||
if (handle.len == 0) return null;
|
||||
var record_buffer: compactor.RecordFileBuffer = undefined;
|
||||
if (compactor.recordFile(&record_buffer, handle) != null) return .{ .kind = .record, .handle = handle };
|
||||
if (result_store.isImageHandle(handle)) return .{ .kind = .images, .handle = handle };
|
||||
if (object.get("query")) |query| {
|
||||
if (query == .string and query.string.len > 0) {
|
||||
return .{ .kind = .query, .handle = handle, .query = query.string };
|
||||
}
|
||||
}
|
||||
return .{ .kind = .range, .handle = handle };
|
||||
}
|
||||
|
||||
fn parseArguments(alloc: Allocator, arguments_json: []const u8) Allocator.Error!?std.json.Parsed(std.json.Value) {
|
||||
const parsed = std.json.parseFromSlice(std.json.Value, alloc, arguments_json, .{}) catch |err| return switch (err) {
|
||||
error.OutOfMemory => error.OutOfMemory,
|
||||
else => null,
|
||||
};
|
||||
if (parsed.value != .object) {
|
||||
parsed.deinit();
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
fn outputNoun(handle: []const u8, command: []const u8, other: []const u8) []const u8 {
|
||||
return if (command_replay_store.isReplayHandle(handle)) command else other;
|
||||
}
|
||||
|
||||
fn recordNoun(kind: u8) []const u8 {
|
||||
return switch (std.ascii.toUpper(kind)) {
|
||||
'M' => "turn",
|
||||
'T' => "tool call",
|
||||
else => "compaction",
|
||||
};
|
||||
}
|
||||
|
||||
fn readPresentation(
|
||||
action_label: []const u8,
|
||||
completed_action_label: []const u8,
|
||||
label_arg_default: []const u8,
|
||||
) tool_dispatch.CallPresentation {
|
||||
return .{
|
||||
.activity_kind = .read,
|
||||
.action_label = action_label,
|
||||
.completed_action_label = completed_action_label,
|
||||
.label_arg_kind = .none,
|
||||
.label_arg_default = label_arg_default,
|
||||
};
|
||||
}
|
||||
|
||||
/// Labels a call by what it reads instead of one shared "tool result" label.
|
||||
/// The resolved row text from `displayTarget` replaces the default object.
|
||||
pub fn presentation(args: std.json.ObjectMap) ?tool_dispatch.CallPresentation {
|
||||
const view = requestView(args) orelse return null;
|
||||
return switch (view.kind) {
|
||||
.range => readPresentation("Reading", "Read", outputNoun(view.handle, "more command output", "more output")),
|
||||
.query => readPresentation("Searching", "Searched", outputNoun(view.handle, "command output", "saved output")),
|
||||
.images => readPresentation("Reloading", "Reloaded", "saved images"),
|
||||
.record => readPresentation("Opening", "Opened", "earlier conversation"),
|
||||
.history_search => readPresentation("Searching", "Searched", "earlier conversation"),
|
||||
};
|
||||
}
|
||||
|
||||
/// The saved output a call reads, with the suffix `validate` restores, or null
|
||||
/// for a history search, a saved record, or arguments `decode` rejects. The
|
||||
/// caller owns the returned handle.
|
||||
pub fn requestedHandle(alloc: Allocator, arguments_json: []const u8) Allocator.Error!?[]u8 {
|
||||
var parsed = (try parseArguments(alloc, arguments_json)) orelse return null;
|
||||
defer parsed.deinit();
|
||||
const view = requestView(parsed.value.object) orelse return null;
|
||||
switch (view.kind) {
|
||||
.record, .history_search => return null,
|
||||
.range, .query, .images => {},
|
||||
}
|
||||
const normalization = classifyHandleNormalization(view.handle);
|
||||
return try std.mem.concat(alloc, u8, &.{ normalization.trimmed, normalization.suffix });
|
||||
}
|
||||
|
||||
/// The row text for what a call reads: the output of the earlier call that
|
||||
/// `source` names, the text it searches for, or the saved record it opens.
|
||||
/// Returns null when the default object from `presentation` already says it.
|
||||
/// `source` must be terminal-safe. The caller owns the returned text.
|
||||
pub fn displayTarget(
|
||||
alloc: Allocator,
|
||||
arguments_json: []const u8,
|
||||
source: ?[]const u8,
|
||||
) Allocator.Error!?[]u8 {
|
||||
var parsed = (try parseArguments(alloc, arguments_json)) orelse return null;
|
||||
defer parsed.deinit();
|
||||
const view = requestView(parsed.value.object) orelse return null;
|
||||
switch (view.kind) {
|
||||
.range => {
|
||||
const name = source orelse return null;
|
||||
return try alloc.print("more output from {s}", .{name});
|
||||
},
|
||||
.query => {
|
||||
var query = try text_utils.encodeTerminalSafe(alloc, view.query, max_query_label_bytes);
|
||||
defer query.deinit(alloc);
|
||||
if (source) |name| return try alloc.print("{s} output for \"{s}\"", .{ name, query.bytes });
|
||||
return try alloc.print("{s} for \"{s}\"", .{
|
||||
outputNoun(view.handle, "command output", "saved output"),
|
||||
query.bytes,
|
||||
});
|
||||
},
|
||||
// `requestView` admits only a record ID: one letter, then digits.
|
||||
.record => return try alloc.print("earlier {s} {c}{s}", .{
|
||||
recordNoun(view.handle[0]),
|
||||
std.ascii.toUpper(view.handle[0]),
|
||||
view.handle[1..],
|
||||
}),
|
||||
.images, .history_search => return null,
|
||||
}
|
||||
}
|
||||
|
||||
fn expectPresentation(arguments_json: []const u8, action_label: []const u8, completed_action_label: []const u8, label_arg_default: []const u8) !void {
|
||||
var parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, arguments_json, .{});
|
||||
defer parsed.deinit();
|
||||
const resolved = presentation(parsed.value.object) orelse return error.TestExpectedPresentation;
|
||||
try std.testing.expectEqual(tool_dispatch.LabelArgKind.none, resolved.label_arg_kind);
|
||||
try std.testing.expectEqualStrings(action_label, resolved.action_label);
|
||||
try std.testing.expectEqualStrings(completed_action_label, resolved.completed_action_label);
|
||||
try std.testing.expectEqualStrings(label_arg_default, resolved.label_arg_default);
|
||||
}
|
||||
|
||||
fn expectDisplayTarget(arguments_json: []const u8, source: ?[]const u8, expected: ?[]const u8) !void {
|
||||
const alloc = std.testing.allocator;
|
||||
const target = try displayTarget(alloc, arguments_json, source);
|
||||
defer if (target) |value| alloc.free(value);
|
||||
if (expected) |text| {
|
||||
try std.testing.expectEqualStrings(text, target orelse return error.TestExpectedDisplayTarget);
|
||||
} else {
|
||||
try std.testing.expect(target == null);
|
||||
}
|
||||
}
|
||||
|
||||
test "read_tool_result rows name what each request reads" {
|
||||
const replay = "fx-command-replay-0123.bin";
|
||||
try expectPresentation("{\"request\":{\"handle\":\"" ++ replay ++ "\"}}", "Reading", "Read", "more command output");
|
||||
try expectPresentation("{\"handle\":\"result-x.txt\",\"start_byte\":9}", "Reading", "Read", "more output");
|
||||
try expectPresentation("{\"request\":{\"handle\":\"" ++ replay ++ "\",\"query\":\"error:\"}}", "Searching", "Searched", "command output");
|
||||
try expectPresentation("{\"handle\":\"result-x\",\"query\":\"error:\"}", "Searching", "Searched", "saved output");
|
||||
// An empty query reads a range, as `decode` treats it.
|
||||
try expectPresentation("{\"handle\":\"result-x\",\"query\":\"\"}", "Reading", "Read", "more output");
|
||||
try expectPresentation("{\"request\":{\"handle\":\"image-result-abc\"}}", "Reloading", "Reloaded", "saved images");
|
||||
try expectPresentation("{\"request\":{\"handle\":\" t5 \"}}", "Opening", "Opened", "earlier conversation");
|
||||
try expectPresentation("{\"request\":{\"search\":[\"retry budget\"]}}", "Searching", "Searched", "earlier conversation");
|
||||
|
||||
var parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, "{\"request\":\"handle\"}", .{});
|
||||
defer parsed.deinit();
|
||||
try std.testing.expect(presentation(parsed.value.object) == null);
|
||||
}
|
||||
|
||||
test "read_tool_result display targets name the source, query, and record" {
|
||||
const replay = "fx-command-replay-0123.bin";
|
||||
try expectDisplayTarget("{\"request\":{\"handle\":\"" ++ replay ++ "\"}}", "zig build test", "more output from zig build test");
|
||||
try expectDisplayTarget("{\"request\":{\"handle\":\"" ++ replay ++ "\"}}", null, null);
|
||||
try expectDisplayTarget(
|
||||
"{\"request\":{\"handle\":\"" ++ replay ++ "\",\"query\":\"error:\"}}",
|
||||
"zig build test",
|
||||
"zig build test output for \"error:\"",
|
||||
);
|
||||
try expectDisplayTarget("{\"request\":{\"handle\":\"" ++ replay ++ "\",\"query\":\"error:\"}}", null, "command output for \"error:\"");
|
||||
try expectDisplayTarget("{\"handle\":\"result-x\",\"query\":\"needle\"}", null, "saved output for \"needle\"");
|
||||
try expectDisplayTarget("{\"request\":{\"handle\":\"t5\"}}", "ignored", "earlier tool call T5");
|
||||
try expectDisplayTarget("{\"request\":{\"handle\":\"M12\"}}", null, "earlier turn M12");
|
||||
try expectDisplayTarget("{\"request\":{\"handle\":\"l2\"}}", null, "earlier compaction L2");
|
||||
try expectDisplayTarget("{\"request\":{\"handle\":\"image-result-abc\"}}", "vision", null);
|
||||
try expectDisplayTarget("{\"request\":{\"search\":\"retry\"}}", null, null);
|
||||
try expectDisplayTarget("not json", "zig build test", null);
|
||||
}
|
||||
|
||||
test "read_tool_result display targets keep search text terminal-safe and short" {
|
||||
const alloc = std.testing.allocator;
|
||||
const target = (try displayTarget(alloc, "{\"handle\":\"result-x\",\"query\":\"\\u001b[31mred" ++ text_utils.repeat("x", 120) ++ "\"}", null)).?;
|
||||
defer alloc.free(target);
|
||||
try std.testing.expect(std.mem.findScalar(u8, target, 0x1b) == null);
|
||||
try std.testing.expect(target.len < "saved output for \"\"".len + max_query_label_bytes + 1);
|
||||
}
|
||||
|
||||
test "read_tool_result requested handles match what validate opens" {
|
||||
const alloc = std.testing.allocator;
|
||||
const restored = (try requestedHandle(alloc, "{\"request\":{\"handle\":\" result-shell-abc \"}}")).?;
|
||||
defer alloc.free(restored);
|
||||
try std.testing.expectEqualStrings("result-shell-abc.txt", restored);
|
||||
const replay = (try requestedHandle(alloc, "{\"handle\":\"fx-command-replay-0123.bin\",\"query\":\"x\"}")).?;
|
||||
defer alloc.free(replay);
|
||||
try std.testing.expectEqualStrings("fx-command-replay-0123.bin", replay);
|
||||
try std.testing.expect(try requestedHandle(alloc, "{\"request\":{\"handle\":\"T5\"}}") == null);
|
||||
try std.testing.expect(try requestedHandle(alloc, "{\"request\":{\"search\":[\"x\"]}}") == null);
|
||||
try std.testing.expect(try requestedHandle(alloc, "[]") == null);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
const std = @import("std");
|
||||
const debug_trace = @import("../shared/debug_trace.zig");
|
||||
const image_data = @import("../images/image_data.zig");
|
||||
const types = @import("../shared/types.zig");
|
||||
const tool_dispatch = @import("tool_dispatch.zig");
|
||||
@@ -21,6 +22,7 @@ pub fn parseRichResult(alloc: Allocator, bytes: []const u8, text_limit: usize, i
|
||||
else => return .{ .failure = try alloc.print("Host tool image could not be used: {s}", .{@errorName(err)}) },
|
||||
};
|
||||
errdefer types.freeToolImages(alloc, images);
|
||||
try nameStoredImages(alloc, images, values.array.items);
|
||||
const omitted = images.len != values.array.items.len;
|
||||
const source = if (omitted) try alloc.print("[Unsupported tool images were omitted.]\n{s}", .{text.string}) else text.string;
|
||||
defer if (omitted) alloc.free(source);
|
||||
@@ -28,6 +30,38 @@ pub fn parseRichResult(alloc: Allocator, bytes: []const u8, text_limit: usize, i
|
||||
return .{ .rich = .{ .text = body, .images = images, .is_error = is_error } };
|
||||
}
|
||||
|
||||
/// Names each image a libfx host stored before it sent the result: one whose
|
||||
/// decoded bytes hash to a `stored` mark the result carries. A mark that
|
||||
/// matches no image leaves the images inline.
|
||||
fn nameStoredImages(alloc: Allocator, images: []types.ToolImage, values: []const std.json.Value) Allocator.Error!void {
|
||||
var marks: usize = 0;
|
||||
for (values) |item| {
|
||||
if (item == .object and item.object.get("stored") != null) marks += 1;
|
||||
}
|
||||
if (marks == 0) return;
|
||||
var named: usize = 0;
|
||||
for (images) |*image| {
|
||||
if (image.data.len == 0) continue;
|
||||
const size = std.base64.standard.Decoder.calcSizeForSlice(image.data) catch continue;
|
||||
const bytes = try alloc.alloc(u8, size);
|
||||
defer alloc.free(bytes);
|
||||
std.base64.standard.Decoder.decode(bytes, image.data) catch continue;
|
||||
var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined;
|
||||
std.crypto.hash.sha2.Sha256.hash(bytes, &digest, .{});
|
||||
const name = std.fmt.bytesToHex(digest, .lower);
|
||||
for (values) |item| {
|
||||
if (item != .object) continue;
|
||||
const mark = item.object.get("stored") orelse continue;
|
||||
if (mark == .string and std.mem.eql(u8, mark.string, &name)) {
|
||||
image.name = name;
|
||||
named += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (named < marks) debug_trace.logf("tools", "event=host_tool_images_not_stored marks={d} named={d} reason=name_mismatch", .{ marks, named });
|
||||
}
|
||||
|
||||
/// Replaces binary fields in a caller-owned parsed MCP block with delivery notes.
|
||||
/// Images themselves travel through the typed result, never JSON text.
|
||||
pub fn projectMediaForText(alloc: std.mem.Allocator, content: *std.json.Value) std.mem.Allocator.Error!void {
|
||||
@@ -55,3 +89,31 @@ fn projectMediaBlock(alloc: std.mem.Allocator, item: *std.json.Value) std.mem.Al
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "a host tool image is named only when its stored mark is the digest of its bytes" {
|
||||
const alloc = std.testing.allocator;
|
||||
const png = "\x89PNG\r\n\x1a\nhost-tool-shot";
|
||||
var encoded: [64]u8 = undefined;
|
||||
const data = std.base64.standard.Encoder.encode(&encoded, png);
|
||||
var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined;
|
||||
std.crypto.hash.sha2.Sha256.hash(png, &digest, .{});
|
||||
const name = std.fmt.bytesToHex(digest, .lower);
|
||||
for ([_]struct { mark: []const u8, named: bool }{
|
||||
.{ .mark = &name, .named = true },
|
||||
.{ .mark = "0000000000000000000000000000000000000000000000000000000000000000", .named = false },
|
||||
}) |case| {
|
||||
const json = try alloc.print("{{\"text\":\"shot\",\"images\":[{{\"type\":\"image\",\"data\":\"{s}\",\"mimeType\":\"image/png\",\"stored\":\"{s}\"}}]}}", .{ data, case.mark });
|
||||
defer alloc.free(json);
|
||||
const result = try parseRichResult(alloc, json, 1024, false);
|
||||
const rich = result.rich;
|
||||
defer {
|
||||
alloc.free(rich.text);
|
||||
types.freeToolImages(alloc, rich.images);
|
||||
}
|
||||
try std.testing.expectEqual(@as(usize, 1), rich.images.len);
|
||||
// A mark that does not match leaves the image inline, never lost.
|
||||
try std.testing.expectEqualStrings(data, rich.images[0].data);
|
||||
try std.testing.expectEqual(case.named, rich.images[0].name != null);
|
||||
if (case.named) try std.testing.expectEqualStrings(&name, &rich.images[0].name.?);
|
||||
}
|
||||
}
|
||||
@@ -672,12 +672,53 @@ pub fn formatPlainAction(alloc: Allocator, input: ToolActionInput) ![]const u8 {
|
||||
return alloc.print("{s} {s}", .{ presentation.action_label, try formatWebSearchActionDetail(scratch, args) });
|
||||
}
|
||||
const value = input.display_target orelse
|
||||
call.resolved_display_target orelse
|
||||
resolvedSkillName(call, presentation) orelse
|
||||
tool_dispatch.presentationLabelValue(presentation, args) orelse
|
||||
presentation.label_arg_default;
|
||||
return alloc.print("{s} {s}", .{ presentation.action_label, value });
|
||||
}
|
||||
|
||||
/// Names an earlier call in the row of a later call that reads its saved
|
||||
/// output: the command it ran, the path or URL it read, or the name of an MCP
|
||||
/// tool. Returns null when the call has none of these, or when its command
|
||||
/// cannot be described, so a label never fails the turn that shows it. The
|
||||
/// text is terminal-safe. The caller owns the returned allocation.
|
||||
pub fn savedOutputSourceLabel(
|
||||
alloc: Allocator,
|
||||
registry: tool_dispatch.Registry,
|
||||
workspace_root: []const u8,
|
||||
call: ToolCall,
|
||||
) Allocator.Error!?[]const u8 {
|
||||
const command_activity = formatRunCommandActivity(alloc, registry, workspace_root, call) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
else => return null,
|
||||
};
|
||||
if (command_activity) |activity| return activity.detail;
|
||||
const spec = registry.lookup(call.name) orelse {
|
||||
if (isProviderSearchAlias(call.name)) return null;
|
||||
return try encodeSourceLabel(alloc, call.name);
|
||||
};
|
||||
var scratch_state = std.heap.ArenaAllocator.init(alloc);
|
||||
defer mem_utils.deinit_arena(scratch_state);
|
||||
const args = tool_args.parseToolArgsObject(scratch_state.allocator(), call.arguments_json) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
else => return null,
|
||||
};
|
||||
const presentation = tool_dispatch.presentationForArgs(spec.*, args);
|
||||
switch (presentation.label_arg_kind) {
|
||||
.path, .url => {},
|
||||
else => return null,
|
||||
}
|
||||
const value = tool_dispatch.presentationLabelValue(presentation, args) orelse return null;
|
||||
return try encodeSourceLabel(alloc, value);
|
||||
}
|
||||
|
||||
fn encodeSourceLabel(alloc: Allocator, raw: []const u8) ![]const u8 {
|
||||
const encoded = try text_utils.encodeTerminalSafe(alloc, raw, max_run_command_activity_bytes);
|
||||
return encoded.bytes;
|
||||
}
|
||||
|
||||
/// The caller owns the returned allocation and must free it with `alloc`.
|
||||
pub fn formatPermissionLabel(alloc: Allocator, registry: tool_dispatch.Registry, call: ToolCall) ![]const u8 {
|
||||
var scratch_state = std.heap.ArenaAllocator.init(alloc);
|
||||
@@ -1502,3 +1543,44 @@ test "tool presentation frees all formatted output with a normal allocator" {
|
||||
fn expectContains(text: []const u8, needle: []const u8) !void {
|
||||
try std.testing.expect(std.mem.find(u8, text, needle) != null);
|
||||
}
|
||||
|
||||
test "read_tool_result rows use the resolved source and the request kind" {
|
||||
const alloc = std.testing.allocator;
|
||||
const registry = test_builtin_tools.advertisement_set.registry;
|
||||
const page: ToolCall = .{
|
||||
.id = "page",
|
||||
.name = "read_tool_result",
|
||||
.arguments_json = "{\"request\":{\"handle\":\"fx-command-replay-0123.bin\"}}",
|
||||
};
|
||||
const generic = try formatPlainAction(alloc, .{ .tool_registry = registry, .call = page });
|
||||
defer alloc.free(generic);
|
||||
try std.testing.expectEqualStrings("Reading more command output", generic);
|
||||
|
||||
var resolved = page;
|
||||
resolved.resolved_display_target = "more output from zig build test";
|
||||
const named = try formatPlainAction(alloc, .{ .tool_registry = registry, .call = resolved });
|
||||
defer alloc.free(named);
|
||||
try std.testing.expectEqualStrings("Reading more output from zig build test", named);
|
||||
}
|
||||
|
||||
test "saved output sources name commands, paths, URLs, and MCP tools" {
|
||||
const alloc = std.testing.allocator;
|
||||
const registry = test_builtin_tools.advertisement_set.registry;
|
||||
const cases = [_]struct { call: ToolCall, expected: ?[]const u8 }{
|
||||
.{ .call = .{ .id = "run", .name = "shell", .arguments_json = "{\"action\":\"run\",\"command\":\"zig build test\"}" }, .expected = "zig build test" },
|
||||
.{ .call = .{ .id = "file", .name = "read_file", .arguments_json = "{\"path\":\"src/\\u001b[2Jmain.zig\"}" }, .expected = "src/\\x1b[2Jmain.zig" },
|
||||
.{ .call = .{ .id = "fetch", .name = "web_fetch", .arguments_json = "{\"url\":\"https://fx.sh/docs\"}" }, .expected = "https://fx.sh/docs" },
|
||||
.{ .call = .{ .id = "mcp", .name = "mcp_linear_list_issues", .arguments_json = "{}" }, .expected = "mcp_linear_list_issues" },
|
||||
.{ .call = .{ .id = "grep", .name = "grep_files", .arguments_json = "{\"pattern\":\"needle\"}" }, .expected = null },
|
||||
.{ .call = .{ .id = "bad", .name = "read_file", .arguments_json = "not json" }, .expected = null },
|
||||
};
|
||||
for (cases) |case| {
|
||||
const label = try savedOutputSourceLabel(alloc, registry, "/workspace", case.call);
|
||||
defer if (label) |value| alloc.free(value);
|
||||
if (case.expected) |expected| {
|
||||
try std.testing.expectEqualStrings(expected, label orelse return error.TestExpectedSourceLabel);
|
||||
} else {
|
||||
try std.testing.expect(label == null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -307,7 +307,6 @@ fn validate_history(alloc: Allocator, messages: []const types.ChatMessage) Error
|
||||
if (call.provenance != .fx_local or call.provider_result != null) return error.UnsupportedToolProvenance;
|
||||
if (call.id.len == 0 or call.final_identity != .valid) return error.InvalidToolCallId;
|
||||
try validate_name(call.name);
|
||||
if (call.argument_integrity != .valid) return error.InvalidToolArguments;
|
||||
if (call.arguments_json.len > max_history_arguments_bytes) return error.ArgumentsTooLarge;
|
||||
try validate_arguments(alloc, call.arguments_json);
|
||||
const entry = try pending.getOrPut(alloc, call.id);
|
||||
@@ -641,14 +640,13 @@ const Tool = struct {
|
||||
};
|
||||
|
||||
/// Single-owner, request-local state machine. No I/O, callbacks, tool execution,
|
||||
/// or hidden cancellation reads. Owns all retained input (including selected
|
||||
/// names) until deinit. Any accept/finish failure poisons the reducer; deinit
|
||||
/// remains mandatory. A successful finish transfers independent owned results.
|
||||
/// or hidden cancellation reads. Owns all retained input until deinit. Any
|
||||
/// accept/finish failure poisons the reducer; deinit remains mandatory. A
|
||||
/// successful finish transfers independent owned results.
|
||||
pub const Reducer = struct {
|
||||
alloc: Allocator,
|
||||
limits: Limits,
|
||||
choice: types.ToolChoice,
|
||||
names: std.ArrayList([]u8) = .empty,
|
||||
tools: std.ArrayList(Tool) = .empty,
|
||||
content: std.ArrayList(u8) = .empty,
|
||||
generation_id: ?[]u8 = null,
|
||||
@@ -671,21 +669,10 @@ pub const Reducer = struct {
|
||||
|
||||
pub fn init(alloc: Allocator, request: stream_provider.RequestData, limits: Limits) Error!Reducer {
|
||||
try validate_request(request);
|
||||
var functions = try select_functions(alloc, request.tools, request.tool_choice);
|
||||
defer functions.deinit(alloc);
|
||||
var self = Reducer{ .alloc = alloc, .limits = limits, .choice = request.tool_choice };
|
||||
errdefer self.deinit();
|
||||
for (functions.items) |function| {
|
||||
const name = try alloc.dupe(u8, function.name);
|
||||
errdefer alloc.free(name);
|
||||
try self.names.append(alloc, name);
|
||||
}
|
||||
return self;
|
||||
return .{ .alloc = alloc, .limits = limits, .choice = request.tool_choice };
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Reducer) void {
|
||||
for (self.names.items) |name| self.alloc.free(name);
|
||||
self.names.deinit(self.alloc);
|
||||
for (self.tools.items) |*tool| tool.deinit(self.alloc);
|
||||
self.tools.deinit(self.alloc);
|
||||
self.content.deinit(self.alloc);
|
||||
@@ -882,20 +869,12 @@ pub const Reducer = struct {
|
||||
if (non_null(function, "name")) |name_value| {
|
||||
const fragment = try string(name_value);
|
||||
try append_bounded(self.alloc, &tool.name, fragment, @min(max_name_bytes, self.limits.identity_bytes), error.IdentityTooLarge);
|
||||
var prefix = false;
|
||||
for (self.names.items) |name| prefix = prefix or std.mem.startsWith(u8, name, tool.name.items);
|
||||
if (!prefix) return error.InvalidToolName;
|
||||
}
|
||||
if (non_null(function, "arguments")) |arguments| try append_bounded(self.alloc, &tool.arguments, try string(arguments), self.limits.arguments_bytes, error.ArgumentsTooLarge);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn known_name(self: *const Reducer, name: []const u8) bool {
|
||||
for (self.names.items) |candidate| if (std.mem.eql(u8, candidate, name)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
fn accept_usage(self: *Reducer, value: std.json.Value, final: bool) Error!void {
|
||||
const fields = try object(value);
|
||||
const incoming = types.Usage{
|
||||
@@ -954,10 +933,13 @@ pub const Reducer = struct {
|
||||
if (self.refusal_seen) return error.Refused;
|
||||
if ((reason == .tool_calls) != (self.tools.items.len != 0)) return error.InconsistentFinishReason;
|
||||
if (self.choice == .required and self.tools.items.len == 0) return error.RequiredToolMissing;
|
||||
// Unadvertised names and invalid arguments are returned as sent. The tool
|
||||
// loop decides whether such a call runs and answers the rest, and the
|
||||
// permission reviewer retries a malformed decision.
|
||||
for (self.tools.items) |tool| {
|
||||
if (tool.id == null) return error.InvalidToolCallId;
|
||||
if (!self.known_name(tool.name.items)) return error.InvalidToolName;
|
||||
try validate_arguments(self.alloc, tool.arguments.items);
|
||||
try validate_name(tool.name.items);
|
||||
try check_json_depth(tool.arguments.items);
|
||||
}
|
||||
const provider_state = self.reasoning_state() catch |err| switch (err) {
|
||||
error.WriteFailed => return error.OutOfMemory,
|
||||
@@ -2172,22 +2154,53 @@ test "chat completions owns fragmented interleaved tools and results independent
|
||||
try std.testing.expectEqual(types.ToolExecutionProvenance.fx_local, completion.tool_calls[1].provenance);
|
||||
}
|
||||
|
||||
test "chat completions malformed and nonobject final arguments never become tools" {
|
||||
test "chat completions returns unadvertised names and invalid arguments as sent" {
|
||||
// The tool loop and the permission reviewer decide what happens to these
|
||||
// calls, so the stream must not fail here.
|
||||
const alloc = std.testing.allocator;
|
||||
for ([_][]const u8{ "", "{", "[]", "null", "3", "{}junk", "{\"x\":1,\"x\":2}" }) |arguments| {
|
||||
const cases = [_]struct { name: []const u8, arguments: []const u8 }{
|
||||
.{ .name = "not_advertised", .arguments = "{\"path\":\"a\"}" },
|
||||
.{ .name = "read_file", .arguments = "" },
|
||||
.{ .name = "read_file", .arguments = "{" },
|
||||
.{ .name = "read_file", .arguments = "[]" },
|
||||
.{ .name = "read_file", .arguments = "null" },
|
||||
.{ .name = "read_file", .arguments = "3" },
|
||||
.{ .name = "read_file", .arguments = "{}junk" },
|
||||
.{ .name = "read_file", .arguments = "{\"x\":1,\"x\":2}" },
|
||||
};
|
||||
for (cases) |case| {
|
||||
var reducer = try Reducer.init(alloc, test_tool_request(), .{});
|
||||
defer reducer.deinit();
|
||||
var out: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer out.deinit();
|
||||
try out.writer.writeAll("{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call-1\",\"function\":{\"name\":\"read_file\",\"arguments\":");
|
||||
try std.json.Stringify.value(arguments, .{}, &out.writer);
|
||||
try out.writer.writeAll("{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call-1\",\"function\":{\"name\":");
|
||||
try std.json.Stringify.value(case.name, .{}, &out.writer);
|
||||
try out.writer.writeAll(",\"arguments\":");
|
||||
try std.json.Stringify.value(case.arguments, .{}, &out.writer);
|
||||
try out.writer.writeAll("}}]}}]}");
|
||||
try test_accept(&reducer, out.written());
|
||||
try std.testing.expectError(error.InvalidToolArguments, test_finish(&reducer, test_tools_finish));
|
||||
try std.testing.expectError(error.StreamClosed, reducer.finish(false));
|
||||
var result = try test_finish(&reducer, test_tools_finish);
|
||||
defer result.deinit(alloc);
|
||||
const call = result.completed.completion.tool_calls[0];
|
||||
try std.testing.expectEqualStrings(case.name, call.name);
|
||||
try std.testing.expectEqualStrings(case.arguments, call.arguments_json);
|
||||
}
|
||||
}
|
||||
|
||||
test "chat completions history replays a call the tool loop blocked" {
|
||||
// dupeBlockedToolCall keeps the rejected integrity and replays `{}`.
|
||||
const alloc = std.testing.allocator;
|
||||
var request = test_tool_request();
|
||||
request.messages = &.{
|
||||
.{ .role = .user, .content = "hi" },
|
||||
.{ .role = .assistant, .tool_calls = &.{.{ .id = "call-1", .name = "read_file", .arguments_json = "{}", .argument_integrity = .malformed_json }} },
|
||||
.{ .role = .tool, .tool_call_id = "call-1", .tool_name = "read_file", .content = "Tool arguments were not valid JSON." },
|
||||
};
|
||||
const body = try build_request(alloc, request, .{});
|
||||
defer alloc.free(body);
|
||||
try std.testing.expect(std.mem.find(u8, body, "\"arguments\":\"{}\"") != null);
|
||||
}
|
||||
|
||||
test "chat completions terminal evidence and finish reasons are strict" {
|
||||
const alloc = std.testing.allocator;
|
||||
{
|
||||
@@ -2240,7 +2253,6 @@ test "chat completions rejects malformed chunks contradictory identities and ext
|
||||
.{ .first = test_text, .chunk = "{\"id\":\"other\",\"choices\":[]}", .failure = error.ConflictingIdentity },
|
||||
.{ .first = test_text, .chunk = "{\"model\":\"other\",\"choices\":[]}", .failure = error.ConflictingIdentity },
|
||||
.{ .first = test_call, .chunk = "{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"other\"}]}}]}", .failure = error.ConflictingIdentity },
|
||||
.{ .first = test_call, .chunk = "{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"shell\"}}]}}]}", .failure = error.InvalidToolName },
|
||||
.{ .first = test_call, .chunk = "{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":1,\"id\":\"call-1\"}]}}]}", .failure = error.ConflictingIdentity },
|
||||
.{ .chunk = "{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":-1}]}}]}", .failure = error.InvalidChunk },
|
||||
.{ .chunk = "{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0},{\"index\":0}]}}]}", .failure = error.ConflictingIdentity },
|
||||
@@ -2464,11 +2476,10 @@ test "chat completions missing tool fields sparse indexes and invalid identities
|
||||
const cases = [_]struct { delta: []const u8, failure: Error, at_finish: bool = false }{
|
||||
.{ .delta = "{\"index\":0,\"function\":{\"name\":\"read_file\",\"arguments\":\"{}\"}}", .failure = error.InvalidToolCallId, .at_finish = true },
|
||||
.{ .delta = "{\"index\":0,\"id\":\"x\",\"function\":{\"arguments\":\"{}\"}}", .failure = error.InvalidToolName, .at_finish = true },
|
||||
.{ .delta = "{\"index\":0,\"id\":\"x\",\"function\":{\"name\":\"read_file\"}}", .failure = error.InvalidToolArguments, .at_finish = true },
|
||||
.{ .delta = "{\"index\":0,\"id\":\"x\",\"function\":{\"name\":\"read file\",\"arguments\":\"{}\"}}", .failure = error.InvalidToolName, .at_finish = true },
|
||||
.{ .delta = "{\"index\":1,\"id\":\"x\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{}\"}}", .failure = error.InvalidToolCallId, .at_finish = true },
|
||||
.{ .delta = "{\"index\":0,\"id\":\"\"}", .failure = error.InvalidChunk },
|
||||
.{ .delta = "{\"index\":0,\"id\":7}", .failure = error.InvalidChunk },
|
||||
.{ .delta = "{\"index\":0,\"function\":{\"name\":\"not_advertised\"}}", .failure = error.InvalidToolName },
|
||||
.{ .delta = "{\"index\":0,\"function\":{\"name\":3}}", .failure = error.InvalidChunk },
|
||||
.{ .delta = "{\"index\":0,\"function\":{\"arguments\":{}}}", .failure = error.InvalidChunk },
|
||||
.{ .delta = "{\"index\":999999999}", .failure = error.TooManyTools },
|
||||
@@ -2485,16 +2496,13 @@ test "chat completions missing tool fields sparse indexes and invalid identities
|
||||
}
|
||||
}
|
||||
|
||||
test "chat completions repeated consistent identity preserves one call and selected names are owned" {
|
||||
test "chat completions repeated consistent identity preserves one call" {
|
||||
const alloc = std.testing.allocator;
|
||||
var request = test_request();
|
||||
const name = try alloc.dupe(u8, "read_file");
|
||||
defer alloc.free(name);
|
||||
const function: model_tool_schema.FunctionSchema = .{ .name = name, .description = "Read." };
|
||||
const function: model_tool_schema.FunctionSchema = .{ .name = "read_file", .description = "Read." };
|
||||
request.tools.additional_functions = &.{function};
|
||||
var reducer = try Reducer.init(alloc, request, .{});
|
||||
defer reducer.deinit();
|
||||
@memset(name, 'x');
|
||||
try test_accept(&reducer, test_call);
|
||||
try test_accept(&reducer, "{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call-1\",\"type\":\"function\",\"function\":{\"arguments\":\"\"}}]}}]}");
|
||||
var result = try test_finish(&reducer, test_tools_finish);
|
||||
|
||||
@@ -1368,6 +1368,10 @@ const App = struct {
|
||||
return SessionAppRuntime.resumeSelectedSession(self);
|
||||
}
|
||||
|
||||
pub fn forkBeforeSelectedPrompt(self: *App) ![]u8 {
|
||||
return SessionAppRuntime.forkBeforeSelectedPrompt(self);
|
||||
}
|
||||
|
||||
pub fn startSessionCatalogPreload(self: *App) void {
|
||||
SessionAppRuntime.preloadSessionCatalog(self);
|
||||
}
|
||||
@@ -4829,6 +4833,8 @@ test "semantic code block preserves indentation on wrapped continuation rows" {
|
||||
}
|
||||
|
||||
test {
|
||||
_ = @import("core/agent/runtime/journal.zig");
|
||||
_ = @import("acp/libfx_journal.zig");
|
||||
_ = @import("mcp/mcp.zig");
|
||||
_ = @import("core/mcp_host/runtime.zig");
|
||||
_ = @import("napi_fetch_state.zig");
|
||||
|
||||
@@ -313,6 +313,16 @@ pub const Engine = struct {
|
||||
return s.saveCredential(w);
|
||||
}
|
||||
|
||||
/// Gives server `name` the credential `saveCredential` wrote, as
|
||||
/// `Config.auth.saved` does, for a host that reads it after `init`.
|
||||
/// `saved` is copied. Call it before anything starts the server: a
|
||||
/// running server keeps the sign-in it has.
|
||||
pub fn restoreCredential(e: *Engine, name: []const u8, saved: []const u8) Error!void {
|
||||
const slot = try e.find(name);
|
||||
std.debug.assert(slot.server == null);
|
||||
slot.config.auth.saved = try slot.arena.allocator().dupe(u8, saved);
|
||||
}
|
||||
|
||||
/// Wakes a `next` that waits, from any thread: it returns null early, so
|
||||
/// the host can act on what it was woken for.
|
||||
pub fn wake(e: *Engine) void {
|
||||
@@ -668,6 +678,29 @@ test "wake from another thread ends a waiting next early" {
|
||||
try std.testing.expect(e.now() - started < 5_000);
|
||||
}
|
||||
|
||||
test "a credential restored after init is the one the server starts with" {
|
||||
const Fake = struct {
|
||||
fn allow(_: *anyopaque, _: []const u8, _: []const u8, _: ?[]const u8) bool {
|
||||
return true;
|
||||
}
|
||||
fn log(_: *anyopaque, _: []const u8, _: []const u8) void {}
|
||||
};
|
||||
var context: u8 = 0;
|
||||
var e: Engine = undefined;
|
||||
const servers = [_]Config{.{ .name = "web", .transport = .{ .http = .{ .url = "https://web.example.invalid/mcp" } } }};
|
||||
try e.init(std.testing.io, std.testing.allocator, .{ .context = &context, .allow = Fake.allow, .log = Fake.log }, .{
|
||||
.client = .{ .info = .{ .name = "test", .version = "0" } },
|
||||
}, &servers);
|
||||
defer e.deinit();
|
||||
|
||||
var saved = "{\"access\":\"w1\"}".*;
|
||||
try e.restoreCredential("web", &saved);
|
||||
// The engine keeps its own copy.
|
||||
@memset(&saved, 0);
|
||||
try std.testing.expectEqualStrings("{\"access\":\"w1\"}", e.slots.items[0].config.auth.saved.?);
|
||||
try std.testing.expectError(error.UnknownServer, e.restoreCredential("nope", "{}"));
|
||||
}
|
||||
|
||||
test "a stdio server's directory is part of its config, so a new one restarts it" {
|
||||
const argv = [_][]const u8{ "node", "s.js" };
|
||||
const a: Config = .{ .name = "x", .transport = .{ .stdio = .{ .argv = &argv, .cwd = "/a" } } };
|
||||
|
||||
+23
-4
@@ -4,6 +4,7 @@ const acp_server = @import("acp/server.zig");
|
||||
const jsonrpc = @import("acp/jsonrpc.zig");
|
||||
const gateway_provider = @import("core/gateway/gateway_provider.zig");
|
||||
const provider_set = @import("core/gateway/provider_set.zig");
|
||||
const stream_provider = @import("core/agent/stream_provider.zig");
|
||||
const agent_steps = @import("core/config/agent_steps.zig");
|
||||
const context_contract = @import("core/workspace/context_contract.zig");
|
||||
const host = @import("core/hosts/host.zig");
|
||||
@@ -31,6 +32,24 @@ const max_input_bytes = 8 * 1024 * 1024;
|
||||
const max_output_bytes = 8 * 1024 * 1024;
|
||||
const max_output_message_bytes = 64 * 1024 * 1024;
|
||||
const max_fetch_request_bytes = 8 * 1024 * 1024;
|
||||
/// The largest request body a provider sends over the fetch bridge: its
|
||||
/// budget less room for the request's method, URL, and headers, which hold
|
||||
/// at most the URL and API key this addon accepts and the other headers.
|
||||
const max_bridge_request_body_bytes = max_fetch_request_bytes - (max_url_bytes + max_api_key_bytes + 64 * 1024);
|
||||
|
||||
/// `provider` with its requests fit to the fetch bridge, so they leave out
|
||||
/// their oldest images instead of failing.
|
||||
fn bridgeBound(provider: stream_provider.Provider) stream_provider.Provider {
|
||||
var bound = provider;
|
||||
bound.max_request_bytes = max_bridge_request_body_bytes;
|
||||
return bound;
|
||||
}
|
||||
|
||||
fn configuredBridgeBundle(raw: ?*anyopaque, definition: *const configured_provider.Definition) provider_set.Bundle {
|
||||
var bundle = host_chat_completions.bundle(raw, definition);
|
||||
if (bundle.agent_stream) |stream| bundle.agent_stream = bridgeBound(stream);
|
||||
return bundle;
|
||||
}
|
||||
const max_fetch_response_bytes = 8 * 1024 * 1024;
|
||||
// One attachment holds one prompt image or one kernel checkpoint, the larger.
|
||||
const max_attachment_bytes = agent_checkpoint.max_checkpoint_bytes;
|
||||
@@ -686,23 +705,23 @@ const Runtime = struct {
|
||||
.presentation = builtin_gateway.provider_bundle.presentation,
|
||||
.auth_strategy = .vercel,
|
||||
.fallback_model_capabilities_fn = builtin_gateway.provider_bundle.fallback_model_capabilities_fn,
|
||||
.agent_stream = host_stream_provider.provider(&self.stream_context),
|
||||
.agent_stream = bridgeBound(host_stream_provider.provider(&self.stream_context)),
|
||||
.model_catalog = @import("gateway/host_model_catalog.zig").provider(&self.stream_context.transport),
|
||||
});
|
||||
providers.codex = .{
|
||||
.presentation = provider_catalog.find(.codex),
|
||||
.auth_strategy = .chatgpt,
|
||||
.agent_stream = host_openai_codex.provider(&self.codex_context),
|
||||
.agent_stream = bridgeBound(host_openai_codex.provider(&self.codex_context)),
|
||||
.model_catalog = host_openai_codex.modelCatalogProvider(&self.codex_context),
|
||||
};
|
||||
providers.grok = .{
|
||||
.presentation = provider_catalog.find(.grok),
|
||||
.auth_strategy = .grok,
|
||||
.agent_stream = host_xai_grok.provider(&self.grok_context),
|
||||
.agent_stream = bridgeBound(host_xai_grok.provider(&self.grok_context)),
|
||||
.model_catalog = host_xai_grok.modelCatalogProvider(&self.grok_context),
|
||||
};
|
||||
if (self.custom_context) |*context| {
|
||||
providers.configured_fn = host_chat_completions.bundle;
|
||||
providers.configured_fn = configuredBridgeBundle;
|
||||
providers.configured_context = context;
|
||||
}
|
||||
acp_server.runWithTransport(
|
||||
|
||||
@@ -5,14 +5,10 @@ const session_child_store = @import("../../core/session/session_child_store.zig"
|
||||
const io_mod = @import("../../core/shared/io.zig");
|
||||
const tool_dispatch = @import("../../core/tooling/tool_dispatch.zig");
|
||||
const compactor = @import("../../core/compactor/compactor.zig");
|
||||
const read_result_presentation = @import("../../core/tooling/read_tool_result_presentation.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const HandleNormalization = struct {
|
||||
trimmed: []const u8,
|
||||
suffix: []const u8,
|
||||
};
|
||||
|
||||
pub const Input = struct {
|
||||
handle: []u8,
|
||||
selector: union(enum) {
|
||||
@@ -130,20 +126,10 @@ fn inputDeinit(ptr: *anyopaque, alloc: Allocator) void {
|
||||
alloc.destroy(input);
|
||||
}
|
||||
|
||||
fn classifyHandleNormalization(handle: []const u8) HandleNormalization {
|
||||
const trimmed = std.mem.trim(u8, handle, " \t\r\n");
|
||||
const suffix = if ((std.mem.startsWith(u8, trimmed, "result-") or result_store.isImageHandle(trimmed)) and
|
||||
std.mem.findScalar(u8, trimmed, '.') == null)
|
||||
".txt"
|
||||
else
|
||||
"";
|
||||
return .{ .trimmed = trimmed, .suffix = suffix };
|
||||
}
|
||||
|
||||
pub fn validate(ctx: tool_dispatch.DispatchContext, erased: tool_dispatch.ToolInput) tool_dispatch.DispatchError!?[]u8 {
|
||||
const input = erased.as(Input);
|
||||
if (input.selector == .search) return null;
|
||||
const normalization = classifyHandleNormalization(input.handle);
|
||||
const normalization = read_result_presentation.classifyHandleNormalization(input.handle);
|
||||
if (normalization.trimmed.len == 0) return try ctx.allocator.dupe(u8, "read_tool_result field \"handle\" must not be empty");
|
||||
var record_buffer: compactor.RecordFileBuffer = undefined;
|
||||
if (compactor.recordFile(&record_buffer, normalization.trimmed)) |file| {
|
||||
@@ -279,6 +265,9 @@ pub fn isIrreversible(_: tool_dispatch.ToolInput) bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Labels each call by what it reads. See `read_tool_result_presentation.zig`.
|
||||
pub const presentation = read_result_presentation.presentation;
|
||||
|
||||
test "read_tool_result decodes range and query inputs" {
|
||||
const alloc = std.testing.allocator;
|
||||
const decoded_range = try decode(.{ .allocator = alloc }, "{\"handle\":\"h.txt\",\"start_byte\":2,\"byte_count\":9}");
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
const std = @import("std");
|
||||
const input_presentation = @import("input_presentation.zig");
|
||||
const render_input = @import("render_input.zig");
|
||||
const ui_render = @import("../render.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
const ForkMenuProjection = render_input.ForkMenuProjection;
|
||||
|
||||
/// Each prompt shows as at most this many wrapped lines.
|
||||
const max_prompt_lines = render_input.max_message_layout_rows;
|
||||
/// Six prompts of three lines.
|
||||
pub const max_inline_rows: u16 = 6 * max_prompt_lines;
|
||||
|
||||
fn indentWidth(width: u16) u16 {
|
||||
return if (width > 4) 2 else 0;
|
||||
}
|
||||
|
||||
fn promptLayout(prompt: []const u8, width: u16) render_input.SteeringMessageLayout {
|
||||
return render_input.steering_message_layout(prompt, width - indentWidth(width), false, max_prompt_lines);
|
||||
}
|
||||
|
||||
/// The menu's lines, every prompt's in order, and where the selected one sits.
|
||||
const Lines = struct {
|
||||
total: usize = 0,
|
||||
selected_start: usize = 0,
|
||||
selected_end: usize = 0,
|
||||
|
||||
fn measure(projection: ForkMenuProjection, width: u16) Lines {
|
||||
var lines: Lines = .{};
|
||||
for (projection.points, 0..) |point, index| {
|
||||
if (index == projection.selected) lines.selected_start = lines.total;
|
||||
lines.total += promptLayout(point.prompt, width).row_count;
|
||||
if (index == projection.selected) lines.selected_end = lines.total;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
};
|
||||
|
||||
pub fn menuRowCount(projection: ForkMenuProjection, width: u16, row_budget: u16) u16 {
|
||||
return @intCast(@min(row_budget, Lines.measure(projection, width).total));
|
||||
}
|
||||
|
||||
/// The first line shown: `projection.window_start`, moved only as far as it
|
||||
/// takes to show the whole selected prompt, so the list scrolls at its edges.
|
||||
pub fn windowStart(projection: ForkMenuProjection, width: u16, row_budget: u16) usize {
|
||||
const lines = Lines.measure(projection, width);
|
||||
const height: usize = @min(row_budget, lines.total);
|
||||
var start = @min(projection.window_start, lines.total - height);
|
||||
if (lines.selected_start < start) start = lines.selected_start;
|
||||
if (lines.selected_end > start + height) start = @min(lines.selected_start, lines.selected_end - height);
|
||||
return start;
|
||||
}
|
||||
|
||||
pub fn composeForkMenuRow(
|
||||
alloc: Allocator,
|
||||
projection: ForkMenuProjection,
|
||||
row_index: u16,
|
||||
width: u16,
|
||||
row_budget: u16,
|
||||
) !std.ArrayList(u8) {
|
||||
var row: std.ArrayList(u8) = .empty;
|
||||
errdefer row.deinit(alloc);
|
||||
if (width == 0 or row_index >= row_budget) return row;
|
||||
const line = windowStart(projection, width, row_budget) + row_index;
|
||||
var first: usize = 0;
|
||||
for (projection.points, 0..) |point, index| {
|
||||
const layout = promptLayout(point.prompt, width);
|
||||
if (line < first + layout.row_count) {
|
||||
try row.appendNTimes(alloc, ' ', indentWidth(width));
|
||||
try row.appendSlice(alloc, if (index == projection.selected) ui_render.selected_completion_style else ui_render.dim_style);
|
||||
try input_presentation.appendMessageLayoutRow(alloc, &row, layout, line - first);
|
||||
try row.appendSlice(alloc, ui_render.reset_style);
|
||||
return row;
|
||||
}
|
||||
first += layout.row_count;
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
const ForkPoint = std.meta.Child(@FieldType(ForkMenuProjection, "points"));
|
||||
|
||||
/// The visible text of row `row_index`, and whether it is selected. Caller owns the text.
|
||||
fn testRow(projection: ForkMenuProjection, row_index: u16, width: u16, row_budget: u16) !struct { text: []u8, selected: bool } {
|
||||
const alloc = std.testing.allocator;
|
||||
var row = try composeForkMenuRow(alloc, projection, row_index, width, row_budget);
|
||||
defer row.deinit(alloc);
|
||||
const selected = std.mem.find(u8, row.items, ui_render.selected_completion_style) != null;
|
||||
var text: std.ArrayList(u8) = .empty;
|
||||
errdefer text.deinit(alloc);
|
||||
var index: usize = 0;
|
||||
while (index < row.items.len) {
|
||||
if (row.items[index] == 0x1b) {
|
||||
index = std.mem.findScalarPos(u8, row.items, index, 'm').? + 1;
|
||||
continue;
|
||||
}
|
||||
try text.append(alloc, row.items[index]);
|
||||
index += 1;
|
||||
}
|
||||
return .{ .text = try text.toOwnedSlice(alloc), .selected = selected };
|
||||
}
|
||||
|
||||
test "fork menu shows each prompt in at most three lines, ending a cut one with an ellipsis" {
|
||||
const points = [_]ForkPoint{
|
||||
.{ .turn = 1, .prompt = @constCast("one two three four five six seven eight nine ten eleven twelve") },
|
||||
.{ .turn = 2, .prompt = @constCast("short\nsecond line") },
|
||||
};
|
||||
const projection: ForkMenuProjection = .{ .points = &points, .selected = 1, .window_start = 0 };
|
||||
try std.testing.expectEqual(@as(u16, 5), menuRowCount(projection, 16, max_inline_rows));
|
||||
const expected = [_][]const u8{ " one two three", " four five six", " seven eight…", " short", " second line" };
|
||||
for (expected, 0..) |text, row_index| {
|
||||
const row = try testRow(projection, @intCast(row_index), 16, max_inline_rows);
|
||||
defer std.testing.allocator.free(row.text);
|
||||
try std.testing.expectEqualStrings(text, row.text);
|
||||
try std.testing.expectEqual(row_index >= 3, row.selected);
|
||||
}
|
||||
}
|
||||
|
||||
test "fork menu lists prompts oldest first and opens on the newest at the bottom" {
|
||||
const points = [_]ForkPoint{
|
||||
.{ .turn = 1, .prompt = @constCast("first") },
|
||||
.{ .turn = 2, .prompt = @constCast("second") },
|
||||
.{ .turn = 3, .prompt = @constCast("third") },
|
||||
};
|
||||
// As the menu opens: the newest selected, the window past the end.
|
||||
var projection: ForkMenuProjection = .{ .points = &points, .selected = 2, .window_start = std.math.maxInt(usize) };
|
||||
for ([_][]const u8{ " first", " second", " third" }, 0..) |text, row_index| {
|
||||
const row = try testRow(projection, @intCast(row_index), 40, 3);
|
||||
defer std.testing.allocator.free(row.text);
|
||||
try std.testing.expectEqualStrings(text, row.text);
|
||||
try std.testing.expectEqual(row_index == 2, row.selected);
|
||||
}
|
||||
|
||||
// Two rows show the two newest; moving up keeps them until the selection
|
||||
// passes the top, then the list scrolls by one.
|
||||
const steps = [_]struct { selected: usize, top: []const u8, bottom: []const u8 }{
|
||||
.{ .selected = 2, .top = " second", .bottom = " third" },
|
||||
.{ .selected = 1, .top = " second", .bottom = " third" },
|
||||
.{ .selected = 0, .top = " first", .bottom = " second" },
|
||||
.{ .selected = 1, .top = " first", .bottom = " second" },
|
||||
};
|
||||
for (steps) |step| {
|
||||
projection.selected = step.selected;
|
||||
projection.window_start = windowStart(projection, 40, 2);
|
||||
const top = try testRow(projection, 0, 40, 2);
|
||||
defer std.testing.allocator.free(top.text);
|
||||
const bottom = try testRow(projection, 1, 40, 2);
|
||||
defer std.testing.allocator.free(bottom.text);
|
||||
try std.testing.expectEqualStrings(step.top, top.text);
|
||||
try std.testing.expectEqualStrings(step.bottom, bottom.text);
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ pub const composeDividerRow = row_text.composeDividerRow;
|
||||
pub const appendClipped = row_text.appendClipped;
|
||||
pub const appendAbsoluteColumn = row_text.appendAbsoluteColumn;
|
||||
|
||||
pub const PickerKind = enum { model_stage, provider_stage, models, file, slash, skills, help, settings, sessions, mcp, auth };
|
||||
pub const PickerKind = enum { model_stage, provider_stage, models, file, slash, skills, help, settings, sessions, fork, mcp, auth };
|
||||
pub const CappedInputRows = struct {
|
||||
row_limit: usize,
|
||||
total_lines: u16,
|
||||
@@ -65,30 +65,38 @@ pub fn composeSteeringMessageRows(
|
||||
var composed: ComposedInputRows = .{};
|
||||
errdefer composed.deinit(alloc);
|
||||
const layout = render_input.steering_message_layout(message, width, waits_for_boundary, row_limit);
|
||||
for (layout.rows[0..layout.row_count], 0..) |content, index| {
|
||||
const normalized = try alloc.dupe(u8, content);
|
||||
defer alloc.free(normalized);
|
||||
for (normalized) |*byte| {
|
||||
if (byte.* == '\t') byte.* = ' ';
|
||||
}
|
||||
var safe = try text_utils.encodeTerminalSafe(alloc, normalized, std.math.maxInt(usize));
|
||||
defer safe.deinit(alloc);
|
||||
|
||||
for (0..layout.row_count) |index| {
|
||||
var row: std.ArrayList(u8) = .empty;
|
||||
errdefer row.deinit(alloc);
|
||||
try row.appendSlice(alloc, if (waits_for_boundary) ui_render.dim_style else ui_render.hint_style);
|
||||
if (waits_for_boundary) try row_text.appendClipped(alloc, &row, "┋ ", width);
|
||||
const ellipsis = layout.truncated and index + 1 == layout.row_count and layout.content_width > 0;
|
||||
try row_text.appendClipped(alloc, &row, safe.bytes, layout.content_width - @as(u16, @intFromBool(ellipsis)));
|
||||
if (ellipsis) {
|
||||
try row.appendSlice(alloc, "…");
|
||||
}
|
||||
try appendMessageLayoutRow(alloc, &row, layout, index);
|
||||
try row.appendSlice(alloc, ui_render.reset_style);
|
||||
try composed.rows.append(alloc, row);
|
||||
}
|
||||
return composed;
|
||||
}
|
||||
|
||||
/// Appends row `index` of a `steering_message_layout` as terminal-safe text,
|
||||
/// tabs as spaces, ending in `…` when it is the last row of a cut message.
|
||||
pub fn appendMessageLayoutRow(
|
||||
alloc: Allocator,
|
||||
row: *std.ArrayList(u8),
|
||||
layout: render_input.SteeringMessageLayout,
|
||||
index: usize,
|
||||
) !void {
|
||||
const normalized = try alloc.dupe(u8, layout.rows[index]);
|
||||
defer alloc.free(normalized);
|
||||
for (normalized) |*byte| {
|
||||
if (byte.* == '\t') byte.* = ' ';
|
||||
}
|
||||
var safe = try text_utils.encodeTerminalSafe(alloc, normalized, std.math.maxInt(usize));
|
||||
defer safe.deinit(alloc);
|
||||
const ellipsis = layout.truncated and index + 1 == layout.row_count and layout.content_width > 0;
|
||||
try row_text.appendClipped(alloc, row, safe.bytes, layout.content_width - @as(u16, @intFromBool(ellipsis)));
|
||||
if (ellipsis) try row.appendSlice(alloc, "…");
|
||||
}
|
||||
|
||||
test "steering rows use the dotted rail without an escape hint" {
|
||||
var first = try composeSteeringMessageRows(std.testing.allocator, "First steer", 80, 2, true);
|
||||
defer first.deinit(std.testing.allocator);
|
||||
@@ -731,6 +739,39 @@ pub fn composeHelpMenuHintRow(alloc: Allocator, width: u16, ctrl_c_pending: bool
|
||||
return row;
|
||||
}
|
||||
|
||||
pub fn composeForkMenuHintRow(alloc: Allocator, width: u16, ctrl_c_pending: bool) !std.ArrayList(u8) {
|
||||
if (ctrl_c_pending) {
|
||||
var warning: std.ArrayList(u8) = .empty;
|
||||
errdefer warning.deinit(alloc);
|
||||
try warning.appendSlice(alloc, ui_render.statusline_style);
|
||||
try row_text.appendClipped(alloc, &warning, "press ctrl+c again to exit", width);
|
||||
try warning.appendSlice(alloc, ui_render.reset_style);
|
||||
return warning;
|
||||
}
|
||||
|
||||
const variants = [_][]const u8{
|
||||
"↑↓ navigate enter fork esc close",
|
||||
"↑↓ navigate enter fork esc close",
|
||||
"↑↓ move enter esc",
|
||||
"enter fork esc close",
|
||||
"enter esc",
|
||||
};
|
||||
var hint = variants[variants.len - 1];
|
||||
for (variants) |candidate| {
|
||||
if (display_width.visibleWidth(candidate) <= width) {
|
||||
hint = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var row: std.ArrayList(u8) = .empty;
|
||||
errdefer row.deinit(alloc);
|
||||
try row.appendSlice(alloc, ui_render.dim_style);
|
||||
try row_text.appendClipped(alloc, &row, hint, width);
|
||||
try row.appendSlice(alloc, ui_render.reset_style);
|
||||
return row;
|
||||
}
|
||||
|
||||
pub fn composeSettingsMenuHintRow(
|
||||
alloc: Allocator,
|
||||
width: u16,
|
||||
|
||||
@@ -26,6 +26,7 @@ const settings_menu_presentation = @import("settings_menu_presentation.zig");
|
||||
const mcp_menu_presentation = @import("mcp_menu_presentation.zig");
|
||||
const mcp_host_menu_presentation = @import("mcp_host_menu_presentation.zig");
|
||||
const resume_menu_presentation = @import("resume_menu_presentation.zig");
|
||||
const fork_menu_presentation = @import("fork_menu_presentation.zig");
|
||||
const question_ui = @import("question_ui.zig");
|
||||
const render_input = @import("render_input.zig");
|
||||
const row_text = @import("row_text.zig");
|
||||
@@ -784,6 +785,18 @@ pub fn composeFooterFrame(
|
||||
);
|
||||
try pushFooterBandRow(alloc, &frame, plan, rows.picker_start + menu_row_index, &menu_row);
|
||||
}
|
||||
} else if (input.picker_kind == .fork and ctx.fork_menu.active()) {
|
||||
var menu_row_index: u16 = 0;
|
||||
while (menu_row_index < input.picker_rows) : (menu_row_index += 1) {
|
||||
var menu_row = try fork_menu_presentation.composeForkMenuRow(
|
||||
alloc,
|
||||
ctx.fork_menu,
|
||||
menu_row_index,
|
||||
shell.layout.cols,
|
||||
input.picker_rows,
|
||||
);
|
||||
try pushFooterBandRow(alloc, &frame, plan, rows.picker_start + menu_row_index, &menu_row);
|
||||
}
|
||||
} else if (input.picker_kind == .models and ctx.model_menu.active) {
|
||||
var menu_row_index: u16 = 0;
|
||||
while (menu_row_index < input.picker_rows) : (menu_row_index += 1) {
|
||||
@@ -986,6 +999,8 @@ pub fn composeFooterFrame(
|
||||
try input_presentation.composeHelpMenuHintRow(alloc, shell.layout.cols, ctx.ctrl_c_pending)
|
||||
else if (input.show_picker and input.picker_kind == .sessions)
|
||||
try input_presentation.composeResumeMenuHintRow(alloc, shell.layout.cols, ctx.ctrl_c_pending)
|
||||
else if (input.show_picker and input.picker_kind == .fork)
|
||||
try input_presentation.composeForkMenuHintRow(alloc, shell.layout.cols, ctx.ctrl_c_pending)
|
||||
else if (input.show_picker and input.picker_kind == .models)
|
||||
try input_presentation.composeModelsMenuHintRow(alloc, shell.layout.cols, ctx.ctrl_c_pending)
|
||||
else if (input.show_picker and input.picker_kind == .slash and input.slash_menu_layout != null)
|
||||
|
||||
@@ -820,7 +820,7 @@ pub noinline fn composePickerOptionRowAnnotated(
|
||||
// brightness alone, like the question panel; other pickers keep the filled row.
|
||||
const selected_style = switch (kind) {
|
||||
.model_stage, .provider_stage, .models => ui_render.selected_completion_style,
|
||||
.file, .slash, .skills, .help, .settings, .sessions, .mcp, .auth => ui_render.approval_button_inactive_style,
|
||||
.file, .slash, .skills, .help, .settings, .sessions, .fork, .mcp, .auth => ui_render.approval_button_inactive_style,
|
||||
};
|
||||
const base_style = if (selected) selected_style else ui_render.dim_style;
|
||||
const available = width_usize - @as(usize, start_col - 1);
|
||||
@@ -928,6 +928,7 @@ pub fn composePickerStatusRowWithProvider(
|
||||
.help => "no matching commands",
|
||||
.settings => "no matching settings",
|
||||
.sessions => "no matching sessions",
|
||||
.fork => "nothing to fork yet",
|
||||
.mcp => "no MCP items available",
|
||||
.auth => "authentication actions unavailable",
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ const mcp_menu_state = @import("../../core/mcp/menu_state.zig");
|
||||
const mcp_host_menu_presentation = @import("mcp_host_menu_presentation.zig");
|
||||
const mcp_runtime = @import("../../core/mcp/mcp_runtime.zig");
|
||||
const picker_state = @import("../../core/input/picker_state.zig");
|
||||
const session_adapter = @import("../../core/session/session_adapter.zig");
|
||||
const session_catalog = @import("../../core/session/session_catalog.zig");
|
||||
const session_store = @import("../../core/session/session_store.zig");
|
||||
const usage_presentation = @import("usage_presentation.zig");
|
||||
@@ -244,6 +245,17 @@ pub const SessionMenuProjection = struct {
|
||||
}
|
||||
};
|
||||
|
||||
pub const ForkMenuProjection = struct {
|
||||
/// The session's prompts, oldest first.
|
||||
points: []const session_adapter.Session.ForkPoint = &.{},
|
||||
selected: usize = 0,
|
||||
window_start: usize = 0,
|
||||
|
||||
pub fn active(self: ForkMenuProjection) bool {
|
||||
return self.points.len > 0;
|
||||
}
|
||||
};
|
||||
|
||||
pub const HelpMenuProjection = struct {
|
||||
active: bool = false,
|
||||
category: ?command_specs.SlashPresentationCategory = null,
|
||||
@@ -461,6 +473,7 @@ pub const RenderContext = struct {
|
||||
settings_menu: SettingsMenuProjection = .{},
|
||||
model_menu: ModelMenuProjection = .{},
|
||||
session_menu: SessionMenuProjection = .{},
|
||||
fork_menu: ForkMenuProjection = .{},
|
||||
statusline_menu: StatuslineMenuProjection = .{},
|
||||
usage_menu: UsageMenuProjection = .{},
|
||||
workspace_menu: WorkspaceMenuProjection = .{},
|
||||
@@ -487,9 +500,12 @@ pub fn activeCompactCommandMenu(ctx: RenderContext) ?CompactCommandMenuProjectio
|
||||
|
||||
pub const steering_composer_gap_rows: u16 = 1;
|
||||
pub const max_steering_message_rows: u16 = 2;
|
||||
/// Rows a message layout holds: steering previews show two, fork menu
|
||||
/// prompts three.
|
||||
pub const max_message_layout_rows: u16 = 3;
|
||||
|
||||
const SteeringMessageLayout = struct {
|
||||
rows: [max_steering_message_rows][]const u8 = @splat(""),
|
||||
pub const SteeringMessageLayout = struct {
|
||||
rows: [max_message_layout_rows][]const u8 = @splat(""),
|
||||
row_count: u16 = 0,
|
||||
content_width: u16,
|
||||
truncated: bool = false,
|
||||
@@ -522,7 +538,7 @@ pub fn steering_message_layout(
|
||||
var layout: SteeringMessageLayout = .{
|
||||
.content_width = if (waits_for_boundary) width -| 2 else width,
|
||||
};
|
||||
const limit = @min(row_limit, max_steering_message_rows);
|
||||
const limit = @min(row_limit, max_message_layout_rows);
|
||||
var offset: usize = 0;
|
||||
while (layout.row_count < limit) {
|
||||
const start = offset;
|
||||
|
||||
@@ -22,6 +22,7 @@ const settings_menu_presentation = @import("settings_menu_presentation.zig");
|
||||
const mcp_menu_presentation = @import("mcp_menu_presentation.zig");
|
||||
const mcp_host_menu_presentation = @import("mcp_host_menu_presentation.zig");
|
||||
const resume_menu_presentation = @import("resume_menu_presentation.zig");
|
||||
const fork_menu_presentation = @import("fork_menu_presentation.zig");
|
||||
const question_ui = @import("question_ui.zig");
|
||||
const render_input = @import("render_input.zig");
|
||||
const surface_invalidation = @import("surface_invalidation.zig");
|
||||
@@ -402,8 +403,9 @@ fn buildFooterSurfaceProjection(
|
||||
const show_mcp_menu = !viewer_active and !show_auth_picker and !show_settings_menu and !modal_active and (ctx.mcp_menu.state.active or ctx.mcp_host_menu.active());
|
||||
const show_help_menu = !viewer_active and !show_auth_picker and !show_settings_menu and !show_mcp_menu and !modal_active and ctx.help_menu.active;
|
||||
const show_session_menu = !viewer_active and !show_auth_picker and !show_settings_menu and !show_mcp_menu and !show_help_menu and !modal_active and ctx.session_menu.active;
|
||||
const show_models_menu = !viewer_active and !show_auth_picker and !show_settings_menu and !show_mcp_menu and !show_help_menu and !show_session_menu and !modal_active and ctx.model_menu.active;
|
||||
const show_inline_catalog = show_settings_menu or show_mcp_menu or show_help_menu or show_session_menu or show_models_menu;
|
||||
const show_fork_menu = !viewer_active and !show_auth_picker and !show_settings_menu and !show_mcp_menu and !show_help_menu and !show_session_menu and !modal_active and ctx.fork_menu.active();
|
||||
const show_models_menu = !viewer_active and !show_auth_picker and !show_settings_menu and !show_mcp_menu and !show_help_menu and !show_session_menu and !show_fork_menu and !modal_active and ctx.model_menu.active;
|
||||
const show_inline_catalog = show_settings_menu or show_mcp_menu or show_help_menu or show_session_menu or show_fork_menu or show_models_menu;
|
||||
const show_skills_query = !viewer_active and !show_auth_picker and !show_inline_catalog and !modal_active and ctx.skills_menu.active;
|
||||
const show_model_query = !viewer_active and !show_auth_picker and !show_inline_catalog and !show_skills_query and !modal_active and
|
||||
ctx.model_query_active;
|
||||
@@ -500,6 +502,8 @@ fn buildFooterSurfaceProjection(
|
||||
.help
|
||||
else if (show_session_menu)
|
||||
.sessions
|
||||
else if (show_fork_menu)
|
||||
.fork
|
||||
else if (show_models_menu)
|
||||
.models
|
||||
else if (show_slash_query)
|
||||
@@ -573,6 +577,12 @@ fn buildFooterSurfaceProjection(
|
||||
banner_rows,
|
||||
mcp_menu_presentation.max_inline_rows,
|
||||
);
|
||||
const fork_picker_row_budget = picker_presentation.inlinePickerRowBudgetCapped(
|
||||
shell.layout.rows,
|
||||
geometry.input_extra,
|
||||
banner_rows,
|
||||
fork_menu_presentation.max_inline_rows,
|
||||
);
|
||||
const picker_rows: u16 = if (sizing_request) |request|
|
||||
if (request.file) |request_file|
|
||||
approval_ui.fileApprovalPickerRows(request_file)
|
||||
@@ -630,6 +640,12 @@ fn buildFooterSurfaceProjection(
|
||||
shell.layout.cols,
|
||||
expanded_picker_row_budget,
|
||||
)
|
||||
else if (show_fork_menu)
|
||||
fork_menu_presentation.menuRowCount(
|
||||
ctx.fork_menu,
|
||||
shell.layout.cols,
|
||||
fork_picker_row_budget,
|
||||
)
|
||||
else if (show_models_menu)
|
||||
model_menu_presentation.menuRowCount(
|
||||
ctx.model_menu,
|
||||
|
||||
@@ -6324,7 +6324,7 @@ test "slash main page renders header categories selection range and contextual c
|
||||
try renderTestFooter(&h, &input, &approval, &h.frame_redraw);
|
||||
try h.flush();
|
||||
|
||||
try expectGridContains(&h, "Commands 36 · type to filter");
|
||||
try expectGridContains(&h, "Commands 37 · type to filter");
|
||||
try expectGridContains(&h, "1–6");
|
||||
try expectGridContains(&h, "/help");
|
||||
try expectGridContains(&h, "General");
|
||||
@@ -6345,7 +6345,7 @@ test "slash main page renders header categories selection range and contextual c
|
||||
|
||||
try expectGridContains(&h, "ask");
|
||||
try expectGridContains(&h, "test-model");
|
||||
try expectGridNotContains(&h, "Commands 36");
|
||||
try expectGridNotContains(&h, "Commands 37");
|
||||
try expectGridNotContains(&h, "↑↓ navigate");
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -35,7 +35,7 @@ tests/
|
||||
├── session_manager/ one folder per model area, named after the module it describes,
|
||||
├── mcp/ each with a check.sh; tla/README.md lists them all
|
||||
├── ...
|
||||
├── lib/ tlc.sh and run-tlc.sh: find TLC and run configs with their verdicts
|
||||
├── lib/ tlc.sh, tlc.py, and run-tlc.sh: find TLC and run configs with their verdicts
|
||||
└── drivers/ Bun drivers that run real code and have TLC check each run
|
||||
├── lib/ tlc.ts and context.ts, shared by every driver
|
||||
└── <area>/ one folder per model area
|
||||
@@ -79,7 +79,7 @@ python3 -m scripts.pgso.corpus --manifest scripts/pgso/corpus.json --list # fr
|
||||
Every TLA+ spec, its TLC checker, the Bun drivers that walk code along a spec, and the design record a spec checks live in `tests/tla/`, and nowhere else. `tests/tla/README.md` lists the requirements and commands.
|
||||
|
||||
- Name a model's folder after the module it describes: `session_manager/` for `src/core/session_manager/`, `mcp_host/` for `src/core/mcp_host/`, and `sdk/` for libfx.
|
||||
- Give each folder a `check.sh` that runs every config and exits non-zero when one misses its expected verdict. Configs that must fail, such as witnesses and planted mutations, are part of that contract, so encode their verdicts in `check.sh`. Source `../lib/tlc.sh` and list each config with its verdict for `tlc_verdicts`, rather than finding TLC yourself.
|
||||
- Give each folder a `check.sh` that runs every config and exits non-zero when one misses its expected verdict. Configs that must fail, such as witnesses and planted mutations, are part of that contract, so encode their verdicts in `check.sh`. Source `../lib/tlc.sh` and list each config with its verdict for `tlc_verdicts`, rather than finding TLC yourself. A Python checker gets Java and the jar from `toolchain()` in `lib/tlc.py`.
|
||||
- Put Bun drivers in `tests/tla/drivers/<area>/`. They run the code under test, from `zig-out/bin/fx` or by relative import, and have TLC check each run. Use `drivers/lib/tlc.ts` to run TLC and `drivers/lib/context.ts` for the binary, the output folder, and the `tests/e2e/` helpers. Every fx process a driver starts sets `FX_SOUND=0`. Generated output, such as `tests/tla/sdk/graphs/`, goes to the temp folder or is ignored by git.
|
||||
- A model of code that has not landed says so at the top, and the drivers that need that code run only behind an explicit flag, such as `--next` or `FX_TLA_NEXT_MCP=1`. `tests/tla/README.md` lists them. Remove the flag in the change that lands the code.
|
||||
- When a change alters behavior that a spec models, update the spec in the same change and run its checker. CI runs only the `sdk/` model and its drivers; run the others yourself.
|
||||
|
||||
+35
-1
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { createConfiguredProviderFixture, completion as configuredCompletion } from "./fixtures/chat-completions";
|
||||
import { createConfiguredProviderFixture, completion as configuredCompletion, toolCompletion as configuredToolCompletion } from "./fixtures/chat-completions";
|
||||
import { spawn as nodeSpawn, type ChildProcess } from "node:child_process";
|
||||
import {
|
||||
chmodSync,
|
||||
@@ -1041,6 +1041,40 @@ test("configured provider ACP prompts and switching preserve connection credenti
|
||||
}
|
||||
}, 45000);
|
||||
|
||||
test("configured provider ACP runs a direct call to a supplied MCP tool that is not loaded", async () => {
|
||||
const httpFixture = startModernMcpHttpFixture("json");
|
||||
const isReview = (body: any) => body.tools?.some((tool: any) => tool.function?.name === "permission_decision");
|
||||
const fixture = createConfiguredProviderFixture(body => {
|
||||
if (isReview(body)) return configuredToolCompletion(body.model, "permission_decision", { decision: "clear", rationale: "fixture review" });
|
||||
if (body.messages.some((message: any) => message.role === "tool")) return configuredCompletion(body.model, "direct call complete");
|
||||
return configuredToolCompletion(body.model, MCP_TOOL_NAME, { text: "direct" }, "direct_unloaded");
|
||||
});
|
||||
const client = await AcpClient.create({ cwd: fixture.workspace, env: { ...fixture.env, FX_MCP_PROTOCOL_VERSION: "2026-07-28" } });
|
||||
try {
|
||||
await client.request("initialize", { protocolVersion: 1 }, 1);
|
||||
const created = await client.request("session/new", {
|
||||
cwd: fixture.workspace,
|
||||
mcpServers: [{ ...acpHttpServer(httpFixture, "acp"), _meta: { fx: { alwaysLoaded: false } } }],
|
||||
}, 2) as any;
|
||||
if (created.error) throw new Error(JSON.stringify(created));
|
||||
await client.readLine();
|
||||
await client.request("session/set_mode", { modeId: "auto" }, 3);
|
||||
const prompt = await runPrompt(client, "Call the echo tool directly.", TIMEOUT);
|
||||
expect(prompt.promptResult.result.stopReason).toBe("end_turn");
|
||||
const agentRequests = fixture.requests.filter(request => !isReview(request.body));
|
||||
expect(agentRequests).toHaveLength(2);
|
||||
expect(agentRequests[0].body.tools.map((tool: any) => tool.function.name)).not.toContain(MCP_TOOL_NAME);
|
||||
const result = agentRequests[1].body.messages.find((message: any) => message.role === "tool" && message.tool_call_id === "direct_unloaded");
|
||||
expect(result.content).toContain(`${MODERN_HTTP_TOOL_RESULT}:direct`);
|
||||
expect(httpFixture.requests.filter((entry) => entry.message.method === "tools/call")).toHaveLength(1);
|
||||
expect(client.stderr).toBe("");
|
||||
} finally {
|
||||
await client.close();
|
||||
fixture.close();
|
||||
httpFixture.stop();
|
||||
}
|
||||
}, 45000);
|
||||
|
||||
function createIsolatedRoot(prefix: string) {
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), prefix)));
|
||||
const home = join(root, "home");
|
||||
|
||||
+53
-10
@@ -1506,17 +1506,18 @@ describe("cli: usage", () => {
|
||||
},
|
||||
});
|
||||
writeChatGptLogin(fixture.home, "acct_e2e");
|
||||
const env = { ...fixture.env, FX_PROVIDER: "codex", FX_MODEL: "gpt-5.6-sol" };
|
||||
// Times show in the local zone, fixed here at five and a half hours east of UTC.
|
||||
const env = { ...fixture.env, FX_PROVIDER: "codex", FX_MODEL: "codex-e2e-model", TZ: "IST-5:30" };
|
||||
try {
|
||||
const text = await runFx(["usage", "--period", "today"], { env });
|
||||
expect(text.code).toBe(0);
|
||||
expect(text.stderr).toBe("");
|
||||
expect(text.stdout).toContain("Codex usage from ChatGPT for samdoe, updated 15:30 UTC");
|
||||
expect(text.stdout).toContain("Codex usage from ChatGPT for samdoe, updated 21:00 IST");
|
||||
expect(text.stdout).toContain("Tokens 40000 today, 100000 7d, 100000 month, 9000000 lifetime");
|
||||
expect(text.stdout).toContain("Share by model (today): gpt-e2e-large fast 70.0%, gpt-e2e-small standard 30.0%");
|
||||
expect(text.stdout).toContain("Plan pro");
|
||||
expect(text.stdout).toContain("Credits 12.40");
|
||||
expect(text.stdout).toContain("- codex (5h window): 42% used, resets 17:30 UTC");
|
||||
expect(text.stdout).toContain("- codex (5h window): 42% used, resets 23:00 IST");
|
||||
expect(text.stdout).toContain("- codex (168h window): 18% used");
|
||||
const paths = fixture.fake.requests.map((request) => request.path).sort();
|
||||
expect(paths).toEqual([
|
||||
@@ -1550,14 +1551,15 @@ describe("cli: usage", () => {
|
||||
grok: { billing: grokBillingBody(), settings: grokSettingsBody() },
|
||||
});
|
||||
writeGrokLogin(fixture.home, "acct_grok_e2e");
|
||||
const env = { ...fixture.env, FX_PROVIDER: "grok", FX_MODEL: "grok-4.20" };
|
||||
// Times show in the local zone; five and a half hours east, the reset moves to the next day.
|
||||
const env = { ...fixture.env, FX_PROVIDER: "grok", FX_MODEL: "grok-4.20", TZ: "IST-5:30" };
|
||||
try {
|
||||
const text = await runFx(["usage"], { env });
|
||||
expect(text.code).toBe(0);
|
||||
expect(text.stderr).toBe("");
|
||||
expect(text.stdout).toContain("Grok usage from xAI, updated 15:30 UTC");
|
||||
expect(text.stdout).toContain("Grok usage from xAI, updated 21:00 IST");
|
||||
expect(text.stdout).toContain("Plan SuperGrok Heavy");
|
||||
expect(text.stdout).toContain("Used 63% of the weekly allowance, resets Oct 14, 2026 20:30 UTC");
|
||||
expect(text.stdout).toContain("Used 63% of the weekly allowance, resets Oct 15, 2026 02:00 IST");
|
||||
expect(text.stdout).toContain("Credits $12.50");
|
||||
expect(text.stdout).toContain("Pay as you go $3.00 of $100.00 this month");
|
||||
expect(fixture.fake.requests.map((request) => request.path).sort()).toEqual(["/billing", "/settings"]);
|
||||
@@ -2545,21 +2547,22 @@ describe("cli: sessions", () => {
|
||||
turns: ["review the landing page"],
|
||||
});
|
||||
|
||||
// Updated times show in the local zone, fixed here at five and a half hours east of UTC.
|
||||
const first = await runFx(["sessions"], {
|
||||
cwd: workspaceRoot,
|
||||
env: { HOME: home, ...NO_GATEWAY_AUTH },
|
||||
env: { HOME: home, ...NO_GATEWAY_AUTH, TZ: "IST-5:30" },
|
||||
timeoutMs: TIMEOUT,
|
||||
});
|
||||
expect(first.code).toBe(0);
|
||||
expect(first.stderr).toBe("");
|
||||
expect(first.stdout).toContain(
|
||||
" - Investigate cache misses\n id=named-session | 2 turns | English | updated 1970-01-01 00:00:00.003 UTC",
|
||||
" - Investigate cache misses\n id=named-session | 2 turns | English | updated 1970-01-01 05:30:00.003 IST",
|
||||
);
|
||||
expect(first.stdout).toContain(
|
||||
" - Untitled session\n id=unnamed-session | 0 turns | English | updated 1970-01-01 00:00:00.002 UTC",
|
||||
" - Untitled session\n id=unnamed-session | 0 turns | English | updated 1970-01-01 05:30:00.002 IST",
|
||||
);
|
||||
expect(first.stdout).toContain(
|
||||
" - Review landing page\n id=script-only-session | 1 turn | Latin script | updated 2023-11-14 22:13:20.123 UTC",
|
||||
" - Review landing page\n id=script-only-session | 1 turn | Latin script | updated 2023-11-15 03:43:20.123 IST",
|
||||
);
|
||||
expect(first.stdout).not.toContain("updated_at_ms");
|
||||
expect(first.stdout).not.toContain("language=");
|
||||
@@ -2601,6 +2604,46 @@ describe("cli: sessions", () => {
|
||||
TIMEOUT,
|
||||
);
|
||||
|
||||
test(
|
||||
"fx sessions text applies daylight saving time for each session's own date",
|
||||
async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "fx-e2e-session-dst-"));
|
||||
try {
|
||||
const home = join(root, "home");
|
||||
const workspace = join(root, "workspace");
|
||||
mkdirSync(workspace);
|
||||
const workspaceRoot = realpathSync(workspace);
|
||||
// 2023-07-10 14:40:00 UTC, during daylight saving time.
|
||||
writeConversationSession(home, workspaceRoot, "summer-session", {
|
||||
title: "Summer session",
|
||||
updatedAtMs: 1_689_000_000_000,
|
||||
});
|
||||
// 2023-11-14 22:13:20.123 UTC, after daylight saving time ends.
|
||||
writeConversationSession(home, workspaceRoot, "winter-session", {
|
||||
title: "Winter session",
|
||||
updatedAtMs: 1_700_000_000_123,
|
||||
});
|
||||
|
||||
const listed = await runFx(["sessions"], {
|
||||
cwd: workspaceRoot,
|
||||
env: { HOME: home, ...NO_GATEWAY_AUTH, TZ: "EST5EDT,M3.2.0,M11.1.0" },
|
||||
timeoutMs: TIMEOUT,
|
||||
});
|
||||
expect(listed.code).toBe(0);
|
||||
expect(listed.stderr).toBe("");
|
||||
expect(listed.stdout).toContain(
|
||||
" - Summer session\n id=summer-session | 0 turns | English | updated 2023-07-10 10:40:00.000 EDT",
|
||||
);
|
||||
expect(listed.stdout).toContain(
|
||||
" - Winter session\n id=winter-session | 0 turns | English | updated 2023-11-14 17:13:20.123 EST",
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
TIMEOUT,
|
||||
);
|
||||
|
||||
test(
|
||||
"session listing pages direct metadata without an index",
|
||||
async () => {
|
||||
|
||||
@@ -9,6 +9,15 @@ async function withReasoning(response: Response, ...deltas: Record<string, unkno
|
||||
return new Response(prefix + await response.text(), { headers: response.headers });
|
||||
}
|
||||
|
||||
// Tool calls whose arguments are sent verbatim, so they can be malformed.
|
||||
function rawToolCalls(model: string, calls: Array<{ id: string; name: string; arguments: string }>) {
|
||||
const chunks = [
|
||||
{ id: "chat-tool", model, choices: [{ index: 0, delta: { tool_calls: calls.map((call, index) => ({ index, id: call.id, type: "function", function: { name: call.name, arguments: call.arguments } })) }, finish_reason: null }] },
|
||||
{ id: "chat-tool", model, choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] },
|
||||
];
|
||||
return new Response(chunks.map(value => `data: ${JSON.stringify(value)}\n\n`).join("") + "data: [DONE]\n\n", { headers: { "content-type": "text/event-stream" } });
|
||||
}
|
||||
|
||||
describe("configured providers", () => {
|
||||
test.each(["reasoning", "reasoning_content"])("replays %s through a tool result and saved-session continuation", async field => {
|
||||
let calls = 0;
|
||||
@@ -209,6 +218,31 @@ describe("configured providers", () => {
|
||||
} finally { f.close(); }
|
||||
}, 25000);
|
||||
|
||||
test("unknown tools and malformed arguments are answered and the turn continues", async () => {
|
||||
const f = fixture(body => body.messages.some((message: any) => message.role === "tool")
|
||||
? completion(body.model, "recovered")
|
||||
: rawToolCalls(body.model, [
|
||||
{ id: "call-read", name: "read_file", arguments: JSON.stringify({ path: "note.txt" }) },
|
||||
{ id: "call-missing", name: "missing_tool", arguments: "{}" },
|
||||
{ id: "call-malformed", name: "read_file", arguments: "{\"path\":\"note.txt\"" },
|
||||
]));
|
||||
try {
|
||||
writeFileSync(join(f.workspace, "note.txt"), "fixture contents");
|
||||
const result = await runFx(["ask", "--json", "--no-save", "Read note.txt"], { cwd: f.workspace, env: f.env, timeoutMs: 20000 });
|
||||
if (result.code !== 0) throw new Error(result.stdout + result.stderr);
|
||||
expect(JSON.parse(result.stdout).output).toBe("recovered");
|
||||
expect(f.requests).toHaveLength(2);
|
||||
const messages = f.requests[1].body.messages;
|
||||
const replayed = messages.find((message: any) => message.role === "assistant" && message.tool_calls?.length).tool_calls;
|
||||
expect(replayed.map((call: any) => call.function.arguments)).toEqual([JSON.stringify({ path: "note.txt" }), "{}", "{}"]);
|
||||
const results = Object.fromEntries(messages.filter((message: any) => message.role === "tool").map((message: any) => [message.tool_call_id, message.content]));
|
||||
expect(results["call-read"]).toContain("fixture contents");
|
||||
expect(results["call-missing"]).toBe("Unsupported tool: missing_tool");
|
||||
expect(results["call-malformed"]).toContain("did not run the call");
|
||||
expect(results["call-malformed"]).not.toContain("fixture contents");
|
||||
} finally { f.close(); }
|
||||
}, 25000);
|
||||
|
||||
test("saved sessions resume the named connection and reject endpoint rebinding", async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
@@ -336,13 +370,14 @@ describe("configured providers", () => {
|
||||
} finally { f.close(); }
|
||||
}, 35000);
|
||||
|
||||
test.each(["clear", "caution", "missing", "refused", "truncated", "incomplete"])("custom review response recovery preserves %s outcome", async outcome => {
|
||||
test.each(["clear", "caution", "missing", "malformed", "refused", "truncated", "incomplete"])("custom review response recovery preserves %s outcome", async outcome => {
|
||||
let marker = "";
|
||||
let reviews = 0;
|
||||
const f = fixture(body => {
|
||||
if (body.tools?.some((tool: any) => tool.function?.name === "permission_decision")) {
|
||||
reviews++;
|
||||
if (reviews === 1 || (outcome === "missing" && reviews === 2)) {
|
||||
if (outcome === "malformed") return rawToolCalls(body.model, [{ id: "review-1", name: "permission_decision", arguments: "{\"decision\":\"clear\"" }]);
|
||||
if (outcome === "refused" || outcome === "truncated" || outcome === "incomplete") {
|
||||
const chunks = [
|
||||
{ id: "review", model: body.model, choices: [{ index: 0, delta: outcome === "refused" ? { refusal: "Cannot review this." } : { content: "Incomplete review." }, finish_reason: null }] },
|
||||
@@ -364,8 +399,8 @@ describe("configured providers", () => {
|
||||
f.save();
|
||||
const result = await runFx(["ask", "--json", "--no-save", "Run the local fixture once."], { cwd: f.workspace, env: f.env, timeoutMs: 15000 });
|
||||
if (result.code !== 0) throw new Error(result.stdout + result.stderr);
|
||||
expect(readFileSync(marker, "utf8")).toBe(outcome === "clear" ? "one\n" : "");
|
||||
expect(reviews).toBe(["clear", "caution", "missing"].includes(outcome) ? 2 : 1);
|
||||
expect(readFileSync(marker, "utf8")).toBe(["clear", "malformed"].includes(outcome) ? "one\n" : "");
|
||||
expect(reviews).toBe(["clear", "caution", "missing", "malformed"].includes(outcome) ? 2 : 1);
|
||||
const requests = f.requests.filter(request => request.body.tools?.some((tool: any) => tool.function?.name === "permission_decision"));
|
||||
if (reviews === 2) expect(requests[1].body).toEqual(requests[0].body);
|
||||
expect(f.requests.every(request => request.path === "/v1/chat/completions")).toBe(true);
|
||||
|
||||
@@ -6356,6 +6356,8 @@ printf '%s' ${JSON.stringify(trailingMarker)} > ${JSON.stringify(effectPath)}
|
||||
const pane = await tui.waitForPane((text) => hasEmptyComposer(text) && (text.includes("RETRIEVAL_TURN_COMPLETE") || text.includes("request failed:")), 30000);
|
||||
expect(pane).not.toContain("request failed:");
|
||||
expect(pane).toContain("RETRIEVAL_TURN_COMPLETE");
|
||||
// The read names the command whose saved output it searches.
|
||||
expect(pane).toContain("cat source.txt output for \"RETRIEVAL_MATCH\"");
|
||||
if (trigger === "manual") {
|
||||
await compactAndWait(tui, root, 20000);
|
||||
}
|
||||
@@ -6391,7 +6393,9 @@ printf '%s' ${JSON.stringify(trailingMarker)} > ${JSON.stringify(effectPath)}
|
||||
expect(readFileSync(join(toolResults, "compacted-T1.txt"), "utf8")).toContain(sourceHandle);
|
||||
const resumed = await runFx(["ask", "--json", "--resume-id", sessionId, "Recover the clipped tail from the saved retrieval without rerunning the command."], { cwd: root.workspace, env, timeoutMs: 30000 });
|
||||
expect(resumed.code).toBe(0);
|
||||
expect(resumed.stderr).toBe("Reading tool result\n");
|
||||
// Compaction folded the command into T1, so the read has no call
|
||||
// left to name.
|
||||
expect(resumed.stderr).toBe("Reading more command output\n");
|
||||
expect(JSON.parse(resumed.stdout).final_output).toBe("RETRIEVAL_RESTART_COMPLETE");
|
||||
expect(gateway.requests).toHaveLength(8);
|
||||
expect(readFileSync(join(root.workspace, "effects.txt"), "utf8")).toBe("once\n");
|
||||
|
||||
@@ -117,7 +117,7 @@ describe.skipIf(!tmuxAvailable())("prompt history", () => {
|
||||
await session.sendText("PLAN10_PROMPT_HISTORY_SENTINEL");
|
||||
await session.waitForText("HTTP 401", TIMEOUT);
|
||||
await session.sendText("/help");
|
||||
await session.waitForText("Commands 36", TIMEOUT);
|
||||
await session.waitForText("Commands 37", TIMEOUT);
|
||||
await session.sendKeys("Escape");
|
||||
await session.waitForPane((pane) => !pane.includes("enter open"), TIMEOUT);
|
||||
await session.sendText("/quit");
|
||||
|
||||
@@ -4,6 +4,8 @@ import { createHash } from "node:crypto";
|
||||
import {
|
||||
appendFileSync,
|
||||
chmodSync,
|
||||
copyFileSync,
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
@@ -16,9 +18,10 @@ import {
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { basename, join } from "node:path";
|
||||
import { FX_BIN, runFx } from "../evals/eval-helpers";
|
||||
import {
|
||||
composerContains,
|
||||
FAKE_GATEWAY_MODEL,
|
||||
type FakeGatewayOptions,
|
||||
fakeGatewayFinalText,
|
||||
@@ -26,6 +29,7 @@ import {
|
||||
fakeGatewayToolCall,
|
||||
fakeShellRun,
|
||||
heldFakeGatewayFinalText,
|
||||
isComposerLine,
|
||||
startDynamicFakeGateway,
|
||||
startFakeGateway,
|
||||
TmuxSession,
|
||||
@@ -1275,6 +1279,131 @@ test.skipIf(!tmuxAvailable())("the picker shows a session open in another fx as
|
||||
}
|
||||
}, TIMEOUT * 4);
|
||||
|
||||
/// The env that traces one launch's session wiring to `trace`.
|
||||
function wiringTrace(trace: string) {
|
||||
return { FX_TRACE_LOG: trace, FX_TRACE_SCOPES: "wiring" };
|
||||
}
|
||||
|
||||
/// With FX_E2E_TLA_DIR set, keeps a run's traces and saved logs for offline
|
||||
/// checking: `<dir>/<test name>/*.wiring.log` and `<dir>/<test name>/sessions-v2/`.
|
||||
function keepRunForOfflineCheck(fixture: Fixture, name: string, traces: string[]) {
|
||||
const dir = process.env.FX_E2E_TLA_DIR;
|
||||
if (!dir) return;
|
||||
const kept = join(dir, name.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, ""));
|
||||
mkdirSync(kept, { recursive: true });
|
||||
for (const trace of traces) if (existsSync(trace)) copyFileSync(trace, join(kept, basename(trace)));
|
||||
if (existsSync(v2Root(fixture))) cpSync(v2Root(fixture), join(kept, "sessions-v2"), { recursive: true });
|
||||
}
|
||||
|
||||
/// The fork menu's rows below the composer, each with whether it is selected.
|
||||
async function forkMenuRows(session: TmuxSession) {
|
||||
const lines = (await session.capturePaneEscapes()).split("\n");
|
||||
const plain = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
|
||||
const composer = plain.findLastIndex(isComposerLine);
|
||||
return plain
|
||||
.map((text, index) => ({ text: text.trim(), selected: lines[index]!.includes("\x1b[1m") }))
|
||||
.slice(composer + 1)
|
||||
.filter((row) => row.text.startsWith("Fork prompt"));
|
||||
}
|
||||
|
||||
const forkTest = "/fork starts a session before a chosen prompt and leaves the source as it was";
|
||||
test.skipIf(!tmuxAvailable())(forkTest, async () => {
|
||||
const fixture = createFixture("fx-v2-app-fork-");
|
||||
const trace = join(fixture.root, "app.wiring.log");
|
||||
const gateway = replyToLatest([
|
||||
["Fork prompt one.", "FORK_ONE"],
|
||||
["Fork prompt two.", "FORK_TWO"],
|
||||
["Fork prompt three.", "FORK_THREE"],
|
||||
["A new second prompt in the fork.", "FORK_NEW_SECOND"],
|
||||
]);
|
||||
try {
|
||||
const app = await startApp(fixture, gateway, [], true, wiringTrace(trace));
|
||||
for (const [prompt, answer] of [["Fork prompt one.", "FORK_ONE"], ["Fork prompt two.", "FORK_TWO"], ["Fork prompt three.", "FORK_THREE"]]) {
|
||||
await app.session.sendText(prompt!);
|
||||
await app.session.waitForText(answer!, TIMEOUT);
|
||||
}
|
||||
await app.session.sendText("/rename Fork source");
|
||||
await app.session.waitForText('renamed to "Fork source"', TIMEOUT);
|
||||
await app.session.waitForStableComposer();
|
||||
const source = onlySession(fixture);
|
||||
const sourcePath = join(v2Root(fixture), source, "log.jsonl");
|
||||
const sourceLog = readFileSync(sourcePath);
|
||||
|
||||
await app.session.sendText("/fork");
|
||||
await app.session.waitForPane((pane) => pane.includes("enter fork"), TIMEOUT);
|
||||
expect(await forkMenuRows(app.session)).toEqual([
|
||||
{ text: "Fork prompt one.", selected: false },
|
||||
{ text: "Fork prompt two.", selected: false },
|
||||
{ text: "Fork prompt three.", selected: true },
|
||||
]);
|
||||
await app.session.sendKeys("Up");
|
||||
expect((await forkMenuRows(app.session)).map((row) => row.selected)).toEqual([false, true, false]);
|
||||
await app.session.sendKeys("Enter");
|
||||
|
||||
expect(await scrollbackContains(app.session, `original session fx --sessions-v2 --resume ${source}`)).toContain("* session forked: Fork source (fork)");
|
||||
await app.session.waitForPane((pane) => composerContains(pane, "Fork prompt two.") && !pane.includes("enter fork"), TIMEOUT);
|
||||
const ids = savedSessions(fixture);
|
||||
expect(ids.length).toBe(2);
|
||||
const fork = ids.find((id) => id !== source)!;
|
||||
expect(logLines(fixture, fork).filter((line) => line.kind === "turn_committed").length).toBe(1);
|
||||
const forkLog = readFileSync(join(v2Root(fixture), fork, "log.jsonl"), "utf8");
|
||||
expect(forkLog).toContain("Fork prompt one.");
|
||||
expect(forkLog).not.toContain("Fork prompt two.");
|
||||
expect(storedTitles(fixture, fork).at(-1)).toBe("Fork source (fork)");
|
||||
// The fork wrote nothing into the source; closing it on the switch adds
|
||||
// only the manager's clean-close line.
|
||||
const sourceAfterFork = readFileSync(sourcePath);
|
||||
expect(sourceAfterFork.subarray(0, sourceLog.length).equals(sourceLog)).toBe(true);
|
||||
const added = sourceAfterFork.subarray(sourceLog.length).toString("utf8").trimEnd().split("\n");
|
||||
expect(added.map((line) => JSON.parse(line).kind)).toEqual(["closed"]);
|
||||
|
||||
// The next prompt goes on in the fork; the source stays as it was.
|
||||
await app.session.sendKeys("C-u");
|
||||
await app.session.sendText("A new second prompt in the fork.");
|
||||
await app.session.waitForText("FORK_NEW_SECOND", TIMEOUT);
|
||||
await quitApp(app);
|
||||
expect(readFileSync(join(v2Root(fixture), fork, "log.jsonl"), "utf8")).toContain("A new second prompt in the fork.");
|
||||
expect(readFileSync(sourcePath).equals(sourceAfterFork)).toBe(true);
|
||||
expect(gateway.requests.at(-1)!.body).not.toContain("Fork prompt two.");
|
||||
for (const id of ids) expectWholeLog(fixture, id);
|
||||
expectNoV1Sessions(fixture);
|
||||
} finally {
|
||||
keepRunForOfflineCheck(fixture, forkTest, [trace]);
|
||||
gateway.stop();
|
||||
rmSync(fixture.root, { recursive: true, force: true });
|
||||
}
|
||||
}, TIMEOUT * 4);
|
||||
|
||||
const forkWithoutV2Test = "/fork without --sessions-v2 says forking needs it";
|
||||
test.skipIf(!tmuxAvailable())(forkWithoutV2Test, async () => {
|
||||
const fixture = createFixture("fx-v2-app-fork-v1-");
|
||||
const trace = join(fixture.root, "app.wiring.log");
|
||||
const gateway = replyToLatest([]);
|
||||
const stderrPath = join(fixture.root, "stderr.log");
|
||||
writeFileSync(stderrPath, "");
|
||||
let session: TmuxSession | null = null;
|
||||
try {
|
||||
session = await TmuxSession.create({
|
||||
cmd: FX_BIN,
|
||||
cwd: fixture.workspace,
|
||||
env: { ...env(fixture, gateway, false), NO_COLOR: "1", ...wiringTrace(trace) },
|
||||
stderrPath,
|
||||
});
|
||||
await session.waitForComposer(TIMEOUT);
|
||||
await session.sendText("/fork");
|
||||
await session.waitForText("! session: forking needs --sessions-v2", TIMEOUT);
|
||||
expect(await session.capturePane()).not.toContain("enter fork");
|
||||
await session.sendText("/quit");
|
||||
expect(await session.waitForSessionEnd()).toBe(true);
|
||||
expect(readFileSync(stderrPath, "utf8")).toBe("");
|
||||
} finally {
|
||||
if (session) await session.kill();
|
||||
keepRunForOfflineCheck(fixture, forkWithoutV2Test, [trace]);
|
||||
gateway.stop();
|
||||
rmSync(fixture.root, { recursive: true, force: true });
|
||||
}
|
||||
}, TIMEOUT * 2);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ACP
|
||||
|
||||
@@ -2752,7 +2881,12 @@ test.skipIf(!tmuxAvailable())("text typed while the app answers steers that turn
|
||||
await resumed.session.sendText("Continue after the steered turn.");
|
||||
await resumed.session.waitForText("AFTER_STEERING_RESUME", TIMEOUT);
|
||||
await quitApp(resumed);
|
||||
expect(gateway.requests.at(-1)!.body).toContain(steering);
|
||||
// The resumed session sends the steering as the live update the model
|
||||
// saw, not as a plain user message.
|
||||
const continued = gateway.requests.at(-1)!.body;
|
||||
expect(continued).toContain(steering);
|
||||
expect(continued).toContain("<user_steering>");
|
||||
expect(continued.indexOf("<user_steering>")).toBeLessThan(continued.indexOf(steering));
|
||||
expectWholeLog(fixture, id);
|
||||
} finally {
|
||||
hold.release?.();
|
||||
|
||||
@@ -1409,7 +1409,8 @@ export class TmuxSession {
|
||||
rmSync(this.exitStatusPath, { force: true });
|
||||
}
|
||||
|
||||
private recordedExitStatus(): number | null {
|
||||
/// The fx process's exit status once the pane has ended, or null.
|
||||
recordedExitStatus(): number | null {
|
||||
try {
|
||||
const value = Number.parseInt(readFileSync(this.exitStatusPath, "utf8").trim(), 10);
|
||||
return Number.isSafeInteger(value) && value >= 0 && value <= 255 ? value : null;
|
||||
|
||||
@@ -1406,7 +1406,7 @@ async function runCanonicalLifecycleFixture(
|
||||
reachedFinal = settled.matched;
|
||||
if (reachedFinal) {
|
||||
await session.sendText("/help");
|
||||
const help = await waitForPaneOrDone(session, "Commands 36", donePath);
|
||||
const help = await waitForPaneOrDone(session, "Commands 37", donePath);
|
||||
helpVisible = help.matched;
|
||||
requestCountAfterHelp = queuedGateway.requests.length;
|
||||
if (helpVisible) {
|
||||
@@ -3178,7 +3178,7 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => {
|
||||
const savedSession = readSavedSession();
|
||||
|
||||
expect(pane).toContain(finalText);
|
||||
expect(scrollback).toContain("Failed tool result");
|
||||
expect(scrollback).toContain("Failed more output");
|
||||
expect(continuation).toContain(expectedFailure);
|
||||
expect(savedSession).toContain('"status":"failure"');
|
||||
expect(savedSession).toContain(expectedFailure);
|
||||
@@ -5186,6 +5186,104 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => {
|
||||
TIMEOUT,
|
||||
);
|
||||
|
||||
test(
|
||||
"parallel saved-output reads name the command they read",
|
||||
async () => {
|
||||
root = realpathSync(mkdtempSync(join(tmpdir(), "fx-parallel-saved-reads-")));
|
||||
const home = join(root, "home");
|
||||
const workspace = join(root, "workspace");
|
||||
const stderrPath = join(root, "stderr.log");
|
||||
const tracePath = join(root, "trace.log");
|
||||
mkdirSync(join(home, ".fx"), { recursive: true });
|
||||
mkdirSync(workspace);
|
||||
writeFileSync(join(home, ".fx", "settings.json"), "{}");
|
||||
// Large enough that fx saves the command output behind a handle.
|
||||
const lines = Array.from({ length: 4000 }, (_, index) => `SAVED_LINE_${String(index).padStart(5, "0")}`);
|
||||
lines[2500] = "PARALLEL_READ_MATCH";
|
||||
writeFileSync(join(workspace, "source.txt"), `${lines.join("\n")}\n`);
|
||||
|
||||
const readGateway = startDynamicFakeGateway((body) => {
|
||||
const request = JSON.parse(body) as {
|
||||
prompt: Array<{ role: string; content: unknown }>;
|
||||
};
|
||||
const parts = request.prompt.flatMap((message) =>
|
||||
Array.isArray(message.content) ? message.content : [],
|
||||
) as Array<Record<string, unknown>>;
|
||||
const output = (callId: string): string | null => {
|
||||
const part = parts.find((candidate) =>
|
||||
candidate.type === "tool-result" && candidate.toolCallId === callId,
|
||||
);
|
||||
return part ? contentText(part.output) : null;
|
||||
};
|
||||
if (output("parallel_read_query") !== null && output("parallel_read_range") !== null) {
|
||||
return fakeGatewayFinalText("PARALLEL_READS_FINISHED");
|
||||
}
|
||||
const source = output("parallel_read_source");
|
||||
if (source === null) {
|
||||
return fakeGatewayToolCall("parallel_read_source", "shell", {
|
||||
request: { action: "run", command: "cat source.txt", profile: "clean", yield_time_ms: 30000 },
|
||||
});
|
||||
}
|
||||
const handle = (JSON.parse(source) as { full_output_handle: string | null }).full_output_handle;
|
||||
if (!handle) return new Response("missing saved output handle", { status: 500 });
|
||||
return fakeGatewaySse([
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "parallel_read_query",
|
||||
toolName: "read_tool_result",
|
||||
input: { request: { handle, query: "PARALLEL_READ_MATCH" } },
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "parallel_read_range",
|
||||
toolName: "read_tool_result",
|
||||
input: { request: { handle, start_byte: 20000, byte_count: 1024 } },
|
||||
},
|
||||
{
|
||||
type: "finish",
|
||||
finishReason: { unified: "tool-calls", raw: "tool-calls" },
|
||||
},
|
||||
]);
|
||||
}, { classifierDecision: "clear" });
|
||||
gateway = readGateway;
|
||||
session = await TmuxSession.create({
|
||||
cwd: workspace,
|
||||
width: 110,
|
||||
height: 35,
|
||||
stderrPath,
|
||||
env: {
|
||||
HOME: home,
|
||||
AI_GATEWAY_API_KEY: "fake-parallel-saved-reads-key",
|
||||
VERCEL_OIDC_TOKEN: undefined,
|
||||
FX_DISABLE_KEYCHAIN: "1",
|
||||
FX_SOUND: "0",
|
||||
FX_AUTO_UPGRADE: "0",
|
||||
FX_PERMISSION_MODE: "auto",
|
||||
FX_GATEWAY_BASE_URL: readGateway.baseUrl,
|
||||
FX_GATEWAY_CHAT_URL: readGateway.chatUrl,
|
||||
FX_E2E_GATEWAY_CHAT_URL: readGateway.chatUrl,
|
||||
FX_MODEL: MODEL,
|
||||
FX_TRACE_LOG: tracePath,
|
||||
FX_TRACE_SCOPES: "tool",
|
||||
},
|
||||
});
|
||||
|
||||
await session.waitForComposer(TIMEOUT);
|
||||
await session.sendText("Run the fixture, then search and page its saved output.");
|
||||
await session.waitForText("PARALLEL_READS_FINISHED", TIMEOUT);
|
||||
const scrollback = await session.captureFullScrollback();
|
||||
|
||||
// Both reads ran as one parallel group and still name their source.
|
||||
expect(readFileSync(tracePath, "utf8")).toMatch(/parallel_tool_group_start[^\n]*count=2/);
|
||||
expect(scrollback).toContain("Searched cat source.txt output for \"PARALLEL_READ_MATCH\"");
|
||||
expect(scrollback).toContain("Read more output from cat source.txt");
|
||||
expect(readFileSync(stderrPath, "utf8")).toBe("");
|
||||
await session.sendText("/quit");
|
||||
expect(await session.waitForSessionEnd(TIMEOUT)).toBe(true);
|
||||
},
|
||||
TIMEOUT * 2,
|
||||
);
|
||||
|
||||
test(
|
||||
"separate provider messages render as separate paragraphs",
|
||||
async () => {
|
||||
|
||||
@@ -322,7 +322,7 @@ tmuxTest(
|
||||
await waitForExactComposerRow(active, "┃ /");
|
||||
|
||||
await active.sendKeys("Enter");
|
||||
await active.waitForText("Commands 36", READY_TIMEOUT);
|
||||
await active.waitForText("Commands 37", READY_TIMEOUT);
|
||||
await active.sendKeys("Escape");
|
||||
await active.waitForPane(
|
||||
(pane) => hasEmptyComposer(pane) && !pane.includes("enter open"),
|
||||
@@ -1876,7 +1876,7 @@ tmuxTest(
|
||||
READY_TIMEOUT,
|
||||
);
|
||||
await active.resizeWindow(80, 24, 300);
|
||||
await active.waitForText("Commands 36", READY_TIMEOUT);
|
||||
await active.waitForText("Commands 37", READY_TIMEOUT);
|
||||
expect(gateway?.requests).toHaveLength(0);
|
||||
expectCleanStderr();
|
||||
},
|
||||
|
||||
@@ -114,7 +114,7 @@ describe.skipIf(SKIP)("tui: render stress", () => {
|
||||
|
||||
await session.sendKeys("C-u");
|
||||
await session.sendText("/help");
|
||||
await session.waitForText("Commands 36", 5_000);
|
||||
await session.waitForText("Commands 37", 5_000);
|
||||
await session.sendKeys("Escape");
|
||||
await session.waitForPane((pane) => !pane.includes("enter open"), 5_000);
|
||||
await session.sendText("/status");
|
||||
|
||||
@@ -3192,7 +3192,7 @@ describe.skipIf(SKIP)("tui: resize", () => {
|
||||
await session.waitForText("/help", 10_000);
|
||||
await waitForSelectedSlashLabel(session, "/help");
|
||||
const shrinkStage = await session.captureFullScrollback();
|
||||
expect(shrinkStage).toContain("Commands 36 · type to filter");
|
||||
expect(shrinkStage).toContain("Commands 37 · type to filter");
|
||||
expect(shrinkStage).toContain("1–4");
|
||||
writeFileSync(join(root, "scrollback-after-shrink.txt"), shrinkStage);
|
||||
|
||||
@@ -4169,11 +4169,11 @@ describe.skipIf(SKIP)("tui: resize", () => {
|
||||
async () => {
|
||||
session = await launchAt(120, 40);
|
||||
await session.sendText("/help");
|
||||
await session.waitForText("Commands 36", 5_000);
|
||||
await session.waitForText("Commands 37", 5_000);
|
||||
await session.resizeWindow(76, 24, 400);
|
||||
|
||||
const grid = await session.capturePaneGrid();
|
||||
expect(grid.join("\n")).toContain("Commands 36");
|
||||
expect(grid.join("\n")).toContain("Commands 37");
|
||||
expect(findInlineHelpPicker(grid)).not.toBeNull();
|
||||
|
||||
await session.sendKeys("Escape");
|
||||
@@ -4191,7 +4191,7 @@ describe.skipIf(SKIP)("tui: resize", () => {
|
||||
async () => {
|
||||
session = await launchAt(120, 40);
|
||||
await session.sendText("/help");
|
||||
await session.waitForText("Commands 36", 5_000);
|
||||
await session.waitForText("Commands 37", 5_000);
|
||||
|
||||
const captureScrollback = () =>
|
||||
execSync(`tmux capture-pane -t ${session!.name} -p -S -`, {
|
||||
@@ -4199,7 +4199,7 @@ describe.skipIf(SKIP)("tui: resize", () => {
|
||||
stdio: "pipe",
|
||||
});
|
||||
const expectHelpCatalog = (grid: string[]) => {
|
||||
expect(grid.join("\n")).toContain("Commands 36");
|
||||
expect(grid.join("\n")).toContain("Commands 37");
|
||||
expect(findInlineHelpPicker(grid)).not.toBeNull();
|
||||
};
|
||||
|
||||
@@ -4217,7 +4217,7 @@ describe.skipIf(SKIP)("tui: resize", () => {
|
||||
const restored = captureScrollback();
|
||||
expect(restored.match(/𝒇x v\d+\.\d+\.\d+\b/g)).toHaveLength(1);
|
||||
expect(restored.match(/Run \/help for commands/g)).toHaveLength(1);
|
||||
expect(restored).not.toContain("Commands 36");
|
||||
expect(restored).not.toContain("Commands 37");
|
||||
expect(findFooter(await session.capturePaneGrid())).not.toBeNull();
|
||||
},
|
||||
TIMEOUT,
|
||||
@@ -4749,7 +4749,7 @@ describe.skipIf(SKIP)("tui: resize", () => {
|
||||
expect(await session.captureFullScrollback()).toContain(marker);
|
||||
|
||||
await session.sendText("/help");
|
||||
await session.waitForText("Commands 36", 5_000);
|
||||
await session.waitForText("Commands 37", 5_000);
|
||||
await session.resizeWindow(84, 28, 500);
|
||||
|
||||
const catalog = await session.capturePaneGrid();
|
||||
@@ -4763,7 +4763,7 @@ describe.skipIf(SKIP)("tui: resize", () => {
|
||||
);
|
||||
const scrollback = await session.captureFullScrollback();
|
||||
expect(scrollback).not.toContain(marker);
|
||||
expect(scrollback).not.toContain("Commands 36");
|
||||
expect(scrollback).not.toContain("Commands 37");
|
||||
const finalGrid = await session.capturePaneGrid();
|
||||
expect(findFooter(finalGrid), finalGrid.join("\n")).not.toBeNull();
|
||||
|
||||
|
||||
@@ -1026,7 +1026,8 @@ test.skipIf(!tmuxAvailable())(
|
||||
active = await TmuxSession.create({
|
||||
cmd: `${FX_BIN} --resume-last`,
|
||||
cwd: realpathSync(workspace),
|
||||
env: gatewayEnv(home, resumeGateway),
|
||||
// A fixed zone, so entry headers show this zone's local time on every machine.
|
||||
env: { ...gatewayEnv(home, resumeGateway), TZ: "IST-5:30" },
|
||||
stderrPath,
|
||||
width: 100,
|
||||
height: 32,
|
||||
@@ -1037,7 +1038,7 @@ test.skipIf(!tmuxAvailable())(
|
||||
const full = await active.waitForPane(
|
||||
(pane) =>
|
||||
pane.includes("full detail · ctrl+o close") &&
|
||||
pane.includes("UTC · Usage") &&
|
||||
pane.includes("IST · Usage") &&
|
||||
/\(↑\d+ ↓5\)/.test(pane),
|
||||
TIMEOUT,
|
||||
);
|
||||
@@ -1615,7 +1616,8 @@ test.skipIf(!tmuxAvailable())(
|
||||
active = await TmuxSession.create({
|
||||
cmd: FX_BIN,
|
||||
cwd: realpathSync(workspace),
|
||||
env: { ...gatewayEnv(home, gateway), FX_RECORD: tapePath },
|
||||
// A fixed zone, so entry headers show this zone's local time on every machine.
|
||||
env: { ...gatewayEnv(home, gateway), FX_RECORD: tapePath, TZ: "IST-5:30" },
|
||||
stderrPath,
|
||||
width: 100,
|
||||
height: 32,
|
||||
@@ -1652,7 +1654,7 @@ test.skipIf(!tmuxAvailable())(
|
||||
// The head of a 100-line output never shares a viewport with its tail.
|
||||
expect(before).not.toContain(tailMarker);
|
||||
}
|
||||
if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} UTC · Tool/.test(before)) sawToolHeader = true;
|
||||
if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} IST · Tool/.test(before)) sawToolHeader = true;
|
||||
if (before.includes(commandArgumentTail)) sawCommandArgument = true;
|
||||
if (sawLineOne && sawToolHeader && sawCommandArgument) break;
|
||||
await active.sendKeys("PPage");
|
||||
@@ -3371,7 +3373,8 @@ test.skipIf(!tmuxAvailable())(
|
||||
active = await TmuxSession.create({
|
||||
cmd: FX_BIN,
|
||||
cwd: realpathSync(workspace),
|
||||
env: gatewayEnv(home, gateway),
|
||||
// A fixed zone, so entry headers show this zone's local time on every machine.
|
||||
env: { ...gatewayEnv(home, gateway), TZ: "IST-5:30" },
|
||||
stderrPath,
|
||||
width: 100,
|
||||
height: 40,
|
||||
@@ -3399,13 +3402,13 @@ test.skipIf(!tmuxAvailable())(
|
||||
const grid = fullTranscript.replace(/\n$/, "").split("\n");
|
||||
const before = grid.findIndex((line) => line.includes(beforeMarker));
|
||||
const toolTimestamp = grid.findIndex(
|
||||
(line, index) => index > before && line.includes("UTC · Tool"),
|
||||
(line, index) => index > before && line.includes("IST · Tool"),
|
||||
);
|
||||
const header = grid.findIndex((line) => line.includes("1 tool call"));
|
||||
const tool = grid.findIndex((line) => line.includes("Ran "));
|
||||
const output = grid.findIndex((line) => line.trimStart().startsWith(`│ ${outputMarker}`));
|
||||
const afterTimestamp = grid.findIndex(
|
||||
(line, index) => index > output && line.includes("UTC · Response"),
|
||||
(line, index) => index > output && line.includes("IST · Response"),
|
||||
);
|
||||
const after = grid.findIndex((line) => line.includes(afterMarker));
|
||||
if (
|
||||
@@ -7674,8 +7677,14 @@ test.skipIf(!tmuxAvailable())(
|
||||
await active.waitForStableComposer(TIMEOUT);
|
||||
await active.sendText("Continue the saved conversation without tools.");
|
||||
await active.waitForPane((pane) => pane.includes(reply) && hasEmptyComposer(pane), TIMEOUT);
|
||||
const events = readFileSync(eventsPath, "utf8").trim().split("\n").map((line) => JSON.parse(line).event);
|
||||
expect(events.filter((event) => event.assistant?.text === reply)).toHaveLength(1);
|
||||
// The reply shows as it streams, before the turn saves it.
|
||||
const savedReplies = () => readFileSync(eventsPath, "utf8").trim().split("\n")
|
||||
.map((line) => JSON.parse(line).event)
|
||||
.filter((event) => event.assistant?.text === reply);
|
||||
await waitForCondition(() => {
|
||||
try { return savedReplies().length > 0; } catch { return false; }
|
||||
}, `the saved ${reply}`);
|
||||
expect(savedReplies()).toHaveLength(1);
|
||||
expect(readFileSync(eventsPath).subarray(0, before.length).equals(before)).toBe(true);
|
||||
expect(await active.captureFullScrollback()).not.toContain("FileNotFound");
|
||||
await active.sendText("/quit");
|
||||
|
||||
@@ -1020,7 +1020,7 @@ describe.skipIf(SKIP)("tui: slash menu", () => {
|
||||
).toBe(69);
|
||||
expect(closedComposerRow).toBe(73);
|
||||
await session.sendLiteralText("/");
|
||||
await session.waitForText("Commands 36", 5_000);
|
||||
await session.waitForText("Commands 37", 5_000);
|
||||
const afterSlash = await capture("after-slash");
|
||||
expect(visibleTranscriptTailRow(afterSlash)).toBe(60);
|
||||
expect(composerRow(afterSlash)).toBe(64);
|
||||
@@ -1545,7 +1545,7 @@ describe.skipIf(SKIP)("tui: slash menu", () => {
|
||||
await session.waitForComposer(10_000);
|
||||
|
||||
await session.sendText("/help");
|
||||
let grid = await waitForHelpMenu(session, 36);
|
||||
let grid = await waitForHelpMenu(session, 37);
|
||||
let pane = grid.join("\n");
|
||||
expect(pane).toContain("𝒇x");
|
||||
expect(pane).toContain("Run /help for commands");
|
||||
@@ -1561,7 +1561,7 @@ describe.skipIf(SKIP)("tui: slash menu", () => {
|
||||
grid = await waitForHelpMenu(session, 5);
|
||||
expect(grid.join("\n")).toContain("[General]");
|
||||
await session.sendKeys("BTab");
|
||||
grid = await waitForHelpMenu(session, 36);
|
||||
grid = await waitForHelpMenu(session, 37);
|
||||
expect(grid.join("\n")).toContain("[All]");
|
||||
|
||||
await session.sendLiteralText("clipboard");
|
||||
@@ -1572,11 +1572,11 @@ describe.skipIf(SKIP)("tui: slash menu", () => {
|
||||
expect(pane).not.toContain("/clear");
|
||||
|
||||
await session.sendKeys("C-u");
|
||||
await waitForHelpMenu(session, 36);
|
||||
await waitForHelpMenu(session, 37);
|
||||
await session.sendKeys("Down");
|
||||
await session.sendKeys("Enter");
|
||||
pane = await session.waitForPane(
|
||||
(current) => hasEmptyComposer(current) && !current.includes("Commands 36"),
|
||||
(current) => hasEmptyComposer(current) && !current.includes("Commands 37"),
|
||||
5_000,
|
||||
);
|
||||
expect(composerContains(pane, "/clear")).toBe(false);
|
||||
@@ -1585,7 +1585,7 @@ describe.skipIf(SKIP)("tui: slash menu", () => {
|
||||
|
||||
await session.sendKeys("C-u");
|
||||
await session.sendText("/help");
|
||||
await waitForHelpMenu(session, 36);
|
||||
await waitForHelpMenu(session, 37);
|
||||
await session.sendLiteralText("additional directories");
|
||||
await waitForHelpMenu(session, 1);
|
||||
await session.sendKeys("Enter");
|
||||
@@ -1602,7 +1602,7 @@ describe.skipIf(SKIP)("tui: slash menu", () => {
|
||||
|
||||
await session.sendKeys("C-u");
|
||||
await session.sendText("/help");
|
||||
await waitForHelpMenu(session, 36);
|
||||
await waitForHelpMenu(session, 37);
|
||||
await session.sendLiteralText("no command can match this query");
|
||||
await session.waitForText("No commands found.", 5_000);
|
||||
await session.sendKeys("Escape");
|
||||
@@ -2180,7 +2180,7 @@ describe.skipIf(SKIP)("tui: slash menu", () => {
|
||||
expect(alternateCount("\x1b[?1049l")).toBe(leavesBeforeSkills);
|
||||
|
||||
await session.sendText("/help");
|
||||
grid = await waitForHelpMenu(session, 36);
|
||||
grid = await waitForHelpMenu(session, 37);
|
||||
expect(grid.join("\n")).toContain("Run /help for commands");
|
||||
expect(alternateCount("\x1b[?1049h")).toBe(entersBeforeSkills);
|
||||
expect(alternateCount("\x1b[?1049l")).toBe(leavesBeforeSkills);
|
||||
@@ -3322,7 +3322,7 @@ describe.skipIf(SKIP)("tui: slash menu", () => {
|
||||
await session.waitForComposer(10_000);
|
||||
|
||||
await session.sendLiteralText("/");
|
||||
await session.waitForText("Commands 36", 5_000);
|
||||
await session.waitForText("Commands 37", 5_000);
|
||||
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await session.sendKeys("Down");
|
||||
|
||||
@@ -47,7 +47,7 @@ describe.skipIf(SKIP)("tui: startup and exit", () => {
|
||||
session = await TmuxSession.create();
|
||||
await session.waitForComposer(10_000);
|
||||
await session.sendText("/help");
|
||||
const pane = await session.waitForText("Commands 36", 5_000);
|
||||
const pane = await session.waitForText("Commands 37", 5_000);
|
||||
expect(pane).toContain("[All]");
|
||||
expect(pane).toContain("tab category");
|
||||
expect(pane).toContain("enter open");
|
||||
|
||||
@@ -3397,7 +3397,13 @@ describe("request size budget", () => {
|
||||
|
||||
type SizeRequest = { body: string; images: string[]; tools: boolean; status: number };
|
||||
|
||||
function startSizeCappedGateway(rejection: { status: number; body: string }, images: Map<string, string>) {
|
||||
// Refuses every request over `cap`. By default each answer asks for the next
|
||||
// image with read_file; `describe` answers at once instead.
|
||||
function startSizeCappedGateway(
|
||||
rejection: { status: number; body: string },
|
||||
images: Map<string, string>,
|
||||
{ cap = SIZE_CAP_BYTES, answer = "read" }: { cap?: number; answer?: "read" | "describe" } = {},
|
||||
) {
|
||||
const byData = new Map([...images].map(([name, data]) => [data, name]));
|
||||
const requests: SizeRequest[] = [];
|
||||
let issued = 0;
|
||||
@@ -3415,12 +3421,13 @@ describe("request size budget", () => {
|
||||
const parsed = JSON.parse(body) as { tools?: unknown[] };
|
||||
const tools = Array.isArray(parsed.tools) && parsed.tools.length > 0;
|
||||
const carried = nativeFileParts(body).map((part) => byData.get(part.data.data) ?? "unknown");
|
||||
const status = body.length > SIZE_CAP_BYTES ? rejection.status : 200;
|
||||
const status = body.length > cap ? rejection.status : 200;
|
||||
requests.push({ body, images: carried, tools, status });
|
||||
if (status !== 200) {
|
||||
return new Response(rejection.body, { status, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
if (!tools) return sseText("Summary: the user asked fx to read six images.");
|
||||
if (answer === "describe") return sseText("done: described the images");
|
||||
if (issued < SIZE_IMAGE_COUNT) {
|
||||
issued += 1;
|
||||
return sseToolCall("read_file", { path: `img0${issued}.png` }, `call_img_${issued}`);
|
||||
@@ -3498,4 +3505,37 @@ describe("request size budget", () => {
|
||||
}
|
||||
}, TIMEOUT * 3);
|
||||
}
|
||||
|
||||
test("a size limit two retries can't reach still leaves out images instead of compacting", async () => {
|
||||
const root = createIsolatedRoot();
|
||||
const images = writeSizeImages(root.workspace);
|
||||
// Six images of about 1 MB in one prompt against a 1.5 MB limit: each
|
||||
// refusal lowers fx's limit by a quarter, so the request fits only after
|
||||
// a third refusal leaves out all but the newest image.
|
||||
const cap = 1_500_000;
|
||||
const gateway = startSizeCappedGateway({ status: 413, body: payloadTooLargeBody }, images, { cap, answer: "describe" });
|
||||
try {
|
||||
const imageArgs = [...images.keys()].flatMap((name) => ["--image", join(root.workspace, name)]);
|
||||
const result = await runFx(["ask", "--json", "--auto", "--no-color", ...imageArgs, "Describe these images."], {
|
||||
cwd: root.workspace,
|
||||
env: gateway.env(root),
|
||||
timeoutMs: TIMEOUT * 2,
|
||||
});
|
||||
const json = parseFxJson(result);
|
||||
expect(json.output).toContain("done: described the images");
|
||||
|
||||
const rejected = gateway.requests.filter((request) => request.status !== 200);
|
||||
expect(rejected.length).toBeGreaterThan(2);
|
||||
// No summary request: the conversation was not compacted.
|
||||
expect(gateway.requests.every((request) => request.tools)).toBe(true);
|
||||
const accepted = gateway.requests.at(-1)!;
|
||||
expect(accepted.status).toBe(200);
|
||||
expect(accepted.body.length).toBeLessThanOrEqual(cap);
|
||||
expect(accepted.images).toEqual(["img06.png"]);
|
||||
expect(accepted.body).toContain(SIZE_NOTICE);
|
||||
} finally {
|
||||
gateway.stop();
|
||||
rmSync(root.root, { recursive: true, force: true });
|
||||
}
|
||||
}, TIMEOUT * 3);
|
||||
});
|
||||
+3
-3
@@ -10,13 +10,13 @@ TLA+ specifications for fx, checked with TLC, together with the scripts and Bun
|
||||
| `indexer/` | When a scanned root's listing may be reused, merging parallel grep results, the file picker's loading row, and listing snapshots (`src/core/indexer/`). | `indexer/check.sh` |
|
||||
| `mcp/` | The MCP-v2 engine (`src/mcp/`): wire format, requests, stdio, version detection, the tool catalog, both HTTP transports, input-required rounds, authorization, subscriptions, elicitation, and the engine. `rules.md` lists the MCP specification rules the models cite by ID. | `mcp/check.sh`, then the drivers in `drivers/mcp/` |
|
||||
| `mcp_host/` | fx's MCP host (`src/core/mcp_host/`): host lifecycle, saved credentials, logins shared between processes, sign-in, and elicitation routing. | `mcp_host/check.sh`, `bun test drivers/mcp_host/` |
|
||||
| `sdk/` | libfx durable sessions (`DurableSession.tla`), agent names and routing (`AgentRouting.tla`), tenants whose agents share one process (`TenantIsolation.tla`), steering (`DurableSteer.tla`), and the provider credential edge (`ProviderAuth.tla`). `check.sh` also writes the TLC state graphs the drivers walk. | `sdk/check.sh`, then the drivers |
|
||||
| `sdk/` | libfx durable sessions (`DurableSession.tla`), how a session object takes prompts in (`PromptIntake.tla`), images saved once by name (`ImageStore.tla`), agent names and routing (`AgentRouting.tla`), tenants whose agents share one process (`TenantIsolation.tla`), steering (`DurableSteer.tla`), and the provider credential edge (`ProviderAuth.tla`). `check.sh` also writes the TLC state graphs the drivers walk. | `sdk/check.sh`, then the drivers |
|
||||
| `session_manager/` | The session log, turn lifecycle, writer lock, snapshots, fork, subagents, catalog, host wiring, the side-file move, and v1 conversion. `rollout/` holds the models of the changes that moved fx onto the session manager. `system-design.md` is the design record the specs check and defines the `D1` to `D62` decision numbers that code comments cite. | `session_manager/check.sh`, `session_manager/rollout/run.py`, `bun test drivers/session_manager/` |
|
||||
| `shell_command/` | How an agent git command is admitted and run (`src/core/shell_command/safe_git.zig`), and one model turn through the shell tool, for the current design and a redesign. | `shell_command/check.sh`, `bun test drivers/shell_command/` |
|
||||
| `terminal/` | The in-memory shell snapshot owner (`src/core/terminal/shell_snapshot.zig`) and the lifecycle of hosted terminals. | `terminal/check.sh` |
|
||||
| `usage/` | `/usage` refreshes and the shared usage cache (`src/usage/`): `LeanUsage.tla` and `UsageModel.tla`. | `usage/check.sh`, then the drivers in `drivers/usage/` |
|
||||
| `drivers/` | The Bun drivers. They run the real fx binary or SDK code, record what happens, and have TLC check each run against its model. `drive.mjs`, `drive-agents.mjs`, `drive-tenants.mjs`, and `drive-steer.mjs` walk the SDK code along the `sdk/` state graphs. | See each folder's `check.sh` |
|
||||
| `lib/` | `tlc.sh`, which finds TLC and Java and runs a table of configs with their expected verdicts, and `run-tlc.sh`, which runs TLC once for the drivers. | |
|
||||
| `drivers/` | The Bun drivers. They run the real fx binary or SDK code, record what happens, and have TLC check each run against its model. `drive.mjs`, `drive-intake.mjs`, `drive-images.mjs`, `drive-agents.mjs`, `drive-tenants.mjs`, and `drive-steer.mjs` walk the SDK code along the `sdk/` state graphs. | See each folder's `check.sh` |
|
||||
| `lib/` | `tlc.sh`, which finds TLC and Java and runs a table of configs with their expected verdicts, `tlc.py`, which asks `tlc.sh` for TLC and Java on behalf of the Python checkers, and `run-tlc.sh`, which runs TLC once for the drivers. | |
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
#!/usr/bin/env bun
|
||||
// Drives real libfx along paths of the TLC state graph of ImageStore.tla,
|
||||
// and checks after every step that the code is in a state the model allows.
|
||||
//
|
||||
// bun tests/tla/drivers/drive-images.mjs [--walks N] [--seed S] [--backend native|wasm]
|
||||
//
|
||||
// Each walk keeps one session in a memory persistence with an image store,
|
||||
// first written by an engine without one, so its first image is saved
|
||||
// inline as an older libfx saved it. Then the walk opens an engine on it and
|
||||
// makes the prompts it chooses, each with image a or b; a turn's tool may
|
||||
// return image a or b as well, which the engine stores before the core gets
|
||||
// the result. Every image write
|
||||
// waits until the driver lets it land or fail; every model request waits
|
||||
// until the driver ends its turn. A crash leaves the engine behind at once:
|
||||
// its writes in flight never land, and nothing it does later reaches the
|
||||
// store. The driver reopens the session, resuming a turn the crash left
|
||||
// open, and may delete an image from the store. After each step the driver
|
||||
// waits until the code is quiet and moves to the model states that show the
|
||||
// same prompts, store, open state and held request. On every step, nothing
|
||||
// saved may name an image the store does not hold (until one is deleted),
|
||||
// only the old image, or a tool's image the store refused, may be saved
|
||||
// inline, and a model request must carry
|
||||
// exactly the history the model holds.
|
||||
//
|
||||
// `tests/tla/sdk/check.sh` writes graphs/images.dot from ImageStore-drive.cfg.
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createFxEngine, createMemoryPersistence } from "../../../sdk/node.js";
|
||||
import { loadGraph, walkGraph } from "./graph.mjs";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const option = (name, fallback) => {
|
||||
const index = args.indexOf(`--${name}`);
|
||||
return index >= 0 ? args[index + 1] : fallback;
|
||||
};
|
||||
const walks = Number(option("walks", 40));
|
||||
let seed = Number(option("seed", 1));
|
||||
const backend = option("backend", "native");
|
||||
if (!["native", "wasm"].includes(backend)) throw new Error("--backend is native or wasm");
|
||||
const verbose = args.includes("--verbose");
|
||||
const here = fileURLToPath(new URL(".", import.meta.url));
|
||||
const addon = resolve(here, "../../../zig-out/lib/libfx.node");
|
||||
const wasm = backend === "wasm" ? await readFile(resolve(here, "../../../zig-out/bin/fx-core.wasm")) : undefined;
|
||||
const quietMs = 150;
|
||||
const stepTimeoutMs = 10_000;
|
||||
// The model's window, which every engine the walk opens keeps too.
|
||||
const windowImages = Number(/^\s*Window = (\d+)$/m.exec(await readFile(resolve(here, "../sdk/ImageStore-drive.cfg"), "utf8"))[1]);
|
||||
const imageWindow = Symbol.for("libfx.imageWindow");
|
||||
const decoder = new TextDecoder();
|
||||
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
||||
const deferred = () => {
|
||||
let resolveIt;
|
||||
let rejectIt;
|
||||
const promise = new Promise((resolveP, rejectP) => { resolveIt = resolveP; rejectIt = rejectP; });
|
||||
promise.catch(() => {});
|
||||
return { promise, resolve: resolveIt, reject: rejectIt };
|
||||
};
|
||||
const random = () => {
|
||||
seed = (seed * 1103515245 + 12345) % 2147483648;
|
||||
return seed / 2147483648;
|
||||
};
|
||||
|
||||
// The model's images, as PNG bytes the kernel accepts, each its own.
|
||||
const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jP0cAAAAASUVORK5CYII=", "base64");
|
||||
const labels = ["o", "a", "b"];
|
||||
const bytesOf = Object.fromEntries(labels.map((label) => [label, new Uint8Array(Buffer.concat([png, Buffer.from(`image-${label}-`.repeat(400))]))]));
|
||||
const base64Of = Object.fromEntries(labels.map((label) => [label, Buffer.from(bytesOf[label]).toString("base64")]));
|
||||
const nameOf = Object.fromEntries(await Promise.all(labels.map(async (label) => [label, Buffer.from(await crypto.subtle.digest("SHA-256", bytesOf[label])).toString("hex")])));
|
||||
const labelOfName = Object.fromEntries(labels.map((label) => [nameOf[label], label]));
|
||||
const labelOfBase64 = Object.fromEntries(labels.map((label) => [base64Of[label], label]));
|
||||
const labelOfBytes = (bytes) => labelOfBase64[Buffer.from(bytes).toString("base64")] ?? "?";
|
||||
|
||||
// What a session saved, decoded: the names its records and checkpoint give,
|
||||
// and the images they hold inline.
|
||||
function durableBytes(value) {
|
||||
if (typeof value === "string") return Buffer.from(value, "utf8");
|
||||
if (value?.encoding === "base64") return Buffer.from(value.data, "base64");
|
||||
return null;
|
||||
}
|
||||
function savedImages(store) {
|
||||
const named = new Set();
|
||||
const inline = [];
|
||||
const visit = (value, blobs) => {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) visit(item, blobs);
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== "object") return;
|
||||
// One the session dropped keeps its name alone, which needs no bytes: an
|
||||
// image held inline takes the name of its bytes when it leaves.
|
||||
if (typeof value.inline_ref === "string" && value.dropped !== true) named.add(value.inline_ref);
|
||||
if (value.inline_data !== undefined) inline.push(durableBytes(value.inline_data));
|
||||
// A tool's image held inline: its base64 data.
|
||||
if (value.type === "image" && typeof value.data === "string" && typeof value.mimeType === "string") inline.push(Buffer.from(value.data, "base64"));
|
||||
if (Number.isInteger(value.inline_blob)) inline.push(blobs[value.inline_blob]);
|
||||
for (const item of Object.values(value)) visit(item, blobs);
|
||||
};
|
||||
for (const record of store.records) visit(JSON.parse(decoder.decode(record.data)), []);
|
||||
if (store.checkpoint) {
|
||||
// FXSN: magic, version, seq, turn, id length and id, an open turn from
|
||||
// version 2 on, then the FXCP checkpoint.
|
||||
const bytes = Buffer.from(store.checkpoint.data);
|
||||
const version = bytes[4];
|
||||
let at = 4 + 1 + 8 + 8;
|
||||
at += 1 + bytes[at];
|
||||
if (version >= 2) {
|
||||
const length = bytes.readUInt32LE(at);
|
||||
visit(JSON.parse(bytes.subarray(at + 4, at + 4 + length).toString("utf8")), []);
|
||||
at += 4 + length;
|
||||
}
|
||||
const checkpoint = bytes.subarray(at);
|
||||
const payload = checkpoint.subarray(4 + 2 + 2 + 4 + 32);
|
||||
if (checkpoint.readUInt16LE(4) === 1) {
|
||||
visit(JSON.parse(payload.toString("utf8")), []);
|
||||
} else {
|
||||
const jsonLength = payload.readUInt32LE(0);
|
||||
const json = JSON.parse(payload.subarray(4, 4 + jsonLength).toString("utf8"));
|
||||
const blobs = [];
|
||||
let offset = 4 + jsonLength;
|
||||
const count = payload.readUInt32LE(offset);
|
||||
offset += 4;
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const length = payload.readUInt32LE(offset);
|
||||
blobs.push(payload.subarray(offset + 4, offset + 4 + length));
|
||||
offset += 4 + length;
|
||||
}
|
||||
visit(json, blobs);
|
||||
}
|
||||
}
|
||||
return { named, inline };
|
||||
}
|
||||
|
||||
const vision = "images/vision";
|
||||
const usage = { inputTokens: { total: 1 }, outputTokens: { total: 1 } };
|
||||
// The model calls the turn's tool for image `label`.
|
||||
const callShot = (p, label) => new Response(
|
||||
[{ type: "tool-call", toolCallId: `call-${p}`, toolName: "shot", input: { label } }, { type: "finish", finishReason: { unified: "tool-calls", raw: "tool-calls" }, usage }, "[DONE]"]
|
||||
.map((frame) => `data: ${typeof frame === "string" ? frame : JSON.stringify(frame)}\n\n`).join(""),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
);
|
||||
// The tool returns the image it is asked for.
|
||||
const shot = {
|
||||
name: "shot",
|
||||
description: "Returns an image",
|
||||
inputSchema: { type: "object", properties: { label: { type: "string" } }, required: ["label"] },
|
||||
execute: async ({ label }) => ({ type: "libfx.tool-result", text: `image ${label}`, images: [{ type: "image", data: base64Of[label], mimeType: "image/png" }] }),
|
||||
};
|
||||
const answer = () => new Response(
|
||||
[{ type: "text-delta", id: "a", delta: "seen" }, { type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage }, "[DONE]"]
|
||||
.map((frame) => `data: ${typeof frame === "string" ? frame : JSON.stringify(frame)}\n\n`).join(""),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
);
|
||||
|
||||
// libfx's own key for a persistence's image store, which durable sessions
|
||||
// give the engines they run.
|
||||
const persistenceImages = Symbol.for("libfx.persistenceImages");
|
||||
|
||||
async function harness(n) {
|
||||
const store = createMemoryPersistence();
|
||||
// The image store: each image once, by name.
|
||||
const storedImages = new Map();
|
||||
const problems = [];
|
||||
// Held image writes by name, and the model request held now.
|
||||
const imageGates = new Map();
|
||||
let held = null;
|
||||
let answerNow = false;
|
||||
// Each engine's own fetch: a crashed engine reaches the model no more.
|
||||
const fetchFor = (current) => async (input, init) => {
|
||||
if (current && !current.alive) throw new Error("crashed");
|
||||
const request = input instanceof Request ? input : new Request(input, init);
|
||||
if (request.method === "GET") return Response.json({ object: "list", data: [{ id: vision, type: "language", tags: ["vision", "file-input"] }] });
|
||||
const prompt = JSON.parse(await request.text()).prompt;
|
||||
const users = prompt.filter((message) => message.role === "user");
|
||||
const text = users.map((message) => (Array.isArray(message.content) ? message.content : []).filter((part) => part.type === "text").map((part) => part.text).join("")).join("\n");
|
||||
const numbers = [...text.matchAll(/prompt-(\d+)/g)].map((match) => Number(match[1]));
|
||||
const images = users.flatMap((message) => (Array.isArray(message.content) ? message.content : []).filter((part) => part.type === "file"))
|
||||
.map((part) => labelOfBase64[part.data?.data] ?? "?");
|
||||
if (answerNow) return answer();
|
||||
const response = deferred();
|
||||
held = { p: numbers.at(-1) ?? 0, images, response, life: current };
|
||||
const signal = init?.signal ?? request.signal;
|
||||
signal?.addEventListener("abort", () => response.reject(signal.reason ?? new Error("aborted")), { once: true });
|
||||
return response.promise;
|
||||
};
|
||||
|
||||
// Each engine's view of the store, until it crashes.
|
||||
let life = null;
|
||||
const persistenceFor = (current) => ({
|
||||
load: () => (current.alive ? store.load() : Promise.reject(new Error("crashed"))),
|
||||
append: (input) => (current.alive ? store.append(input) : Promise.reject(new Error("crashed"))),
|
||||
saveCheckpoint: (input) => (current.alive ? store.saveCheckpoint(input) : Promise.reject(new Error("crashed"))),
|
||||
[persistenceImages]: {
|
||||
async put(name, bytes) {
|
||||
if (!current.alive) throw new Error("crashed");
|
||||
const gate = imageGates.get(name);
|
||||
if (gate) {
|
||||
imageGates.delete(name);
|
||||
const lands = await gate.promise;
|
||||
if (!lands) throw new Error("injected image write failure");
|
||||
// A write the crash caught in flight never lands.
|
||||
if (!current.alive) return new Promise(() => {});
|
||||
}
|
||||
if (!storedImages.has(name)) storedImages.set(name, bytes.slice());
|
||||
},
|
||||
get: async (name) => {
|
||||
if (!current.alive) throw new Error("crashed");
|
||||
return storedImages.get(name)?.slice();
|
||||
},
|
||||
},
|
||||
});
|
||||
const engineOptions = (persistence, current = null) => ({
|
||||
backend,
|
||||
nativeAddon: addon,
|
||||
...(wasm ? { wasm } : {}),
|
||||
apiKey: "images-key",
|
||||
model: vision,
|
||||
fetch: fetchFor(current),
|
||||
persistence,
|
||||
checkpointAfterBytes: 0,
|
||||
tools: [shot],
|
||||
[imageWindow]: { images: windowImages },
|
||||
});
|
||||
|
||||
// The session an older libfx saved: its image inline, in a record and in a
|
||||
// checkpoint.
|
||||
{
|
||||
answerNow = true;
|
||||
const old = await createFxEngine(engineOptions({ load: () => store.load(), append: (input) => store.append(input), saveCheckpoint: (input) => store.saveCheckpoint(input) }));
|
||||
const turn = old.prompt([{ type: "text", text: "old" }, { type: "image", data: bytesOf.o, mimeType: "image/png" }]);
|
||||
for await (const _ of turn) {}
|
||||
assert.equal((await turn.result).stopReason, "end_turn");
|
||||
await old.close();
|
||||
answerNow = false;
|
||||
}
|
||||
|
||||
let engine = null;
|
||||
let up = "down";
|
||||
let lost = false;
|
||||
let made = 0;
|
||||
const ph = Array(n).fill("unmade");
|
||||
const gates = {};
|
||||
// Each turn's tool image write, held until the driver lets it land or fail,
|
||||
// and the images the store refused, which the session holds inline.
|
||||
const toolGates = {};
|
||||
const refused = new Set();
|
||||
const watch = (p, turn) => {
|
||||
turn.result.then(
|
||||
(result) => { if (result.stopReason === "end_turn") ph[p - 1] = "ended"; },
|
||||
(error) => { if (error?.message === "injected image write failure") ph[p - 1] = "failed"; },
|
||||
);
|
||||
};
|
||||
|
||||
async function perform(act) {
|
||||
const [name, p, image] = act;
|
||||
switch (name) {
|
||||
case "Make": {
|
||||
made = p;
|
||||
ph[p - 1] = "putting";
|
||||
gates[p] = deferred();
|
||||
imageGates.set(nameOf[image], { promise: gates[p].promise });
|
||||
const turn = engine.prompt([{ type: "text", text: `prompt-${p}` }, { type: "image", data: bytesOf[image].slice(), mimeType: "image/png" }]);
|
||||
void turn.result.catch(() => {});
|
||||
watch(p, turn);
|
||||
return;
|
||||
}
|
||||
case "PutLand": gates[p].resolve(true); return;
|
||||
case "PutFail": gates[p].resolve(false); return;
|
||||
case "ToolCall": {
|
||||
if (!held || held.p !== p) throw new Error(`no model request is held for prompt ${p}`);
|
||||
toolGates[p] = { gate: deferred(), image };
|
||||
imageGates.set(nameOf[image], { promise: toolGates[p].gate.promise });
|
||||
const { response } = held;
|
||||
held = null;
|
||||
response.resolve(callShot(p, image));
|
||||
return;
|
||||
}
|
||||
case "ToolPutLand": toolGates[p].gate.resolve(true); return;
|
||||
case "ToolPutFail": {
|
||||
refused.add(toolGates[p].image);
|
||||
toolGates[p].gate.resolve(false);
|
||||
return;
|
||||
}
|
||||
case "TurnEnd": {
|
||||
if (!held || held.p !== p) throw new Error(`no model request is held for prompt ${p}`);
|
||||
const { response } = held;
|
||||
held = null;
|
||||
response.resolve(answer());
|
||||
return;
|
||||
}
|
||||
case "Crash": {
|
||||
life.alive = false;
|
||||
// The crash ends the engine's held request and image writes with it.
|
||||
if (held) {
|
||||
held.response.reject(new Error("crashed"));
|
||||
held = null;
|
||||
}
|
||||
for (let q = 1; q <= n; q += 1) if (ph[q - 1] === "putting") ph[q - 1] = "lost";
|
||||
for (const gate of Object.values(gates)) gate.resolve(true);
|
||||
for (const { gate } of Object.values(toolGates)) gate.resolve(true);
|
||||
const left = engine;
|
||||
engine = null;
|
||||
up = "down";
|
||||
void left.close().catch(() => {});
|
||||
return;
|
||||
}
|
||||
case "OpenBegin": {
|
||||
life = { alive: true };
|
||||
try {
|
||||
engine = await createFxEngine(engineOptions(persistenceFor(life), life));
|
||||
} catch (error) {
|
||||
if (error?.code !== "FX_JOURNAL_IMAGE_MISSING") throw error;
|
||||
up = "failed";
|
||||
return;
|
||||
}
|
||||
up = "open";
|
||||
// A turn the crash left open continues.
|
||||
const resumed = engine.resume();
|
||||
if (resumed) {
|
||||
const open = ph.findIndex((phase) => phase === "started") + 1;
|
||||
if (open === 0) problems.push("the engine resumed a turn the model has no started prompt for");
|
||||
void resumed.result.catch(() => {});
|
||||
watch(open, resumed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "Lose": {
|
||||
lost = true;
|
||||
storedImages.delete(nameOf[p]);
|
||||
return;
|
||||
}
|
||||
default: throw new Error(`the driver cannot take ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
function observe() {
|
||||
// A prompt whose model request is held has started.
|
||||
if (held && held.life === life && ph[held.p - 1] !== "ended") ph[held.p - 1] = "started";
|
||||
const { named, inline } = savedImages(store);
|
||||
if (!lost) for (const name of named) if (!storedImages.has(name)) problems.push(`a record or checkpoint names ${labelOfName[name] ?? name}, which the store does not hold`);
|
||||
for (const bytes of inline) {
|
||||
const label = labelOfBytes(bytes);
|
||||
if (label !== "o" && !refused.has(label)) problems.push(`image ${label} is saved inline`);
|
||||
}
|
||||
return {
|
||||
made,
|
||||
ph: [...ph],
|
||||
up,
|
||||
store: [...storedImages.keys()].map((name) => labelOfName[name] ?? "?").sort(),
|
||||
held: held && held.life === life ? held.images : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
let last = JSON.stringify(observe());
|
||||
let since = Date.now();
|
||||
const begun = Date.now();
|
||||
while (Date.now() - since < quietMs) {
|
||||
await sleep(15);
|
||||
const now = JSON.stringify(observe());
|
||||
if (now !== last) { last = now; since = Date.now(); }
|
||||
if (Date.now() - begun > stepTimeoutMs) break;
|
||||
}
|
||||
return observe();
|
||||
}
|
||||
|
||||
async function close() {
|
||||
if (held) held.response.resolve(answer());
|
||||
for (const gate of Object.values(gates)) gate.resolve(true);
|
||||
for (const { gate } of Object.values(toolGates)) gate.resolve(true);
|
||||
if (engine) await Promise.race([engine.close().catch(() => {}), sleep(3000)]);
|
||||
}
|
||||
|
||||
return { perform, settle, close, problems };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Walks.
|
||||
|
||||
const graph = await loadGraph(resolve(here, "../sdk/graphs", "images.dot"));
|
||||
const n = graph.states.get(graph.init).ph.length;
|
||||
const autoActs = new Set(["Send", "TurnStart", "ToolResult", "Checkpoint", "Stage", "OpenMissing", "OpenOk"]);
|
||||
// While a turn's tool stores its image or hands it on, no request is held.
|
||||
const toolRunning = (state) => state.tph.some((phase) => ["called", "put", "refused"].includes(phase));
|
||||
const view = (state) => JSON.stringify({
|
||||
made: state.made,
|
||||
ph: state.ph,
|
||||
up: state.up,
|
||||
store: [...state.store].sort(),
|
||||
// A model request carries the images the core keeps; a dropped one is a note.
|
||||
held: state.up === "open" && state.ph.includes("started") && !toolRunning(state) ? state.mem.filter((entry) => !entry.dropped).map((entry) => entry.i) : null,
|
||||
});
|
||||
const { steps, covered, totalEdges } = await walkGraph({
|
||||
graph,
|
||||
walks,
|
||||
random,
|
||||
verbose,
|
||||
autoActs,
|
||||
view,
|
||||
nameOf: (act) => act[0],
|
||||
sameAct: (a, b) => JSON.stringify(a) === JSON.stringify(b),
|
||||
label: (act) => `${act[0]}(${act.slice(1).join(",")})`,
|
||||
open: () => harness(n),
|
||||
});
|
||||
if (!process.exitCode) console.log(`ok images: ${walks} walks, ${steps} steps matched the model; ${covered} of ${totalEdges} edges covered (${backend})`);
|
||||
process.exit(process.exitCode ?? 0);
|
||||
@@ -0,0 +1,466 @@
|
||||
#!/usr/bin/env bun
|
||||
// Drives real libfx along paths of the TLC state graph of PromptIntake.tla,
|
||||
// and checks after every step that the code is in a state the model allows.
|
||||
//
|
||||
// bun tests/tla/drivers/drive-intake.mjs [--walks N] [--seed S] [--durability memory|world]
|
||||
// [--harness fx|scripted] [--backend native|wasm]
|
||||
//
|
||||
// Each walk opens one agent and one session object, which makes the prompts
|
||||
// the walk chooses: text, or a prompt with an image to prepare: base64 data
|
||||
// whose write to the session's image store waits until the driver lets it
|
||||
// land or fail. Every log write
|
||||
// of a prompt, or of a prompt's cancel, waits until the driver lets it land
|
||||
// or fail. With the fx harness each turn runs on a real fx engine, whose
|
||||
// model request the driver holds until it ends the turn; with the scripted
|
||||
// harness a turn is a stand-in the driver ends. After each step the driver
|
||||
// waits until the code is quiet, reads back what it shows, and moves to the
|
||||
// model states that show the same. Every stored prompt must name its image,
|
||||
// already in the image store with the caller's bytes; and every model
|
||||
// request must carry its prompt's image unchanged.
|
||||
//
|
||||
// `tests/tla/sdk/check.sh` writes graphs/intake.dot from PromptIntake-drive.cfg.
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { createFxAgent, memory } from "../../../sdk/node.js";
|
||||
import { createDurableAgentFactory } from "../../../sdk/durable.js";
|
||||
import { loadGraph, walkGraph } from "./graph.mjs";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const option = (name, fallback) => {
|
||||
const index = args.indexOf(`--${name}`);
|
||||
return index >= 0 ? args[index + 1] : fallback;
|
||||
};
|
||||
const walks = Number(option("walks", 40));
|
||||
let seed = Number(option("seed", 1));
|
||||
const durabilityKind = option("durability", "memory");
|
||||
const harnessKind = option("harness", "fx");
|
||||
const backend = option("backend", "native");
|
||||
if (!["memory", "world"].includes(durabilityKind)) throw new Error("--durability is memory or world");
|
||||
if (!["fx", "scripted"].includes(harnessKind)) throw new Error("--harness is fx or scripted");
|
||||
const verbose = args.includes("--verbose");
|
||||
const here = fileURLToPath(new URL(".", import.meta.url));
|
||||
const addon = resolve(here, "../../../zig-out/lib/libfx.node");
|
||||
const wasm = harnessKind === "fx" && backend === "wasm" ? await readFile(resolve(here, "../../../zig-out/bin/fx-core.wasm")) : undefined;
|
||||
const { appWorldAt, closeAppWorlds } = durabilityKind === "world" ? await import("../../../sdk/tests/app-world.mjs") : {};
|
||||
// How long the code must show nothing new before a step has settled. An fx
|
||||
// engine takes a moment to open, and a World's stream a moment to read.
|
||||
const quietMs = (harnessKind === "fx" ? 200 : 60) + (durabilityKind === "world" ? 350 : 0);
|
||||
const stepTimeoutMs = 10_000;
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
||||
const until = async (check, ms = stepTimeoutMs) => {
|
||||
const started = Date.now();
|
||||
while (!check()) {
|
||||
if (Date.now() - started > ms) return false;
|
||||
await sleep(5);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const base64 = (bytes) => Buffer.from(bytes).toString("base64");
|
||||
const deferred = () => {
|
||||
let resolveIt;
|
||||
let rejectIt;
|
||||
const promise = new Promise((resolveP, rejectP) => { resolveIt = resolveP; rejectIt = rejectP; });
|
||||
promise.catch(() => {});
|
||||
return { promise, resolve: resolveIt, reject: rejectIt };
|
||||
};
|
||||
// A PNG the kernel accepts, followed by bytes that name the walk and prompt,
|
||||
// so each prompt's image is its own.
|
||||
const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jP0cAAAAASUVORK5CYII=", "base64");
|
||||
const imageOf = (walk, i) => new Uint8Array(Buffer.concat([png, Buffer.from(`walk-${walk}-prompt-${i}`.padEnd(24, "."))]));
|
||||
|
||||
const nameOf = async (bytes) => Buffer.from(await crypto.subtle.digest("SHA-256", bytes)).toString("hex");
|
||||
// An image's stream on a World, as libfx names it.
|
||||
const imageStreamSuffix = (name) => `_user_${Buffer.from(`libfx-image-${name}`).toString("base64url")}`;
|
||||
|
||||
// A durability whose log writes of prompts and their cancels wait for the
|
||||
// driver, which lets each land or fail, as do the image writes it is told to
|
||||
// hold (`holdImage`). `images` has the bytes of every image write that
|
||||
// landed, by name. Works on memory() and on a World.
|
||||
function heldInputs(durability) {
|
||||
const held = new Map();
|
||||
const landed = [];
|
||||
const images = new Map();
|
||||
const heldImages = new Map();
|
||||
let open = false;
|
||||
const throughImage = async (name, bytes, write) => {
|
||||
const hold = heldImages.get(name);
|
||||
if (hold && !open) {
|
||||
heldImages.delete(name);
|
||||
if (!(await hold.promise)) throw Object.assign(new Error("injected image write failure"), { code: "FX_TEST_FAULT" });
|
||||
}
|
||||
const result = await write();
|
||||
images.set(name, Buffer.from(bytes).toString("base64"));
|
||||
return result;
|
||||
};
|
||||
const keyOf = (entry) => {
|
||||
if (entry?.k !== "input") return null;
|
||||
if (entry.type === "prompt") return `prompt:${entry.messageId}`;
|
||||
if (entry.type === "cancel" && typeof entry.target === "string") return `cancel:${entry.target}`;
|
||||
return null;
|
||||
};
|
||||
const through = async (entry, write) => {
|
||||
const key = keyOf(entry);
|
||||
if (key !== null && !open) {
|
||||
const lands = await new Promise((go) => held.set(key, { go }));
|
||||
held.delete(key);
|
||||
if (!lands) throw Object.assign(new Error("injected log write failure"), { code: "FX_TEST_FAULT" });
|
||||
}
|
||||
const result = await write();
|
||||
if (key !== null) landed.push(entry);
|
||||
return result;
|
||||
};
|
||||
const control = {
|
||||
held,
|
||||
landed,
|
||||
images,
|
||||
// The next write of the image named `name` waits for `settleImage`.
|
||||
holdImage(name) {
|
||||
const gate = {};
|
||||
gate.promise = new Promise((go) => { gate.go = go; });
|
||||
heldImages.set(name, gate);
|
||||
return gate;
|
||||
},
|
||||
async settle(key, lands) {
|
||||
if (!(await until(() => held.has(key)))) throw new Error(`no log write is held for ${key}`);
|
||||
held.get(key).go(lands);
|
||||
},
|
||||
openAll() {
|
||||
open = true;
|
||||
for (const item of [...held.values()]) item.go(true);
|
||||
for (const gate of [...heldImages.values()]) gate.go(true);
|
||||
},
|
||||
};
|
||||
if (typeof durability.create === "function") {
|
||||
const create = durability.create;
|
||||
const wrapped = new WeakMap();
|
||||
return [{
|
||||
...durability,
|
||||
create() {
|
||||
const backend = create();
|
||||
if (!wrapped.has(backend)) {
|
||||
wrapped.set(backend, {
|
||||
...backend,
|
||||
async session(id) {
|
||||
const log = await backend.session(id);
|
||||
return {
|
||||
...log,
|
||||
append: (entry) => through(entry, () => log.append(entry)),
|
||||
images: { put: (name, bytes) => throughImage(name, bytes, () => log.images.put(name, bytes)), get: (name) => log.images.get(name) },
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
return wrapped.get(backend);
|
||||
},
|
||||
}, control];
|
||||
}
|
||||
const bound = (target, key) => {
|
||||
const value = Reflect.get(target, key);
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
};
|
||||
return [{
|
||||
...durability,
|
||||
async world() {
|
||||
const world = await durability.world();
|
||||
// An image write is a write of its stream; the driver knows its name.
|
||||
const streams = new Proxy(world.streams, {
|
||||
get(target, key) {
|
||||
if (key !== "write") return bound(target, key);
|
||||
return async (runId, stream, chunk) => {
|
||||
const name = [...pendingNames].find((candidate) => String(stream).endsWith(imageStreamSuffix(candidate)));
|
||||
if (name === undefined) return target.write(runId, stream, chunk);
|
||||
return throughImage(name, chunk, () => target.write(runId, stream, chunk));
|
||||
};
|
||||
},
|
||||
});
|
||||
const events = new Proxy(world.events, {
|
||||
get(target, key) {
|
||||
if (key !== "create") return bound(target, key);
|
||||
return async (runId, event, params) => {
|
||||
if (event?.eventType !== "step_created" || event.eventData?.stepName !== "fx.log") return target.create(runId, event, params);
|
||||
// What the World stores, read back as the log reads it.
|
||||
const entry = JSON.parse(decoder.decode(event.eventData.input));
|
||||
return through(entry, () => target.create(runId, event, params));
|
||||
};
|
||||
},
|
||||
});
|
||||
return new Proxy(world, { get: (target, key) => (key === "events" ? events : key === "streams" ? streams : bound(target, key)) });
|
||||
},
|
||||
}, control];
|
||||
}
|
||||
// The names of the walk's images, so a World's stream writes can be told
|
||||
// apart from its other streams'.
|
||||
const pendingNames = new Set();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The code under test: one agent, one session object.
|
||||
|
||||
const vision = "intake/vision";
|
||||
const usage = { inputTokens: { total: 1 }, outputTokens: { total: 1 } };
|
||||
const answer = (text) => new Response(
|
||||
[{ type: "text-delta", id: "a", delta: text }, { type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage }, "[DONE]"]
|
||||
.map((frame) => `data: ${typeof frame === "string" ? frame : JSON.stringify(frame)}\n\n`).join(""),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
);
|
||||
const promptNumber = (text) => Number(/prompt-(\d+)/.exec(text ?? "")?.[1]);
|
||||
|
||||
async function harness(walk, n) {
|
||||
const dir = await mkdtemp(join(tmpdir(), "libfx-intake-"));
|
||||
const [durability, writes] = heldInputs(durabilityKind === "world" ? await appWorldAt(dir) : memory());
|
||||
const expected = {};
|
||||
const names = {};
|
||||
const kinds = {};
|
||||
const imageGates = {};
|
||||
const controllers = {};
|
||||
const turnsRunning = new Map();
|
||||
const started = new Set();
|
||||
const acc = {};
|
||||
const accError = {};
|
||||
const res = {};
|
||||
const early = {};
|
||||
const problems = [];
|
||||
let made = 0;
|
||||
let closed = false;
|
||||
let closing = null;
|
||||
// Each turn's image as the model or the stand-in turn received it; a text
|
||||
// prompt's turn receives none.
|
||||
const received = (i, image) => {
|
||||
if (kinds[i] === "text") {
|
||||
if (image !== undefined) problems.push(`text prompt ${i}'s turn received an image`);
|
||||
return;
|
||||
}
|
||||
if (image?.data !== expected[i] || image?.mimeType !== "image/png") problems.push(`prompt ${i}'s turn received ${JSON.stringify(image).slice(0, 120)}`);
|
||||
};
|
||||
const turnStarted = (i, end, abortWith) => {
|
||||
if (started.has(i)) problems.push(`prompt ${i}'s turn started twice`);
|
||||
started.add(i);
|
||||
turnsRunning.set(i, { end, abortWith });
|
||||
};
|
||||
let agent;
|
||||
if (harnessKind === "fx") {
|
||||
agent = createFxAgent({
|
||||
backend,
|
||||
nativeAddon: addon,
|
||||
...(wasm ? { wasm } : {}),
|
||||
apiKey: "intake-key",
|
||||
model: vision,
|
||||
durability,
|
||||
async fetch(input, init) {
|
||||
// The WebAssembly host passes a Request, the native one a URL and init.
|
||||
const request = input instanceof Request ? input : new Request(input, init);
|
||||
if (request.method === "GET") return Response.json({ object: "list", data: [{ id: vision, type: "language", tags: ["vision", "file-input"] }] });
|
||||
const prompt = JSON.parse(await request.text()).prompt;
|
||||
const last = prompt.filter((message) => message.role === "user").at(-1);
|
||||
const parts = Array.isArray(last?.content) ? last.content : [];
|
||||
const i = promptNumber(parts.filter((part) => part.type === "text").map((part) => part.text).join(""));
|
||||
const images = parts.filter((part) => part.type === "file");
|
||||
if (images.length !== (kinds[i] === "text" ? 0 : 1)) problems.push(`prompt ${i}'s model request carries ${images.length} images`);
|
||||
received(i, images[0] && { data: images[0].data?.data, mimeType: images[0].mediaType });
|
||||
const signal = init?.signal ?? request.signal;
|
||||
return new Promise((resolveResponse, rejectResponse) => {
|
||||
turnStarted(i, () => { turnsRunning.delete(i); resolveResponse(answer(`answered ${i}`)); });
|
||||
signal?.addEventListener("abort", () => { turnsRunning.delete(i); rejectResponse(signal.reason ?? new Error("aborted")); }, { once: true });
|
||||
});
|
||||
},
|
||||
});
|
||||
} else {
|
||||
agent = createDurableAgentFactory({
|
||||
harness: () => ({
|
||||
async open({ store }) {
|
||||
await store.load();
|
||||
let key = 0;
|
||||
const append = (record, marks) => store.append({ idempotencyKey: `intake:${Date.now()}:${key++}`, data: encoder.encode(JSON.stringify(record)), marks });
|
||||
return {
|
||||
prompt(input, { turnId }) {
|
||||
const i = promptNumber(input?.[0]?.text);
|
||||
received(i, input?.[1]);
|
||||
const ended = deferred();
|
||||
const result = (async () => {
|
||||
await append({ turnId, start: true }, [{ start: turnId }]);
|
||||
turnStarted(i, () => { turnsRunning.delete(i); ended.resolve("end_turn"); });
|
||||
const stopReason = await ended.promise;
|
||||
turnsRunning.delete(i);
|
||||
await append({ turnId, end: true }, [{ end: true }]);
|
||||
return { stopReason, usage: {} };
|
||||
})();
|
||||
return { result, steer: async () => {}, cancel: () => ended.resolve("cancelled"), async *[Symbol.asyncIterator]() { await result; } };
|
||||
},
|
||||
resume() { throw new Error("no walk leaves a turn open"); },
|
||||
get openTurn() { return null; },
|
||||
settled: async () => {},
|
||||
saveCheckpoint: async () => {},
|
||||
exportCheckpoint: async () => new Uint8Array(),
|
||||
close: async () => {},
|
||||
};
|
||||
},
|
||||
}),
|
||||
defaultDurability: async () => memory(),
|
||||
})({ durability });
|
||||
}
|
||||
const session = agent.session();
|
||||
|
||||
async function perform(act) {
|
||||
const i = act.p;
|
||||
switch (act.name) {
|
||||
case "Make": {
|
||||
const controller = new AbortController();
|
||||
if (act.early) controller.abort();
|
||||
const image = imageOf(walk, i);
|
||||
expected[i] = base64(image);
|
||||
names[i] = await nameOf(image);
|
||||
kinds[i] = act.kind;
|
||||
early[i] = act.early;
|
||||
const blocks = [{ type: "text", text: `prompt-${i}` }];
|
||||
// An image prompt's image write is held until the driver lets it go.
|
||||
if (act.kind === "image") {
|
||||
blocks.push({ type: "image", data: expected[i], mimeType: "image/png" });
|
||||
pendingNames.add(names[i]);
|
||||
imageGates[i] = writes.holdImage(names[i]);
|
||||
}
|
||||
const turn = session.prompt(blocks, { messageId: `p${i}`, signal: controller.signal });
|
||||
controllers[i] = controller;
|
||||
made = i;
|
||||
acc[i] = "pending";
|
||||
res[i] = "pending";
|
||||
turn.accepted.then(() => { acc[i] = "resolved"; }, (error) => { acc[i] = "rejected"; accError[i] = error; });
|
||||
turn.result.then(
|
||||
(result) => { res[i] = result.stopReason === "end_turn" ? "end" : result.stopReason === "cancelled" ? "cancelled" : `other:${result.stopReason}:${result.error?.message ?? ""}`; },
|
||||
() => { res[i] = "rejected"; },
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "Prepared":
|
||||
imageGates[i].go(true);
|
||||
return;
|
||||
case "PrepareFail":
|
||||
imageGates[i].go(false);
|
||||
return;
|
||||
case "Store": return writes.settle(`prompt:p${i}`, true);
|
||||
case "StoreFail": return writes.settle(`prompt:p${i}`, false);
|
||||
case "Abort": controllers[i].abort(); return;
|
||||
case "CancelStore": return writes.settle(`cancel:p${i}`, true);
|
||||
case "Close": {
|
||||
closed = true;
|
||||
closing = agent.close();
|
||||
// A closed agent's session object takes no more prompts.
|
||||
try {
|
||||
session.prompt("after close");
|
||||
problems.push("prompt() after close did not throw");
|
||||
} catch (error) {
|
||||
if (!/is closed/.test(error.message)) problems.push(`prompt() after close threw ${error.message}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "End": {
|
||||
if (!(await until(() => turnsRunning.has(i)))) throw new Error(`prompt ${i} has no turn to end`);
|
||||
turnsRunning.get(i).end();
|
||||
return;
|
||||
}
|
||||
default: throw new Error(`the driver cannot take ${act.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
function observe() {
|
||||
const stored = writes.landed.filter((entry) => entry.type === "prompt");
|
||||
const cancels = new Set(writes.landed.filter((entry) => entry.type === "cancel").map((entry) => entry.target));
|
||||
for (const entry of stored) {
|
||||
const i = promptNumber(entry.input?.[0]?.text);
|
||||
const image = entry.input?.[1];
|
||||
// What the log holds is JSON. An image prompt names its image, which
|
||||
// the image store already holds, with the caller's original bytes.
|
||||
if (!isDeepStrictEqual(JSON.parse(JSON.stringify(entry)), entry)) problems.push(`prompt ${i}'s entry is not JSON`);
|
||||
if (kinds[i] === "text") {
|
||||
if (entry.input?.length !== 1) problems.push(`text prompt ${i} stored ${JSON.stringify(entry.input).slice(0, 120)}`);
|
||||
continue;
|
||||
}
|
||||
if (image?.data?.sha256 !== names[i] || Object.keys(image.data).length !== 1 || image?.mimeType !== "image/png" || image?.type !== "image") problems.push(`prompt ${i} stored ${JSON.stringify(image).slice(0, 120)}`);
|
||||
if (writes.images.get(names[i]) !== expected[i]) problems.push(`prompt ${i} was stored before its image, or with other bytes`);
|
||||
}
|
||||
const order = stored.map((entry) => promptNumber(entry.input?.[0]?.text));
|
||||
const ph = [];
|
||||
for (let i = 1; i <= n; i += 1) {
|
||||
if (i > made) ph.push("unmade");
|
||||
else if (early[i]) ph.push("early");
|
||||
else if (order.includes(i)) ph.push("stored");
|
||||
else if (writes.held.has(`prompt:p${i}`)) ph.push("sending");
|
||||
else if (acc[i] === "rejected") ph.push(/is closed/.test(accError[i]?.message ?? "") ? "refused" : "failed");
|
||||
// Accepted, yet neither stored nor on its way: an abort withdrew it.
|
||||
else if (acc[i] === "resolved") ph.push("withdrawn");
|
||||
else ph.push("preparing");
|
||||
}
|
||||
const each = (fn) => Array.from({ length: n }, (_, index) => fn(index + 1));
|
||||
return {
|
||||
made,
|
||||
closed,
|
||||
log: order,
|
||||
ph,
|
||||
acc: each((i) => acc[i] ?? "pending"),
|
||||
res: each((i) => res[i] ?? "pending"),
|
||||
cancel: each((i) => (cancels.has(`p${i}`) ? "stored" : writes.held.has(`cancel:p${i}`) ? "held" : "none")),
|
||||
ran: each((i) => started.has(i)),
|
||||
};
|
||||
}
|
||||
|
||||
// Waits until the code shows nothing new for quietMs.
|
||||
async function settle() {
|
||||
let last = JSON.stringify(observe());
|
||||
let since = Date.now();
|
||||
const begun = Date.now();
|
||||
while (Date.now() - since < quietMs) {
|
||||
await sleep(15);
|
||||
const now = JSON.stringify(observe());
|
||||
if (now !== last) { last = now; since = Date.now(); }
|
||||
if (Date.now() - begun > stepTimeoutMs) break;
|
||||
}
|
||||
return observe();
|
||||
}
|
||||
|
||||
async function close() {
|
||||
writes.openAll();
|
||||
pendingNames.clear();
|
||||
for (const turn of [...turnsRunning.values()]) turn.end();
|
||||
await Promise.race([(closing ?? agent.close()).catch(() => {}), sleep(3000)]);
|
||||
await closeAppWorlds?.();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
return { perform, settle, close, problems };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Walks.
|
||||
|
||||
const random = () => {
|
||||
seed = (seed * 1103515245 + 12345) % 2147483648;
|
||||
return seed / 2147483648;
|
||||
};
|
||||
|
||||
const graph = await loadGraph(resolve(here, "../sdk/graphs", "intake.dot"));
|
||||
const n = graph.states.get(graph.init).ph.length;
|
||||
const autoActs = new Set(["Released", "Start", "Skip", "CancelRun"]);
|
||||
const view = (state) => JSON.stringify({
|
||||
made: state.made, closed: state.closed, log: state.log, ph: state.ph, acc: state.acc, res: state.res, cancel: state.cancel, ran: state.ran,
|
||||
});
|
||||
const { steps, covered, totalEdges } = await walkGraph({
|
||||
graph,
|
||||
walks,
|
||||
random,
|
||||
verbose,
|
||||
autoActs,
|
||||
view,
|
||||
nameOf: (act) => act.name,
|
||||
sameAct: (a, b) => a.name === b.name && a.p === b.p && a.kind === b.kind && a.early === b.early,
|
||||
label: (act) => `${act.name}(${act.p}${act.name === "Make" ? `,${act.kind}${act.early ? ",early" : ""}` : ""})`,
|
||||
// Closing ends what a walk can do, so it comes late more often than not.
|
||||
choose: (pool) => (pool.length > 1 && random() < 0.75 ? pool.filter((item) => item.act.name !== "Close") : pool),
|
||||
open: (walk) => harness(walk, n),
|
||||
});
|
||||
if (!process.exitCode) console.log(`ok intake: ${walks} walks, ${steps} steps matched the model; ${covered} of ${totalEdges} edges covered (${harnessKind}, ${durabilityKind}${harnessKind === "fx" ? `, ${backend}` : ""})`);
|
||||
// A walk that ended with prompts stored after close leaves the memory
|
||||
// queue retrying with nobody listening; nothing more runs.
|
||||
process.exit(process.exitCode ?? 0);
|
||||
@@ -231,8 +231,9 @@ async function harness() {
|
||||
for (const [messageId, owner] of ownerOf) if (owner) stateOf.set(owner, ended.get(messageId) ?? "running");
|
||||
// The turns the model was sent, from the newest request: a message that
|
||||
// started a turn on the stream opens it, and every other message joins
|
||||
// the turn before it. A steer a resumed turn replays reaches the model as
|
||||
// a plain message, so the stream, not the text, says who owns a turn.
|
||||
// the turn before it. A steer that joined a turn reaches the model as a
|
||||
// live update, a resumed turn's too, but the stream, not the text, says
|
||||
// who owns a turn.
|
||||
const turns = [];
|
||||
const counts = new Map();
|
||||
for (const text of requests.at(-1)?.users ?? []) {
|
||||
|
||||
+100
-2
@@ -1,4 +1,5 @@
|
||||
// The state graphs tests/tla/sdk/check.sh has TLC write, as the drivers read them.
|
||||
// The state graphs tests/tla/sdk/check.sh has TLC write, as the drivers read
|
||||
// them, and the walk drive-intake.mjs and drive-images.mjs take along one.
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
@@ -91,9 +92,106 @@ export async function loadGraph(path) {
|
||||
if (match) state[match[1]] = parseValue(match[2].trim());
|
||||
}
|
||||
states.set(id, state);
|
||||
if (state.act?.name === "Init") init = id;
|
||||
// `act` is a record with a name, or a tuple whose first item is one.
|
||||
if (state.act?.name === "Init" || (Array.isArray(state.act) && state.act[0] === "Init")) init = id;
|
||||
}
|
||||
}
|
||||
assert.ok(init, "the graph has an initial state");
|
||||
return { states, next, init };
|
||||
}
|
||||
|
||||
// Walks the code along `graph`, `walks` times. Each walk opens the code with
|
||||
// `open(walk)`, which returns `{ perform(act), settle(), close(), problems }`.
|
||||
// Each step is one the model allows from where the walk stands, chosen at
|
||||
// random among those that cross an edge no walk has crossed yet, if any;
|
||||
// `choose` may narrow that pool. After it the code must show, through
|
||||
// `view`, a state the model reaches once the code has taken every step in
|
||||
// `autoActs` it can on its own; `nameOf` gives a step's action name. A walk
|
||||
// the model does not allow, or whose code reports a problem, prints its path
|
||||
// and sets `process.exitCode`, and no more walks run. Returns the steps taken
|
||||
// and the edges covered.
|
||||
export async function walkGraph({ graph, walks, random, autoActs, nameOf, sameAct, label, view, open, choose = (pool) => pool, verbose = false }) {
|
||||
const actOf = (id) => graph.states.get(id).act;
|
||||
const successors = (id) => [...(graph.next.get(id) ?? [])].filter((to) => to !== id);
|
||||
// The states the code settles in after a step: every path of the steps it
|
||||
// takes on its own, to where it can take none.
|
||||
const settledFrom = (id, edges) => {
|
||||
const seen = new Set([id]);
|
||||
const queue = [id];
|
||||
const quiet = [];
|
||||
while (queue.length > 0) {
|
||||
const from = queue.shift();
|
||||
const autos = successors(from).filter((to) => autoActs.has(nameOf(actOf(to))));
|
||||
if (autos.length === 0) quiet.push(from);
|
||||
for (const to of autos) {
|
||||
edges.push(`${from}>${to}`);
|
||||
if (!seen.has(to)) { seen.add(to); queue.push(to); }
|
||||
}
|
||||
}
|
||||
return quiet;
|
||||
};
|
||||
const covered = new Set();
|
||||
const totalEdges = [...graph.next.values()].reduce((sum, targets) => sum + [...targets].length, 0);
|
||||
let steps = 0;
|
||||
for (let walk = 1; walk <= walks; walk += 1) {
|
||||
const code = await open(walk);
|
||||
const path = [];
|
||||
let current = new Set(settledFrom(graph.init, []));
|
||||
try {
|
||||
for (;;) {
|
||||
const options = [];
|
||||
for (const from of current) {
|
||||
for (const to of successors(from)) {
|
||||
const act = actOf(to);
|
||||
if (autoActs.has(nameOf(act))) continue;
|
||||
let item = options.find((entry) => sameAct(entry.act, act));
|
||||
if (!item) options.push(item = { act, fresh: false });
|
||||
if (!covered.has(`${from}>${to}`)) item.fresh = true;
|
||||
}
|
||||
}
|
||||
if (options.length === 0) break;
|
||||
const fresh = options.filter((item) => item.fresh);
|
||||
const pool = choose(fresh.length > 0 ? fresh : options);
|
||||
const { act } = pool[Math.floor(random() * pool.length)];
|
||||
path.push(label(act));
|
||||
if (verbose) console.log(`walk ${walk}: ${label(act)}`);
|
||||
await code.perform(act);
|
||||
const shown = await code.settle();
|
||||
if (code.problems.length > 0) throw new Error([...new Set(code.problems)].join("; "));
|
||||
const seen = JSON.stringify(shown);
|
||||
if (verbose) console.log(` shows ${seen}`);
|
||||
steps += 1;
|
||||
const next = new Set();
|
||||
const allowed = new Set();
|
||||
for (const from of current) {
|
||||
for (const to of successors(from).filter((id) => sameAct(actOf(id), act))) {
|
||||
const edges = [`${from}>${to}`];
|
||||
for (const end of settledFrom(to, edges)) {
|
||||
allowed.add(view(graph.states.get(end)));
|
||||
if (view(graph.states.get(end)) !== seen) continue;
|
||||
next.add(end);
|
||||
for (const edge of edges) covered.add(edge);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (next.size === 0) {
|
||||
console.error(`FAIL walk ${walk} after ${path.join(" ")}`);
|
||||
console.error(` the code shows ${seen}`);
|
||||
for (const item of allowed) console.error(` the model allows ${item}`);
|
||||
process.exitCode = 1;
|
||||
break;
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`FAIL walk ${walk} after ${path.join(" ")}: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await code.close();
|
||||
}
|
||||
if (process.exitCode) break;
|
||||
if (walk % 25 === 0 || walk === walks) console.log(`${walk} walks, ${steps} steps, ${covered.size} of ${totalEdges} edges`);
|
||||
}
|
||||
assert.ok(steps > 0 || process.exitCode, "the walks took steps");
|
||||
return { steps, covered: covered.size, totalEdges };
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
export type Verdict = "ACCEPTED" | "REJECTED" | "INCONCLUSIVE" | "TOOL_ERROR";
|
||||
export type Model = "turns" | "children" | "wiring";
|
||||
export type Model = "turns" | "children" | "fork" | "wiring";
|
||||
export interface RuntimeEvidence {
|
||||
version: 1;
|
||||
sessionsRoot: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Strict runtime-trace checking of real fx sessions against the session manager
|
||||
trace wrappers in tests/tla/session_manager/trace/."""
|
||||
trace wrappers in tests/tla/session_manager/trace/: each log's turns, its
|
||||
children's work, a fork's copied lines (ForkLog), and the processes' wiring."""
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import importlib
|
||||
@@ -173,9 +174,11 @@ def read_regular(path):
|
||||
def verify(request, report):
|
||||
ids = request.get('sessionIds', [])
|
||||
required = request.get('requiredModels', ['turns', 'wiring'])
|
||||
if request.get('version') != 1 or not ids or len(ids) != len(set(ids)) or not all(isinstance(s, str) and VALID_ID.fullmatch(s) for s in ids):
|
||||
raise ValueError('version 1 and nonempty unique safe sessionIds required')
|
||||
if not required or not set(required) <= {'turns', 'children', 'wiring'} or len(required) != len(set(required)):
|
||||
# A run that saved no session, such as a refused /fork, is judged by its
|
||||
# wiring alone.
|
||||
if request.get('version') != 1 or (not ids and required != ['wiring']) or len(ids) != len(set(ids)) or not all(isinstance(s, str) and VALID_ID.fullmatch(s) for s in ids):
|
||||
raise ValueError('version 1 and unique safe sessionIds required, nonempty unless only wiring is')
|
||||
if not required or not set(required) <= {'turns', 'children', 'fork', 'wiring'} or len(required) != len(set(required)):
|
||||
raise ValueError('requiredModels must be a nonempty unique supported model set')
|
||||
root = Path(request['sessionsRoot'])
|
||||
snapshot = report / 'input/sessions'
|
||||
@@ -211,7 +214,7 @@ def verify(request, report):
|
||||
# Bind that callback only in this new, single-threaded CLI process.
|
||||
saved.run_tlc = executor.run
|
||||
wiring.run_tlc = executor.run
|
||||
children_checked = 0
|
||||
children_checked = forks_checked = 0
|
||||
for sid, lines in observed.items():
|
||||
try:
|
||||
if 'turns' in required:
|
||||
@@ -226,10 +229,18 @@ def verify(request, report):
|
||||
saved.check_children(target, final)
|
||||
children_checked += 1
|
||||
checks.append({'kind': 'children', 'sessionId': sid, **executor.last})
|
||||
if 'fork' in required and lines[0].get('forked_from'):
|
||||
copied, source = saved.fork_target(saved.read_lines(snapshot / sid / 'log.jsonl', raw=True), str(snapshot))
|
||||
executor.expected = len(copied)
|
||||
saved.check_fork(copied, source)
|
||||
forks_checked += 1
|
||||
checks.append({'kind': 'fork', 'sessionId': sid, **executor.last})
|
||||
except (saved.Unreadable, KeyError, TypeError, ValueError) as error:
|
||||
checks.append({'kind': 'projection', 'sessionId': sid, 'verdict': 'INCONCLUSIVE', 'detail': str(error)})
|
||||
if 'children' in required and children_checked == 0:
|
||||
checks.append({'kind': 'children', 'verdict': 'INCONCLUSIVE', 'detail': 'required child work evidence is empty'})
|
||||
if 'fork' in required and forks_checked == 0:
|
||||
checks.append({'kind': 'fork', 'verdict': 'INCONCLUSIVE', 'detail': 'required fork evidence is empty'})
|
||||
if 'wiring' in required:
|
||||
try:
|
||||
data = read_regular(Path(request['wiringTrace']))
|
||||
@@ -244,7 +255,7 @@ def verify(request, report):
|
||||
'jarHash': sha256(jar), 'requestHash': sha256(report / 'request.json'),
|
||||
'checkerHash': sha256(Path(__file__)),
|
||||
'projectionHashes': {name: sha256(ARCHIVE / name) for name in ('check_saved_logs.py', 'check_wiring_traces.py')},
|
||||
'scope': 'Observed TurnLifecycle/Subagents/Wiring projections only; not all per-PR model refinement.'}
|
||||
'scope': 'Observed TurnLifecycle/Subagents/ForkLog/Wiring projections only; not all per-PR model refinement.'}
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
// /fork (D58) through the real binary in a terminal, judged by TLC. Each
|
||||
// scenario drives fx in tmux, then check_runtime.py has TLC check what it
|
||||
// left: every process's wiring trace against
|
||||
// tests/tla/session_manager/Wiring.tla (Fork with ForkIsNewSession,
|
||||
// ForkLeavesSource and ForkBetweenTurns), each saved log against
|
||||
// TurnLifecycle, and each fork's log against trace/ForkLog.tla: the
|
||||
// source's first lines byte for byte, ending at a turn boundary. Faults
|
||||
// planted into a kept run must be rejected.
|
||||
import { expect, test } from "bun:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { artifacts, binary, shellQuote, targetHelpers, testEnvironment } from "../lib/context";
|
||||
import { inspectRuntime, requireAccepted, type Model, type RuntimeEvidence } from "./bridge";
|
||||
|
||||
const { TmuxSession, startDynamicFakeGateway, fakeGatewayFinalText, fakeGatewayToolCall, heldFakeGatewayFinalText, FAKE_GATEWAY_MODEL, tmuxAvailable } =
|
||||
await targetHelpers();
|
||||
const TIMEOUT = 20_000;
|
||||
|
||||
type Reply = (body: string) => Response | Promise<Response>;
|
||||
type Process = { pid: number; exitCode: number | null; signal: string | null };
|
||||
type App = { session: any; pid: number; stderr: string };
|
||||
|
||||
function fixture(label: string) {
|
||||
const root = mkdtempSync(join(artifacts, `fork-${label}-`));
|
||||
const home = join(root, "home"), workspace = join(root, "workspace"), trace = join(root, "wiring.log");
|
||||
mkdirSync(join(home, ".fx"), { recursive: true, mode: 0o700 });
|
||||
mkdirSync(workspace, { mode: 0o700 });
|
||||
const sessionsRoot = join(home, ".fx/sessions/v2");
|
||||
let reply: Reply = () => fakeGatewayFinalText("UNSCRIPTED");
|
||||
const gateway = startDynamicFakeGateway(body => reply(body));
|
||||
const processes: Process[] = [];
|
||||
const apps: App[] = [];
|
||||
const env = (v2: boolean) => testEnvironment({
|
||||
HOME: home, AI_GATEWAY_API_KEY: "synthetic-fork-fixture", FX_SKIP_ONBOARDING: "1",
|
||||
FX_GATEWAY_BASE_URL: gateway.baseUrl, FX_GATEWAY_CHAT_URL: gateway.chatUrl,
|
||||
FX_E2E_GATEWAY_CHAT_URL: gateway.chatUrl, FX_E2E_GATEWAY_MODELS_URL: `${gateway.baseUrl}/coding-agent/v1/models`,
|
||||
FX_MODEL: FAKE_GATEWAY_MODEL, FX_PERMISSION_MODE: "full-access", NO_COLOR: "1",
|
||||
FX_TRACE_LOG: trace, FX_TRACE_SCOPES: "wiring", ...(v2 ? { FX_SESSIONS_V2: "1" } : {}),
|
||||
});
|
||||
return {
|
||||
root, trace, sessionsRoot, processes,
|
||||
script(next: Reply) { reply = next; },
|
||||
async app(args: string[] = [], v2 = true): Promise<App> {
|
||||
const stderr = join(root, `app-${apps.length + 1}.stderr`);
|
||||
writeFileSync(stderr, "");
|
||||
const command = [binary, ...args].map(shellQuote).join(" ");
|
||||
const session = await TmuxSession.create({ cmd: command, cwd: workspace, env: env(v2), stderrPath: stderr, width: 100, height: 40 });
|
||||
await session.waitForStableComposer(TIMEOUT);
|
||||
const app = { session, pid: session.processPid(), stderr };
|
||||
apps.push(app);
|
||||
return app;
|
||||
},
|
||||
async quit(app: App) {
|
||||
await app.session.sendKeys("C-u");
|
||||
await app.session.sendText("/quit");
|
||||
expect(await app.session.waitForSessionEnd(TIMEOUT)).toBe(true);
|
||||
processes.push({ pid: app.pid, exitCode: app.session.recordedExitStatus(), signal: null });
|
||||
expect(readFileSync(app.stderr, "utf8")).toBe("");
|
||||
},
|
||||
async crash(app: App) {
|
||||
process.kill(app.pid, "SIGKILL");
|
||||
expect(await app.session.waitForSessionEnd(TIMEOUT)).toBe(true);
|
||||
processes.push({ pid: app.pid, exitCode: null, signal: "SIGKILL" });
|
||||
},
|
||||
sessions(): string[] {
|
||||
if (!existsSync(sessionsRoot)) return [];
|
||||
return readdirSync(sessionsRoot).filter(id => !id.startsWith(".") && existsSync(join(sessionsRoot, id, "log.jsonl"))).sort();
|
||||
},
|
||||
log(id: string): any[] {
|
||||
return readFileSync(join(sessionsRoot, id, "log.jsonl"), "utf8").trimEnd().split("\n").map(line => JSON.parse(line));
|
||||
},
|
||||
/// TLC's verdict on the run: `ids` (default every saved session) under
|
||||
/// `models`, against this run's sessions and wiring trace.
|
||||
async judge(models: Model[], ids?: string[]) {
|
||||
const evidence: RuntimeEvidence = {
|
||||
version: 1, sessionsRoot, sessionIds: ids ?? this.sessions(), wiringTrace: trace, processes, requiredModels: models,
|
||||
};
|
||||
return inspectRuntime(evidence, join(root, "tlc"));
|
||||
},
|
||||
async finish() {
|
||||
for (const app of apps) await app.session.kill().catch(() => {});
|
||||
gateway.stop();
|
||||
},
|
||||
};
|
||||
}
|
||||
type Fixture = ReturnType<typeof fixture>;
|
||||
|
||||
function scenario(name: string, body: (f: Fixture) => Promise<void>) {
|
||||
test.skipIf(!tmuxAvailable())(name, async () => {
|
||||
const f = fixture(name.replace(/[^A-Za-z0-9]+/g, "-").slice(0, 40));
|
||||
try { await body(f); } finally { await f.finish(); }
|
||||
}, TIMEOUT * 8);
|
||||
}
|
||||
|
||||
/// Answers each request by the scripted prompt that appears last in it, so a
|
||||
/// fork's history and a resumed session's are answered by their newest prompt.
|
||||
function byLatestPrompt(answers: Record<string, string>): Reply {
|
||||
return body => {
|
||||
const latest = Object.keys(answers).map(prompt => [prompt, body.lastIndexOf(prompt)] as const)
|
||||
.filter(([, at]) => at >= 0).sort((a, b) => b[1] - a[1])[0];
|
||||
return fakeGatewayFinalText(latest ? answers[latest[0]]! : "UNSCRIPTED");
|
||||
};
|
||||
}
|
||||
|
||||
async function prompts(app: App, answers: Record<string, string>, which: string[]) {
|
||||
for (const prompt of which) {
|
||||
await app.session.sendText(prompt);
|
||||
await app.session.waitForText(answers[prompt]!, TIMEOUT);
|
||||
}
|
||||
await app.session.waitForStableComposer(TIMEOUT);
|
||||
}
|
||||
|
||||
/// The pane once it has stopped changing; unlike waitForStableComposer, the
|
||||
/// composer may hold text, as it does after a fork.
|
||||
async function settled(app: App): Promise<string> {
|
||||
const deadline = Date.now() + TIMEOUT;
|
||||
let previous = "", since = Date.now();
|
||||
while (Date.now() < deadline) {
|
||||
const pane: string = await app.session.capturePane();
|
||||
if (pane !== previous) [previous, since] = [pane, Date.now()];
|
||||
else if (Date.now() - since >= 300) return pane;
|
||||
await Bun.sleep(25);
|
||||
}
|
||||
throw new Error(`pane never settled:\n${previous}`);
|
||||
}
|
||||
|
||||
/// Opens /fork, moves `up` rows from the newest prompt, and forks there.
|
||||
async function fork(app: App, up: number) {
|
||||
await app.session.sendText("/fork");
|
||||
await app.session.waitForText("enter fork", TIMEOUT);
|
||||
for (let n = 0; n < up; n++) await app.session.sendKeys("Up");
|
||||
await app.session.sendKeys("Enter");
|
||||
await app.session.waitForText("session forked", TIMEOUT);
|
||||
await settled(app);
|
||||
}
|
||||
|
||||
const titles = (log: any[]) => log.filter(line => line.kind === "set" && line.key === "title").map(line => line.value);
|
||||
const turnsIn = (log: any[]) => log.filter(line => line.kind === "turn_committed").length;
|
||||
|
||||
const ANSWERS = { "Fork prompt one.": "FORK_ONE", "Fork prompt two.": "FORK_TWO", "Fork prompt three.": "FORK_THREE", "A new prompt in the fork.": "FORK_NEW" };
|
||||
// The run the planted faults below are made from.
|
||||
let kept: { root: string; sessions: string; wiring: string; source: string; fork: string; processes: Process[] } | null = null;
|
||||
|
||||
scenario("a fork before the middle prompt holds the turns before it, and the source is unchanged", async f => {
|
||||
f.script(byLatestPrompt(ANSWERS));
|
||||
const app = await f.app();
|
||||
await prompts(app, ANSWERS, ["Fork prompt one.", "Fork prompt two.", "Fork prompt three."]);
|
||||
const [source] = f.sessions();
|
||||
const before = readFileSync(join(f.sessionsRoot, source!, "log.jsonl"));
|
||||
await fork(app, 1);
|
||||
expect(await app.session.capturePane()).toContain("Fork prompt two.");
|
||||
await app.session.sendKeys("C-u");
|
||||
await prompts(app, ANSWERS, ["A new prompt in the fork."]);
|
||||
await f.quit(app);
|
||||
const ids = f.sessions();
|
||||
expect(ids).toHaveLength(2);
|
||||
const forkId = ids.find(id => id !== source)!;
|
||||
const after = readFileSync(join(f.sessionsRoot, source!, "log.jsonl"));
|
||||
expect(after.subarray(0, before.length).equals(before)).toBe(true);
|
||||
expect(turnsIn(f.log(forkId))).toBe(2);
|
||||
requireAccepted(await f.judge(["turns", "wiring", "fork"]));
|
||||
kept = { root: f.root, sessions: f.sessionsRoot, wiring: f.trace, source: source!, fork: forkId, processes: [...f.processes] };
|
||||
});
|
||||
|
||||
scenario("a fork before the first prompt holds no turn and puts that prompt in the composer", async f => {
|
||||
f.script(byLatestPrompt(ANSWERS));
|
||||
const app = await f.app();
|
||||
await prompts(app, ANSWERS, ["Fork prompt one.", "Fork prompt two."]);
|
||||
await fork(app, 1);
|
||||
expect(await app.session.capturePane()).toContain("Fork prompt one.");
|
||||
await f.quit(app);
|
||||
const ids = f.sessions();
|
||||
expect(ids).toHaveLength(2);
|
||||
expect(ids.map(id => turnsIn(f.log(id))).sort()).toEqual([0, 2]);
|
||||
requireAccepted(await f.judge(["turns", "wiring", "fork"]));
|
||||
});
|
||||
|
||||
scenario("a fork of a fork is a third session, titled with one fork mark", async f => {
|
||||
f.script(byLatestPrompt(ANSWERS));
|
||||
const app = await f.app();
|
||||
await prompts(app, ANSWERS, ["Fork prompt one.", "Fork prompt two."]);
|
||||
await app.session.sendText("/rename Fork source");
|
||||
await app.session.waitForText('renamed to "Fork source"', TIMEOUT);
|
||||
await fork(app, 0);
|
||||
await app.session.sendKeys("C-u");
|
||||
await prompts(app, ANSWERS, ["A new prompt in the fork."]);
|
||||
await fork(app, 0);
|
||||
await f.quit(app);
|
||||
const ids = f.sessions();
|
||||
expect(ids).toHaveLength(3);
|
||||
const forks = ids.filter(id => f.log(id)[0].forked_from);
|
||||
expect(forks).toHaveLength(2);
|
||||
for (const id of forks) expect(titles(f.log(id)).at(-1)).toBe("Fork source (fork)");
|
||||
requireAccepted(await f.judge(["turns", "wiring", "fork"]));
|
||||
});
|
||||
|
||||
scenario("/fork while a turn runs waits for it and forks nothing", async f => {
|
||||
const held = heldFakeGatewayFinalText();
|
||||
f.script(body => body.includes("A slow prompt.") ? held.response : fakeGatewayFinalText("UNSCRIPTED"));
|
||||
const app = await f.app();
|
||||
await app.session.sendText("A slow prompt.");
|
||||
await app.session.waitForText("Thinking", TIMEOUT);
|
||||
await app.session.sendText("/fork");
|
||||
await app.session.waitForText("/fork is available once the current turn finishes", TIMEOUT);
|
||||
held.release("SLOW_DONE");
|
||||
await app.session.waitForText("SLOW_DONE", TIMEOUT);
|
||||
await app.session.waitForStableComposer(TIMEOUT);
|
||||
await f.quit(app);
|
||||
expect(f.sessions()).toHaveLength(1);
|
||||
expect(readFileSync(f.trace, "utf8")).not.toContain("action=Fork");
|
||||
requireAccepted(await f.judge(["turns", "wiring"]));
|
||||
});
|
||||
|
||||
// The model forks only with no child running (ForkBetweenTurns). A subagent
|
||||
// the parent's reply to steering left working keeps that turn open, so /fork
|
||||
// must wait for it too.
|
||||
scenario("/fork while a subagent still works after steering waits for it", async f => {
|
||||
const child = heldFakeGatewayFinalText();
|
||||
let childAsked = false;
|
||||
f.script(raw => {
|
||||
const body = JSON.parse(raw);
|
||||
const latest = JSON.stringify(body.prompt?.filter((item: any) => item.role === "user").at(-1)?.content ?? "");
|
||||
if (latest.includes("FORK_CHILD_TASK")) { childAsked = true; return child.response; }
|
||||
if (raw.includes("FORK_CHILD_RESULT")) return fakeGatewayFinalText("PARENT_AFTER_CHILD");
|
||||
if (latest.includes("Answer now.")) return fakeGatewayFinalText("STEERED");
|
||||
if (latest.includes("Delegate the slow work.")) {
|
||||
return fakeGatewayToolCall("fork-delegation", "subagent", { request: { action: "run", task: "FORK_CHILD_TASK" } });
|
||||
}
|
||||
return fakeGatewayFinalText("UNSCRIPTED");
|
||||
});
|
||||
const app = await f.app();
|
||||
await app.session.sendText("Delegate the slow work.");
|
||||
await app.session.waitForPane(() => childAsked, TIMEOUT);
|
||||
await app.session.sendText("Answer now.");
|
||||
await app.session.waitForText("STEERED", TIMEOUT);
|
||||
await app.session.sendText("/fork");
|
||||
const pane: string = await app.session.waitForPane(
|
||||
(text: string) => text.includes("/fork is available once the current turn finishes") || text.includes("enter fork"), TIMEOUT);
|
||||
expect(pane).not.toContain("enter fork");
|
||||
child.release("FORK_CHILD_RESULT");
|
||||
await app.session.waitForText("PARENT_AFTER_CHILD", TIMEOUT);
|
||||
await app.session.waitForStableComposer(TIMEOUT);
|
||||
await fork(app, 0);
|
||||
await f.quit(app);
|
||||
requireAccepted(await f.judge(["turns", "children", "wiring", "fork"]));
|
||||
});
|
||||
|
||||
scenario("a crash right after a fork leaves the fork and its source whole", async f => {
|
||||
f.script(byLatestPrompt(ANSWERS));
|
||||
const first = await f.app();
|
||||
await prompts(first, ANSWERS, ["Fork prompt one.", "Fork prompt two."]);
|
||||
const [source] = f.sessions();
|
||||
await fork(first, 0);
|
||||
await f.crash(first);
|
||||
const forkId = f.sessions().find(id => id !== source)!;
|
||||
for (const id of [forkId, source!]) {
|
||||
const again = await f.app(["--resume", id]);
|
||||
await again.session.waitForText("FORK_ONE", TIMEOUT);
|
||||
await f.quit(again);
|
||||
}
|
||||
requireAccepted(await f.judge(["turns", "wiring", "fork"]));
|
||||
});
|
||||
|
||||
scenario("Esc closes the fork menu without forking", async f => {
|
||||
f.script(byLatestPrompt(ANSWERS));
|
||||
const app = await f.app();
|
||||
await prompts(app, ANSWERS, ["Fork prompt one."]);
|
||||
await app.session.sendText("/fork");
|
||||
await app.session.waitForText("enter fork", TIMEOUT);
|
||||
await app.session.sendKeys("Escape");
|
||||
await app.session.waitForPane((pane: string) => !pane.includes("enter fork"), TIMEOUT);
|
||||
await f.quit(app);
|
||||
expect(f.sessions()).toHaveLength(1);
|
||||
requireAccepted(await f.judge(["turns", "wiring"]));
|
||||
});
|
||||
|
||||
scenario("/fork with no prompt yet says so and saves nothing", async f => {
|
||||
const app = await f.app();
|
||||
await app.session.sendText("/fork");
|
||||
await app.session.waitForText("nothing to fork yet", TIMEOUT);
|
||||
await f.quit(app);
|
||||
expect(f.sessions()).toHaveLength(0);
|
||||
requireAccepted(await f.judge(["wiring"], []));
|
||||
});
|
||||
|
||||
// Without --sessions-v2 fx never opens the session manager, so there is no
|
||||
// trace for TLC to judge: the check is that none was written.
|
||||
scenario("/fork without --sessions-v2 says it needs it and never opens the session manager", async f => {
|
||||
f.script(byLatestPrompt(ANSWERS));
|
||||
const app = await f.app([], false);
|
||||
await prompts(app, ANSWERS, ["Fork prompt one."]);
|
||||
await app.session.sendText("/fork");
|
||||
await app.session.waitForText("forking needs --sessions-v2", TIMEOUT);
|
||||
await f.quit(app);
|
||||
expect(f.sessions()).toHaveLength(0);
|
||||
expect(existsSync(f.trace) ? readFileSync(f.trace, "utf8") : "").toBe("");
|
||||
});
|
||||
|
||||
// Planted faults, made from the first scenario's kept run: each copy changes
|
||||
// one thing the model forbids, and TLC must reject it.
|
||||
|
||||
function crc32c(data: Buffer): number {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of data) {
|
||||
crc ^= byte;
|
||||
for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (crc & 1 ? 0x82f63b78 : 0);
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
/// `line` with `from` replaced by `to` and its CRC recomputed, so the change
|
||||
/// reaches the model instead of the line check.
|
||||
function resealed(line: string, from: string, to: string): string {
|
||||
const marker = line.lastIndexOf(',"crc":"');
|
||||
assert(marker > 0 && line.slice(0, marker).includes(from), `no ${from} to plant in that line`);
|
||||
const body = line.slice(0, marker).replace(from, to);
|
||||
return `${body},"crc":"${crc32c(Buffer.from(body)).toString(16).padStart(8, "0")}"}`;
|
||||
}
|
||||
|
||||
async function plantedVerdict(models: Model[], plant: (copy: { sessions: string; wiring: string }) => void) {
|
||||
assert(kept, "the first scenario keeps the run these faults are planted into");
|
||||
const root = mkdtempSync(join(kept.root, "planted-"));
|
||||
const copy = { sessions: join(root, "sessions"), wiring: join(root, "wiring.log") };
|
||||
cpSync(kept.sessions, copy.sessions, { recursive: true });
|
||||
cpSync(kept.wiring, copy.wiring);
|
||||
plant(copy);
|
||||
const evidence: RuntimeEvidence = {
|
||||
version: 1, sessionsRoot: copy.sessions, sessionIds: [kept.source, kept.fork].sort(), wiringTrace: copy.wiring,
|
||||
processes: kept.processes, requiredModels: models,
|
||||
};
|
||||
return (await inspectRuntime(evidence, join(root, "tlc"))).verdict;
|
||||
}
|
||||
|
||||
test.skipIf(!tmuxAvailable())("planted: a fork moved inside a turn is rejected", async () => {
|
||||
const verdict = await plantedVerdict(["wiring"], copy => {
|
||||
const lines = readFileSync(copy.wiring, "utf8").trimEnd().split("\n");
|
||||
const at = lines.findIndex(line => line.includes("[wiring] action=Fork "));
|
||||
const begin = lines.slice(0, at).findLastIndex(line => line.includes("[wiring] action=BeginTurn "));
|
||||
assert(at > 0 && begin >= 0, "the kept run has a fork after a turn began");
|
||||
const [moved] = lines.splice(at, 1);
|
||||
lines.splice(begin + 1, 0, moved!);
|
||||
writeFileSync(copy.wiring, lines.join("\n") + "\n");
|
||||
});
|
||||
expect(verdict).toBe("REJECTED");
|
||||
}, TIMEOUT * 4);
|
||||
|
||||
test.skipIf(!tmuxAvailable())("planted: a fork whose copied line differs from its source is rejected", async () => {
|
||||
const verdict = await plantedVerdict(["fork"], copy => {
|
||||
const path = join(copy.sessions, kept!.fork, "log.jsonl");
|
||||
const lines = readFileSync(path, "utf8").trimEnd().split("\n");
|
||||
const at = lines.findIndex(line => line.includes("Fork prompt one."));
|
||||
assert(at > 0, "the kept fork copied the first prompt");
|
||||
lines[at] = resealed(lines[at]!, "Fork prompt one.", "Fork prompt ONE.");
|
||||
writeFileSync(path, lines.join("\n") + "\n");
|
||||
});
|
||||
expect(verdict).toBe("REJECTED");
|
||||
}, TIMEOUT * 4);
|
||||
@@ -11,7 +11,7 @@ plants realistic bugs and requires TLC to catch each one.
|
||||
| `ParallelBatch.tla` | Parallel by default, `writes: true` as a fence, results in model order | A writer never overlaps another call, and nothing passes a writer |
|
||||
| `Fencing.tla` | Two processes on one World log with conditional appends | One intent, one outcome, one run across processes |
|
||||
| `Inbox.tla` | Prompts, steers, and follow-ups through the journal; withdraw | A resolved promise means durable, and each input is placed once |
|
||||
| `Resumption.tla` | Resuming a turn a crash cut off | fx never asks, every crash of an open turn is resumed, and the model is told |
|
||||
| `Resumption.tla` | Resuming a turn a crash cut off, including after its reply was saved | fx never asks, every crash of an open turn is resumed, the model is told, a saved reply is delivered without asking the model again, and `turn.result` waits for the turn's durable end |
|
||||
| `SnapshotTail.tla` | `load()` returning a snapshot plus the events after it | Snapshots only cover durable events, and restore replays the tail exactly once |
|
||||
|
||||
Every spec also checks `TypeOK`.
|
||||
|
||||
@@ -9,3 +9,5 @@ INVARIANTS
|
||||
ModelToldEveryTime
|
||||
NeverResumeFinishedTurn
|
||||
AutoResumeOnlyAfterCrash
|
||||
SavedAnswerNotRequested
|
||||
ResultOnlyAfterDurableEnd
|
||||
@@ -11,6 +11,12 @@
|
||||
(* covers `agent.resume()`, the queue waking a function through *)
|
||||
(* libfx/workflow's route, and `fx -c`. *)
|
||||
(* *)
|
||||
(* A turn ends in three steps: its final reply is saved with the turn's *)
|
||||
(* progress, then its end is sent, then the end lands. A crash in *)
|
||||
(* between loses the end, never the saved reply. A resume that finds a *)
|
||||
(* saved reply delivers it and asks the model nothing, and the host's *)
|
||||
(* `turn.result` resolves only once the turn's end is durable. *)
|
||||
(* *)
|
||||
(* Today's CLI asks after the first unclean exit *)
|
||||
(* (`ask_after_unclean_exit`); mutants.py keeps that behavior, and a *)
|
||||
(* one-resume guard, as planted bugs this spec must reject. *)
|
||||
@@ -27,11 +33,19 @@ VARIABLES
|
||||
autoResumes, \* durable: automatic resumptions of this turn
|
||||
asked, \* fx asked the user what to do with this turn
|
||||
notices, \* resume notices given to the model for this turn
|
||||
delivered, \* resumptions that delivered the turn's saved reply
|
||||
badResume, \* history: an automatic resume of a turn that was not cut off
|
||||
crashes
|
||||
crashes,
|
||||
reply, \* durable: "none" | "saved", the open turn's final reply
|
||||
ending, \* the turn's end was sent and is not durable yet
|
||||
resolved, \* the host's turn.result for this turn has resolved
|
||||
replySaved, \* history: this turn's reply was saved at some point
|
||||
askedAgain, \* history: a model request after the turn's reply was saved
|
||||
earlyResult \* history: a turn.result that resolved before its durable end
|
||||
|
||||
vars == <<proc, turn, turns, crashesInTurn, autoResumes, asked, notices,
|
||||
badResume, crashes>>
|
||||
delivered, badResume, crashes, reply, ending, resolved, replySaved,
|
||||
askedAgain, earlyResult>>
|
||||
|
||||
TypeOK ==
|
||||
/\ proc \in {"running", "down"}
|
||||
@@ -41,8 +55,15 @@ TypeOK ==
|
||||
/\ autoResumes \in 0..MaxCrashes
|
||||
/\ asked \in BOOLEAN
|
||||
/\ notices \in 0..MaxCrashes
|
||||
/\ delivered \in 0..MaxCrashes
|
||||
/\ badResume \in BOOLEAN
|
||||
/\ crashes \in 0..MaxCrashes
|
||||
/\ reply \in {"none", "saved"}
|
||||
/\ ending \in BOOLEAN
|
||||
/\ resolved \in BOOLEAN
|
||||
/\ replySaved \in BOOLEAN
|
||||
/\ askedAgain \in BOOLEAN
|
||||
/\ earlyResult \in BOOLEAN
|
||||
|
||||
Init ==
|
||||
/\ proc = "running"
|
||||
@@ -52,8 +73,15 @@ Init ==
|
||||
/\ autoResumes = 0
|
||||
/\ asked = FALSE
|
||||
/\ notices = 0
|
||||
/\ delivered = 0
|
||||
/\ badResume = FALSE
|
||||
/\ crashes = 0
|
||||
/\ reply = "none"
|
||||
/\ ending = FALSE
|
||||
/\ resolved = FALSE
|
||||
/\ replySaved = FALSE
|
||||
/\ askedAgain = FALSE
|
||||
/\ earlyResult = FALSE
|
||||
|
||||
StartTurn ==
|
||||
/\ proc = "running"
|
||||
@@ -65,53 +93,128 @@ StartTurn ==
|
||||
/\ autoResumes' = 0
|
||||
/\ asked' = FALSE
|
||||
/\ notices' = 0
|
||||
/\ UNCHANGED <<proc, badResume, crashes>>
|
||||
/\ delivered' = 0
|
||||
/\ reply' = "none"
|
||||
/\ ending' = FALSE
|
||||
/\ resolved' = FALSE
|
||||
/\ replySaved' = FALSE
|
||||
/\ UNCHANGED <<proc, badResume, crashes, askedAgain, earlyResult>>
|
||||
|
||||
\* The turn finishes and its end is durable.
|
||||
Commit ==
|
||||
\* A model request for the open turn. One whose turn already has a saved
|
||||
\* final reply would ask the model for an answer the turn already has.
|
||||
Request ==
|
||||
/\ proc = "running"
|
||||
/\ turn = "open"
|
||||
/\ ~ending
|
||||
/\ reply = "none"
|
||||
/\ askedAgain' = (askedAgain \/ replySaved)
|
||||
/\ UNCHANGED <<proc, turn, turns, crashesInTurn, autoResumes, asked,
|
||||
notices, delivered, badResume, crashes, reply, ending,
|
||||
resolved, replySaved, earlyResult>>
|
||||
|
||||
\* The final reply finished, and the progress saved after it holds it.
|
||||
FinishReply ==
|
||||
/\ proc = "running"
|
||||
/\ turn = "open"
|
||||
/\ ~ending
|
||||
/\ reply = "none"
|
||||
/\ reply' = "saved"
|
||||
/\ replySaved' = TRUE
|
||||
/\ UNCHANGED <<proc, turn, turns, crashesInTurn, autoResumes, asked,
|
||||
notices, delivered, badResume, crashes, ending, resolved,
|
||||
askedAgain, earlyResult>>
|
||||
|
||||
\* The turn's end goes to the store, which has not made it durable yet.
|
||||
SendEnd ==
|
||||
/\ proc = "running"
|
||||
/\ turn = "open"
|
||||
/\ reply = "saved"
|
||||
/\ ~ending
|
||||
/\ ending' = TRUE
|
||||
/\ UNCHANGED <<proc, turn, turns, crashesInTurn, autoResumes, asked,
|
||||
notices, delivered, badResume, crashes, reply, resolved,
|
||||
replySaved, askedAgain, earlyResult>>
|
||||
|
||||
\* The store makes the turn's end durable.
|
||||
EndLands ==
|
||||
/\ proc = "running"
|
||||
/\ ending
|
||||
/\ turn' = "committed"
|
||||
/\ UNCHANGED <<proc, turns, crashesInTurn, autoResumes, asked, notices, badResume, crashes>>
|
||||
/\ ending' = FALSE
|
||||
/\ UNCHANGED <<proc, turns, crashesInTurn, autoResumes, asked, notices,
|
||||
delivered, badResume, crashes, reply, resolved, replySaved,
|
||||
askedAgain, earlyResult>>
|
||||
|
||||
\* The host's turn.result resolves.
|
||||
ResolveResult ==
|
||||
/\ proc = "running"
|
||||
/\ ~resolved
|
||||
/\ turns > 0
|
||||
/\ turn = "committed"
|
||||
/\ resolved' = TRUE
|
||||
/\ earlyResult' = (earlyResult \/ turn # "committed")
|
||||
/\ UNCHANGED <<proc, turn, turns, crashesInTurn, autoResumes, asked,
|
||||
notices, delivered, badResume, crashes, reply, ending,
|
||||
replySaved, askedAgain>>
|
||||
|
||||
\* The user cancels the turn (Esc, Ctrl+C, turn.cancel()); recorded durably.
|
||||
UserStop ==
|
||||
/\ proc = "running"
|
||||
/\ turn = "open"
|
||||
/\ ~ending
|
||||
/\ turn' = "user_stopped"
|
||||
/\ UNCHANGED <<proc, turns, crashesInTurn, autoResumes, asked, notices, badResume, crashes>>
|
||||
/\ UNCHANGED <<proc, turns, crashesInTurn, autoResumes, asked, notices,
|
||||
delivered, badResume, crashes, reply, ending, resolved,
|
||||
replySaved, askedAgain, earlyResult>>
|
||||
|
||||
\* Laptop crash, kill -9, function timeout.
|
||||
\* Laptop crash, kill -9, function timeout. An end still being stored is
|
||||
\* lost; the saved reply is not.
|
||||
Crash ==
|
||||
/\ proc = "running"
|
||||
/\ crashes < MaxCrashes
|
||||
/\ proc' = "down"
|
||||
/\ crashes' = crashes + 1
|
||||
/\ crashesInTurn' = IF turn = "open" THEN crashesInTurn + 1 ELSE crashesInTurn
|
||||
/\ UNCHANGED <<turn, turns, autoResumes, asked, notices, badResume>>
|
||||
/\ ending' = FALSE
|
||||
/\ UNCHANGED <<turn, turns, autoResumes, asked, notices, delivered,
|
||||
badResume, reply, resolved, replySaved, askedAgain,
|
||||
earlyResult>>
|
||||
|
||||
\* A clean exit only happens between turns.
|
||||
CleanExit ==
|
||||
/\ proc = "running"
|
||||
/\ turn # "open"
|
||||
/\ proc' = "down"
|
||||
/\ UNCHANGED <<turn, turns, crashesInTurn, autoResumes, asked, notices, badResume, crashes>>
|
||||
/\ UNCHANGED <<turn, turns, crashesInTurn, autoResumes, asked, notices,
|
||||
delivered, badResume, crashes, reply, ending, resolved,
|
||||
replySaved, askedAgain, earlyResult>>
|
||||
|
||||
\* agent.resume(), the queue waking a function, or fx -c.
|
||||
\* agent.resume(), the queue waking a function, or fx -c. A turn with a
|
||||
\* saved reply is delivered and asks the model nothing; any other open turn
|
||||
\* tells the model in its next request.
|
||||
Reopen ==
|
||||
/\ proc = "down"
|
||||
/\ proc' = "running"
|
||||
/\ IF turn = "open"
|
||||
THEN /\ autoResumes' = autoResumes + 1
|
||||
/\ notices' = notices + 1
|
||||
/\ IF reply = "saved"
|
||||
THEN /\ delivered' = delivered + 1
|
||||
/\ UNCHANGED notices
|
||||
ELSE /\ notices' = notices + 1
|
||||
/\ UNCHANGED delivered
|
||||
/\ UNCHANGED asked
|
||||
ELSE UNCHANGED <<autoResumes, notices, asked>>
|
||||
ELSE UNCHANGED <<autoResumes, notices, delivered, asked>>
|
||||
/\ badResume' = (badResume \/ (autoResumes' > autoResumes /\ turn # "open"))
|
||||
/\ UNCHANGED <<turn, turns, crashesInTurn, crashes>>
|
||||
/\ UNCHANGED <<turn, turns, crashesInTurn, crashes, reply, ending,
|
||||
resolved, replySaved, askedAgain, earlyResult>>
|
||||
|
||||
Next ==
|
||||
\/ StartTurn
|
||||
\/ Commit
|
||||
\/ Request
|
||||
\/ FinishReply
|
||||
\/ SendEnd
|
||||
\/ EndLands
|
||||
\/ ResolveResult
|
||||
\/ UserStop
|
||||
\/ Crash
|
||||
\/ CleanExit
|
||||
@@ -129,8 +232,9 @@ NeverAsks == ~asked
|
||||
\* time the session runs again.
|
||||
EveryCrashResumed == proc = "running" /\ turn = "open" => autoResumes = crashesInTurn
|
||||
|
||||
\* Every automatic resumption tells the model what happened.
|
||||
ModelToldEveryTime == notices = autoResumes
|
||||
\* Every automatic resumption tells the model what happened, or asks it
|
||||
\* nothing because it delivers the turn's saved reply.
|
||||
ModelToldEveryTime == notices + delivered = autoResumes
|
||||
|
||||
\* Committed and user-stopped turns are never resumed automatically.
|
||||
NeverResumeFinishedTurn == ~badResume
|
||||
@@ -138,4 +242,14 @@ NeverResumeFinishedTurn == ~badResume
|
||||
\* Each automatic resumption answers a crash during that turn.
|
||||
AutoResumeOnlyAfterCrash == autoResumes <= crashesInTurn
|
||||
|
||||
\* A turn whose final reply was saved never asks the model again.
|
||||
SavedAnswerNotRequested == ~askedAgain
|
||||
|
||||
\* The host's turn.result resolves only after the turn's end is durable.
|
||||
ResultOnlyAfterDurableEnd == ~earlyResult
|
||||
|
||||
\* Witness only: no resumption ever delivers a saved reply. mutants.py
|
||||
\* adds it to the .cfg and requires TLC to violate it.
|
||||
NoDelivery == delivered = 0
|
||||
|
||||
=============================================================================
|
||||
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user