From 9b37fb71b92e08251aaf4478805b7ea2d48cab3e Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Wed, 29 Jul 2026 22:32:12 -0700 Subject: [PATCH 01/13] Add the Agent Factories authoring surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent Factories let a trusted extension declare a JavaScript closure that orchestrates a fleet of subagents, and invoke it by name. The runtime half shipped in copilot-agent-runtime#12953 and #13077; this is the SDK half. The feature is dark behind the runtime's `agent_factories` flag plus its billing gate, and every public type is marked `@experimental`. Authoring - `defineFactory({ meta, run })` validates limits and phases at the authoring boundary and returns an opaque handle. The metadata is deep snapshotted and frozen, so mutating the caller's object afterwards cannot desynchronize what was validated from what is advertised. - `joinSession({ factories })` registers handles for a session; names must be unique within the call. Running - `session.factory.run()` and `.resume()` resolve with the run envelope (`FactoryRunResult`) for every outcome — `completed`, `error`, `halted`, and `cancelled` alike. A declined fresh run resolves with a terminal `cancelled` envelope, because the run row already exists by the time the prompt is answered; only failures that occur before a run exists reject. - `session.factory.waitForRun()` settles once a run reaches a terminal status. It subscribes to `factory.run_updated` before its first read so a transition cannot be missed, collapses a burst of invalidations into a single re-read, and re-reads periodically so a dropped event degrades into a slightly late resolution rather than an unbounded wait. Inside a run - `context.agent()` spawns a subagent, `step()` journals a result so a resumed run replays it for free, and `phase()`/`log()` report progress. - `parallel()` and `pipeline()` compose subagent work; both propagate hard RPC failures rather than folding them into per-branch results. - Journaled values are validated as strict JSON, because a step result has to survive serialization unchanged to be replayable. Negative zero is rejected for that reason: it serializes to `0`. - Cancellation is checked before an operation is dispatched, so an aborted run does not start new runtime work. Abort controllers are keyed by run ID and execution token so overlapping attempts stay individually addressable. Codegen - Narrows the `x-opaque-json` conversion to definitions carrying no structural keywords. Only one of the annotated definitions is genuinely unconstrained; blanket-converting the rest discarded the structure of `McpServerConfig`, `ExternalToolResult`, `EventLogTypes`, and `UIElicitationSchemaProperty`. Without this, a factory result generates as an object alone, which a non-object or void result cannot satisfy. - Genuinely opaque nodes emit `JsonValue` rather than `unknown`, a global tightening. `ToolTelemetry`, the hooks-invoke response, and one session-event assertion are realigned to match. Docs are in `nodejs/docs/factories.md` and `nodejs/docs/factory-patterns.md`. --- nodejs/docs/extensions.md | 1 + nodejs/docs/factories.md | 236 +++ nodejs/docs/factory-patterns.md | 194 +++ nodejs/src/client.ts | 25 +- nodejs/src/extension.ts | 57 +- nodejs/src/factory.ts | 417 +++++ nodejs/src/generated/rpc.ts | 411 +---- nodejs/src/generated/session-events.ts | 217 +-- nodejs/src/index.ts | 26 + nodejs/src/session.ts | 758 ++++++++- nodejs/src/types.ts | 41 +- nodejs/test/extension.test.ts | 12 +- nodejs/test/factory.test.ts | 1899 +++++++++++++++++++++++ nodejs/test/session-event-types.test.ts | 16 +- nodejs/test/typescript-codegen.test.ts | 38 +- scripts/codegen/typescript.ts | 49 +- 16 files changed, 3858 insertions(+), 539 deletions(-) create mode 100644 nodejs/docs/factories.md create mode 100644 nodejs/docs/factory-patterns.md create mode 100644 nodejs/src/factory.ts create mode 100644 nodejs/test/factory.test.ts diff --git a/nodejs/docs/extensions.md b/nodejs/docs/extensions.md index 8b36de8a50..d33a733120 100644 --- a/nodejs/docs/extensions.md +++ b/nodejs/docs/extensions.md @@ -56,4 +56,5 @@ The `session` object provides methods for sending messages, logging to the timel ## Further Reading - `examples.md` — Practical code examples for tools, hooks, events, and complete extensions +- `factories.md`: Authoring, running, resuming, and observing Agent Factories - `agent-author.md` — Step-by-step workflow for agents authoring extensions programmatically diff --git a/nodejs/docs/factories.md b/nodejs/docs/factories.md new file mode 100644 index 0000000000..025d44442a --- /dev/null +++ b/nodejs/docs/factories.md @@ -0,0 +1,236 @@ +# Agent Factories + +Agent Factories are extension-authored, session-scoped workflows that coordinate subagents and durable steps. The API is experimental. + +## Define and register a factory + +Use `defineFactory` and pass the returned handle to `joinSession`: + +```js +import { defineFactory, joinSession } from "@github/copilot-sdk/extension"; + +const reviewChanged = defineFactory({ + meta: { + name: "review-changed", + description: "Review changed files and verify the findings", + phases: [{ title: "Review" }, { title: "Verify" }], + limits: { + maxConcurrentSubagents: 3, + maxTotalSubagents: 10, + timeoutSeconds: 90.5, + maxAiCredits: 5, + }, + }, + run: async (ctx) => { + ctx.phase("Review"); + const reviews = await ctx.parallel( + ctx.args.files.map( + (file) => () => ctx.agent(`Review ${file}`, { label: `Review ${file}` }) + ) + ); + + ctx.phase("Verify"); + const report = await ctx.step("report", () => ({ reviews })); + ctx.log(`Completed factory run ${ctx.runId}`); + return report; + }, +}); + +const session = await joinSession({ factories: [reviewChanged] }); +``` + +Factory metadata contains a stable `name`, a human-readable `description`, declared `phases`, and optional `limits`. Phase entries contain a `title` and optional `detail`. + +`defineFactory` accepts a `run(context)` function returning `Promise`, where `TResult` is `JsonValue | void`. Objects, arrays, strings, numbers, booleans, and `null` are valid results. Returning `undefined` completes the factory with no result. Other non-JSON values are rejected. + +## Factory context + +The `run()` context provides: + +- `ctx.runId`: Stable ID reused across resumed attempts. +- `ctx.args`: Invocation arguments, forwarded verbatim. When the caller omits `args`, this is `{}` rather than `undefined`. +- `ctx.agent(prompt, options?)`: Runs one factory-owned subagent. Options are exactly `label`, `schema`, and `model`. See [Subagent calls](#subagent-calls). +- `ctx.parallel(thunks)`: Runs thunks concurrently and awaits all of them (a barrier). A thunk that throws becomes `null` in the result array, except cooperative cancellation propagates. Rejects above 4096 items. +- `ctx.pipeline(items, ...stages)`: Flows each item through every stage without a barrier between stages, so one item can be in a later stage while another is still in an earlier one. Each stage is called as `(previous, item, index)`, where `previous` is the prior stage's result and `item` is the original input. A stage that throws drops that item to `null` and skips its remaining stages. Rejects above 4096 items. +- `ctx.phase(title)`: Starts a named progress phase. This sets a single run-global value, so calling it from inside concurrent `parallel`/`pipeline` stages races. Call it at run-level transitions and distinguish concurrent work by `label` instead. +- `ctx.log(message)`: Appends a progress line. When a factory bounds its own coverage (top-N, sampling), log what was dropped. +- `ctx.step(key, producer, options?)`: Journals the producer's JSON result under a stable key so a resume replays it without re-running the producer. A journaled (default) producer must return a JSON-serializable value; `undefined` or a non-JSON value is rejected. Pass `{ volatile: true }` to bypass the journal and run the producer every time. + + The key is the *sole* identity: neither the producer body nor its inputs contribute to it. A resume replays the cached value for a matching key even if the producer has since changed, so version the key (`"scan-v2"`) whenever its inputs or meaning change. Journaled producers are best-effort at-least-once and may run again across crashes or concurrent same-key callers, so keep side effects idempotent. +- `ctx.session`: The full session returned by `joinSession`. +- `ctx.signal`: Cooperative cancellation signal for extension work and subprocesses. +- `ctx.factory(...)`: Always rejects because nested factories are not supported. + +Factory-owned subagents are intentionally hidden from `read_agent` and `write_agent`. Use the factory observability APIs instead. + +### Subagent calls + +`ctx.agent(prompt, options?)` spawns one factory-scoped subagent and awaits it. Without a schema it resolves to the subagent's final text. With `options.schema` it resolves to the parsed JSON value. + +**Identical calls are memoized into one subagent.** Each call is journaled by its canonical prompt and options, including `label`. Two calls with the same prompt and the same options return one shared result — even when issued concurrently. To spawn N *independent* subagents, give each a unique `label` or vary the prompt: + +```js +// One subagent, awaited five times — almost certainly not what you want. +await ctx.parallel([1, 2, 3, 4, 5].map(() => () => ctx.agent("Find a bug"))); + +// Five independent subagents. +await ctx.parallel( + [1, 2, 3, 4, 5].map((i) => () => ctx.agent("Find a bug", { label: `finder:${i}` })) +); +``` + +**An ordinary failure resolves to `null` — it does not throw.** A subagent that errors, returns nothing, or (with a schema) produces output that still fails to parse or match after its one retry resolves `null`. Always guard the result before using it, including a bare `await ctx.agent(...)`: + +```js +const finding = await ctx.agent(prompt, { label: "inspector" }); +if (!finding) return { finding: null }; +``` + +Cancellation and hard runtime failures — a reached limit, a durable-state failure — reject instead, aborting the run. When filtering results, prefer `v => v !== null` over `Boolean`, which also discards a valid `false`, `0`, or `""`. + +**`schema` is a structural subset of JSON Schema, not a validator.** Honored: `type`, `required`, `enum`, `const`, recursive `properties`/`items`, and `anyOf`/`oneOf`/`allOf` — where `oneOf` is treated as `anyOf`, meaning at least one branch matches rather than exactly one. Ignored and *not* enforced: `additionalProperties`, `pattern`, `minLength`/`maxLength`, `format`, numeric ranges, and boolean schemas. Do not rely on an ignored keyword to constrain a result. A schema call retries once on a parse or match failure, so it may spawn twice, and both spawns count toward `maxTotalSubagents`. + +### Choosing between pipeline and parallel + +Prefer `pipeline` for multi-stage work. It has no barrier between stages, so each item advances as soon as its own prior stage finishes. + +Reach for a barrier — `parallel` between stages — only when a stage genuinely needs every prior result at once: deduplicating or merging across the full set, an early exit based on the total, or a prompt that compares one result against the others. Needing to map, filter, or flatten is not a reason to use a barrier; do that inside a pipeline stage. Barrier latency is real: if the slowest of N subagents takes three times the fastest, a barrier wastes the rest of the pool's time. + +See [factory-patterns.md](./factory-patterns.md) for composable orchestration patterns built on these primitives. + +## Resource limits + +Limits may be declared in `meta.limits` and overridden per invocation. All limits must be positive when present. + +- `maxConcurrentSubagents`: Positive integer concurrent-subagent cap. Additional subagents wait in a queue. Queueing applies backpressure and does not fail the run. +- `maxTotalSubagents`: Positive integer cumulative admission cap. An attempted subagent beyond the cap ends the attempt with failure kind `maxTotalSubagents`. +- `timeoutSeconds`: Positive finite number of seconds, including positive fractions, capped at `2_147_483.647`. It measures accumulated active-execution time across attempts, including the extension body, subprocess waits, queued-agent waits, and sleeps. Time between attempts is excluded. The timeout is soft because already-running work may take time to stop. Its failure kind is `timeoutSeconds`. +- `maxAiCredits`: Positive finite AI-credit budget for the whole run's factory subagent subtree, including descendants. AI credits are GitHub Copilot's universal usage metric. This is a soft, post-paid ceiling, so completed or parallel turns can settle above it before the run stops. Accounting is fail-closed: an accounting failure stops a budgeted run rather than allowing untracked use. Its failure kind is `maxAiCredits`. + +`maxTotalSubagents`, `timeoutSeconds`, and `maxAiCredits` use reject-and-retry semantics. A rejected attempt ends with run status `error` and `failure.type` set to `factory_limit_reached`. The failed run keeps its ID, arguments, journal, and accounting. Resume the run with a raised limit when additional work is approved. Previously consumed resources still count. + +## Run and resume + +Run by registered name or handle: + +```ts +const run = await session.factory.run("review-changed", { + args: { files: ["src/a.ts"] }, + limits: { maxAiCredits: 3 }, +}); + +if (run.status === "completed") { + console.log(run.result); +} else { + console.error(`run ${run.runId} ended as ${run.status}`, run.failure ?? run.error); +} +``` + +The name overload is: + +```ts +session.factory.run( + name: string, + options?: { args?: JsonValue; limits?: FactoryLimits }, +): Promise; +``` + +Resume by run ID without resending the name or arguments: + +```ts +const run = await session.factory.resume(runId, { + limits: { maxAiCredits: 6 }, +}); +``` + +The signature is: + +```ts +session.factory.resume( + runId: string, + options?: { limits?: FactoryLimits }, +): Promise; +``` + +Both resolve with the run envelope (`FactoryRunResult`) for **every** outcome — `completed`, `error`, `halted`, and `cancelled` alike. Inspect `status` and read `result` only when the run completed; a limit breach carries a typed `failure`. A declined fresh run is not a pre-execution failure: the run row already exists by the time the prompt is answered, so it resolves with a terminal `cancelled` envelope carrying the run ID. Only failures that occur *before* a run exists reject: an unknown factory name or an already-active session. Pre-execution resume failures, including a declined reapproval, throw `FactoryResumeError`, whose `code` is one of `not_found`, `non_resumable`, `already_active`, `reapproval_declined`, or `no_approval_provider`. + +An agent that no longer has a prior run's ID in context can recover it with `factories_manage` and `operation: "runs"`, which lists the session's factory runs with their IDs and statuses. This matters for resume: a run that reached a limit keeps its journal, so resuming it replays completed work for free, while restarting it from scratch pays for that work twice. + +The agent-facing `run_factory` tool has exactly two input branches: + +```ts +{ name: string; args?: JsonValue; limits?: FactoryLimits } +{ resumeFromRunId: string; limits?: FactoryLimits } +``` + +## Authoring a factory from inside a session + +The agent-facing `factories_manage` tool writes a factory into a session-scoped extension at runtime with `operation: "author"`. The rules above all apply, plus one constraint that does not affect an extension author. + +**The `run` body is self-contained.** It is emitted verbatim into a generated module as a single async function expression. It closes over nothing: not the conversation that authored it, and not any authoring-time binding. Only its own locals, its `ctx` parameter, and standard Node and JavaScript globals are in scope, so every schema, constant, and helper must be defined *inside* the function. The generated module imports the SDK itself; the expression cannot add static `import` statements or use `require`. Load anything else with a dynamic `await import("...")` in the body. + +```js +async ({ args, agent, phase }) => { + // Defined inside — there is no outer scope to close over. + const VERDICT = { type: "object", properties: { real: { type: "boolean" } }, required: ["real"] }; + + phase("Inspect"); + const finding = await agent(`Name one likely bug in ${args.file ?? "the code"}.`, { + label: "inspector", + }); + if (!finding) return { finding: null, real: false }; + + phase("Verify"); + const verdict = await agent(`Is this a real bug? Claim: ${finding}`, { + label: "verifier", + schema: VERDICT, + }); + return { finding, real: verdict?.real === true }; +}; +``` + +Authoring registers the factory but does not run it. Invoke it afterwards with `run_factory`. Use `factories_manage` with `operation: "list"` to see the factories already registered in the session and `operation: "inspect"` to read one factory's description, phases, and limits before running it. + +## Observe a run + +The calling session can inspect its own factory runs: + +```ts +const runs = await session.factory.listRuns(); +const detail = await session.factory.getRunDetail(runId); +const page = await session.factory.getRunProgress(runId, { + phaseId, + afterSeq, + beforeSeq, + limit, +}); +``` + +- `listRuns()` returns summaries in durable creation order. +- `getRunDetail(runId)` returns phases, prompt-safe agent summaries, and the latest progress page. +- `getRunProgress(runId, options?)` pages progress forward, backward, by phase, or from the latest tail. + +`getRun(runId)` reads the latest run envelope, and `cancel(runId)` cancels a run and returns its terminal envelope. + +`waitForRun(runId, options?)` resolves with the terminal envelope once the run settles into `completed`, `error`, `halted`, or `cancelled`, and resolves immediately when it has already settled: + +```ts +const settled = await session.factory.waitForRun(runId); +if (settled.status === "completed") { + console.log(settled.result); +} +``` + +It watches `factory.run_updated` and re-reads the durable envelope rather than polling on a timer, and it collapses a burst of invalidation events into a single in-flight read. Pass a `signal` to stop waiting: + +```ts +const controller = new AbortController(); +setTimeout(() => controller.abort(), 30_000); +const settled = await session.factory.waitForRun(runId, { signal: controller.signal }); +``` + +Aborting rejects the wait and has no effect on the run, which keeps executing — use `cancel(runId)` to actually stop it. Because a terminal envelope is final, the resolved value never changes afterwards. `isFactoryRunTerminal(status)` exposes the same terminal-status test for callers driving their own loop. + +Listen for the ephemeral `factory.run_updated` event. Its `{ runId, revision }` payload is an invalidation signal. Re-read the desired API when a newer monotonic revision arrives. + +Revisions cover durable lifecycle, accounting, phase, agent, and progress changes. Continuous read-time fields can change without a new revision. These include `observedAt`, active-time calculations, live counts, and a live agent's status or prompt-safe activity text. Factory prompts are never exposed by these APIs. A run is visible only through the session that owns it. diff --git a/nodejs/docs/factory-patterns.md b/nodejs/docs/factory-patterns.md new file mode 100644 index 0000000000..66c6d13b1f --- /dev/null +++ b/nodejs/docs/factory-patterns.md @@ -0,0 +1,194 @@ +# Agent Factory patterns + +Composable orchestration patterns built on the factory context. Read [factories.md](./factories.md) first for the API and its semantics. The API is experimental. + +Every snippet below assumes the surrounding `async (ctx) => { ... }` run body and destructures the hooks it uses. Three rules apply throughout, because breaking them fails silently: + +- **Give every independent subagent a unique `label`.** Identical prompt-and-options pairs memoize into a single shared subagent. +- **Guard every `agent()` result.** An ordinary failure resolves to `null` rather than throwing. +- **Filter with `v => v !== null`,** not `Boolean`, which also discards a valid `false`, `0`, or `""`. + +## Multi-stage review + +The default shape: fan out across dimensions, and let each dimension verify as soon as its own review lands. No barrier, so a slow dimension never holds up a fast one. + +```js +async ({ pipeline, parallel, agent, phase, log }) => { + const FINDINGS = { + type: "object", + properties: { + findings: { + type: "array", + items: { + type: "object", + properties: { title: { type: "string" } }, + required: ["title"], + }, + }, + }, + required: ["findings"], + }; + const VERDICT = { + type: "object", + properties: { isReal: { type: "boolean" } }, + required: ["isReal"], + }; + const DIMENSIONS = [ + { key: "bugs", prompt: "Review the diff for correctness bugs. Return JSON {findings:[{title}]}." }, + { key: "perf", prompt: "Review the diff for performance issues. Return JSON {findings:[{title}]}." }, + ]; + + phase("Review"); // Run-global: set it before the fan-out, never inside a stage. + const perDimension = await pipeline( + DIMENSIONS, + (d) => agent(d.prompt, { label: `review:${d.key}`, schema: FINDINGS }), + (review, d) => { + if (!review) { + log(`review:${d.key} produced nothing`); + return []; + } + return parallel( + (review.findings ?? []).map((f, i) => () => + agent(`Adversarially verify this finding is real: ${f.title}`, { + label: `verify:${d.key}:${i}`, + schema: VERDICT, + }).then((v) => (v && v.isReal ? f : null)) + ) + ); + } + ); + + return { confirmed: perDimension.flat().filter((v) => v !== null) }; +}; +``` + +## When a barrier is correct + +Deduplicating across every finding needs the whole set in hand, so the barrier earns its cost here. Dedup itself is plain JavaScript, done in the body between the two fan-outs. This excerpt reuses `FINDINGS`, `VERDICT`, and `DIMENSIONS` from the previous example — define them inside your own function. + +```js +const all = await parallel( + DIMENSIONS.map((d) => () => agent(d.prompt, { label: `find:${d.key}`, schema: FINDINGS })) +); +const findings = all.filter((v) => v !== null).flatMap((r) => r.findings ?? []); +const deduped = [...new Map(findings.map((f) => [f.title, f])).values()]; // Needs all of them. +const verified = await parallel( + deduped.map((f, i) => () => agent(`Verify: ${f.title}`, { label: `verify:${i}`, schema: VERDICT })) +); +``` + +## Loop until count + +Accumulate toward a target. Each iteration needs a unique identity — a unique label plus a prompt that excludes what has already been found — a bounded attempt count, and a null guard. + +```js +const BUG = { + type: "object", + properties: { title: { type: "string" } }, + required: ["title"], +}; + +const bugs = []; +let attempt = 0; +while (bugs.length < 10 && attempt < 30) { + const r = await agent( + `Find ONE distinct bug NOT already listed: ${JSON.stringify(bugs.map((b) => b.title))}. Return JSON {title}.`, + { label: `finder:${attempt}`, schema: BUG } + ); + attempt++; + if (r && r.title) bugs.push(r); + log(`${bugs.length}/10 found`); +} +``` + +## Loop until dry + +Keep spawning finders until some number of consecutive rounds surface nothing new. Deduplicate against everything *seen*, not just what was kept, or discarded findings resurface every round. + +```js +const BUGS = { + type: "object", + properties: { + bugs: { + type: "array", + items: { type: "object", properties: { title: { type: "string" } }, required: ["title"] }, + }, + }, + required: ["bugs"], +}; +const VERDICT = { + type: "object", + properties: { real: { type: "boolean" } }, + required: ["real"], +}; + +const seen = new Set(); +const confirmed = []; +const keyOf = (b) => b.title.toLowerCase(); +let dry = 0; +let round = 0; + +while (dry < 2 && round < 20) { + const found = ( + await parallel( + [0, 1, 2].map((i) => () => + agent(`Find bugs (finder ${i}, round ${round}). Return JSON {bugs:[{title}]}.`, { + label: `find:${round}:${i}`, + schema: BUGS, + }) + ) + ) + ) + .filter((v) => v !== null) + .flatMap((r) => r.bugs ?? []); + + const fresh = found.filter((b) => { + const k = keyOf(b); + if (seen.has(k)) return false; + seen.add(k); + return true; + }); + + if (!fresh.length) { + dry++; + round++; + continue; + } + dry = 0; + + const judged = await parallel( + fresh.map((b, i) => () => + parallel( + ["correctness", "security", "repro"].map((lens) => () => + agent(`Judge via ${lens}: is "${b.title}" real? Return JSON {real}.`, { + label: `judge:${round}:${i}:${lens}`, + schema: VERDICT, + }) + ) + ).then((vs) => ({ b, real: vs.filter((v) => v !== null).filter((v) => v.real).length >= 2 })) + ) + ); + + confirmed.push(...judged.filter((v) => v !== null && v.real).map((v) => v.b)); + round++; +} +``` + +## Quality patterns + +Compose these freely. + +- **Adversarial verify.** Spawn several independent skeptics per finding, each prompted to *refute* it and to default to refuted when uncertain. Keep only what a majority fails to refute. +- **Perspective-diverse verify.** Give each verifier a distinct lens — correctness, security, performance, does-it-reproduce — instead of several identical skeptics. The distinct prompts also stop them memoizing into one subagent. +- **Judge panel.** Generate several independent attempts from different angles, score them with parallel judges, then synthesize from the winner while grafting the best ideas from the runners-up. +- **Multi-modal sweep.** Run parallel searchers that each look a different way: by container, by content, by entity, by time. +- **Completeness critic.** End with an agent asking what is missing — an angle not run, a claim unverified, a source unread — and use its answer to seed the next round. +- **No silent caps.** When the factory bounds its own coverage with a top-N, a sampling step, or a no-retry rule, `log()` what was dropped. + +## Scaling + +Match the orchestration to what was asked. A quick check wants a couple of subagents and single-vote verification; a request to be thorough or comprehensive wants a larger finder pool, a three-to-five vote adversarial pass, and a synthesis stage. + +There is no in-script budget object. Scale with your own counters, as in the loop patterns above, and treat the declared limits as the safety ceiling rather than the control mechanism. Only `agent()` spawns are throttled, by `maxConcurrentSubagents` falling back to `maxTotalSubagents`; with neither declared there is no built-in concurrency cap, so declare one before fanning out widely. `parallel` itself is `Promise.all`, so non-agent work in a thunk runs fully concurrently regardless. + +These patterns are not exhaustive. Compose novel harnesses — tournament brackets, self-repair loops, staged escalation — when the task calls for it. diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 61f4a99416..0b054fd293 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -35,6 +35,7 @@ import { } from "./generated/rpc.js"; import type { GitHubTelemetryNotification, + JsonValue, OpenCanvasInstance, SessionUpdateOptionsParams, } from "./generated/rpc.js"; @@ -85,6 +86,7 @@ import type { TypedSessionLifecycleHandler, } from "./types.js"; import { defaultJoinSessionPermissionHandler } from "./types.js"; +import type { FactoryHandle } from "./factory.js"; /** * Minimum protocol version this SDK can communicate with. @@ -1663,6 +1665,23 @@ export class CopilotClient { * ``` */ async resumeSession(sessionId: string, config: ResumeSessionConfig): Promise { + return this.resumeSessionInternal(sessionId, config); + } + + /** @internal */ + async resumeSessionForExtension( + sessionId: string, + config: ResumeSessionConfig, + factories?: FactoryHandle[] + ): Promise { + return this.resumeSessionInternal(sessionId, config, factories); + } + + private async resumeSessionInternal( + sessionId: string, + config: ResumeSessionConfig, + factories?: FactoryHandle[] + ): Promise { if (!this.connection) { await this.start(); } @@ -1679,6 +1698,7 @@ export class CopilotClient { session.registerTools(config.tools); session.registerCanvases(config.canvases); session.registerCommands(config.commands); + session.registerFactories(factories); const { wireProvider: bearerWireProvider, wireProviders: bearerWireProviders, @@ -1750,6 +1770,7 @@ export class CopilotClient { })), toolSearch: config.toolSearch, canvases: config.canvases?.map((canvas) => canvas.declaration), + factories: factories?.map((factory) => factory.meta), requestCanvasRenderer: config.requestCanvasRenderer, requestExtensions: config.requestExtensions, extensionSdkPath: config.extensionSdkPath, @@ -2981,7 +3002,7 @@ export class CopilotClient { sessionId: string; hookType: string; input: unknown; - }): Promise<{ output?: unknown }> { + }): Promise<{ output?: JsonValue }> { if ( !params || typeof params.sessionId !== "string" || @@ -2996,7 +3017,7 @@ export class CopilotClient { } const output = await session._handleHooksInvoke(params.hookType, params.input); - return { output }; + return output === undefined ? {} : { output: JSON.parse(JSON.stringify(output)) }; } private async handleSystemMessageTransform(params: { diff --git a/nodejs/src/extension.ts b/nodejs/src/extension.ts index 72bde93bde..c3ae0fd874 100644 --- a/nodejs/src/extension.ts +++ b/nodejs/src/extension.ts @@ -6,10 +6,10 @@ import { CopilotClient } from "./client.js"; import type { CopilotSession } from "./session.js"; import { defaultJoinSessionPermissionHandler, - type ExtensionInfo, type PermissionHandler, type ResumeSessionConfig, } from "./types.js"; +import type { FactoryHandle } from "./factory.js"; export { Canvas, @@ -27,9 +27,42 @@ export type JoinSessionConfig = Omit< "onPermissionRequest" | "extensionSdkPath" > & { onPermissionRequest?: PermissionHandler; + /** + * Factory handles to register when the extension joins the session. + * + * @experimental Part of the experimental Agent Factories surface and may + * change or be removed in future SDK or CLI releases. + */ + factories?: FactoryHandle[]; }; -export type { ExtensionInfo }; +export type { ExtensionInfo, FactoryLimits, FactoryMeta } from "./types.js"; +export { + defineFactory, + FactoryResumeError, + isFactoryRunTerminal, + type RunOptions, + type ResumeOptions, + type FactoryResumeErrorCode, + type SessionFactoryApi, + type FactoryAgentOptions, + type FactoryContext, + type FactoryDefinition, + type FactoryHandle, + type FactoryJsonSchema, + type JsonValue, + type FactoryPipelineStage, + type FactoryStepOptions, + type FactoryRunResult, + type FactoryRunStatus, + type FactoryRunSummary, + type FactoryRunDetail, + type FactoryProgressPage, + type FactoryProgressLine, + type FactoryPhaseObservation, + type FactoryPhaseStatus, + type FactoryAgentSummary, +} from "./factory.js"; /** * Joins the current foreground session. @@ -58,14 +91,22 @@ export async function joinSession(config: JoinSessionConfig = {}): Promise = new Set([ + "completed", + "halted", + "cancelled", + "error", +]); + +/** + * Whether a factory run status is terminal. + * + * @experimental Part of the experimental Agent Factories surface and may + * change or be removed in future SDK or CLI releases. + */ +export function isFactoryRunTerminal(status: FactoryRunStatus): boolean { + return FACTORY_TERMINAL_STATUSES.has(status); +} + +declare const factoryHandleBrand: unique symbol; + +/** A value that can be represented losslessly on the SDK JSON wire. */ +export type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { [key: string]: JsonValue }; + +/** + * Conservative JSON shape language accepted for structured factory agent output. + * + * This is a best-effort structural guard used to decide whether a subagent's + * structured output should be accepted or retried — **not** a full JSON Schema + * validator. Only these keywords are honored: `type`, `required`, `enum`, + * `const`, recursive `properties`/`items`, and `anyOf`/`oneOf`/`allOf`. + * + * Everything else is **ignored, not enforced**. In particular, string + * constraints (`pattern`, `minLength`, `maxLength`, `format`), numeric ranges + * (`minimum`, `maximum`), `additionalProperties`, and boolean (`true`/`false`) + * schemas do not reject non-conforming output. `oneOf` is treated like `anyOf` + * (at least one branch must match) rather than strict exactly-one. Author + * schemas within this subset; do not rely on unsupported constraints for + * correctness. + * + * @experimental Part of the experimental Agent Factories surface and may + * change or be removed in future SDK or CLI releases. + */ +export type FactoryJsonSchema = { [key: string]: JsonValue }; + +/** + * Options for one factory-scoped subagent call. + * + * @experimental Part of the experimental Agent Factories surface and may + * change or be removed in future SDK or CLI releases. + */ +export interface FactoryAgentOptions { + label?: string; + schema?: FactoryJsonSchema; + model?: string; +} + +/** + * Options for a durable factory step. + * + * @experimental Part of the experimental Agent Factories surface and may + * change or be removed in future SDK or CLI releases. + */ +export interface FactoryStepOptions { + /** Skip the journal and always invoke the producer. */ + volatile?: boolean; +} + +/** + * One stage in a per-item factory pipeline. + * + * @experimental Part of the experimental Agent Factories surface and may + * change or be removed in future SDK or CLI releases. + */ +export type FactoryPipelineStage = ( + previous: TInput, + item: unknown, + index: number +) => Promise | TResult; + +/** + * Context passed to an extension-authored factory body. + * + * @experimental Part of the experimental Agent Factories surface and may + * change or be removed in future SDK or CLI releases. + */ +export interface FactoryContext { + /** Stable identifier for the current factory run. */ + readonly runId: string; + /** Spawn and await one factory-scoped subagent. */ + agent(prompt: string, options?: FactoryAgentOptions): Promise; + /** Memoize an arbitrary producer under a stable author-supplied key. */ + step( + key: string, + producer: () => Promise | JsonValue, + options?: FactoryStepOptions + ): Promise; + /** Run thunks concurrently, returning null for a thunk that throws. */ + parallel( + thunks: Array<() => Promise | TResult> + ): Promise>; + /** Run each item through every stage without barriers between stages. */ + pipeline(items: unknown[], ...stages: FactoryPipelineStage[]): Promise; + /** Start a named factory progress phase. */ + phase(title: string): void; + /** Emit a factory progress line. */ + log(message: string): void; + /** Reject because nested factories are not supported. */ + factory(name: string, args?: JsonValue): Promise; + /** Caller-supplied input, forwarded verbatim. */ + args: TArgs; + /** The same full session instance returned by `joinSession`. */ + session: CopilotSession; + /** Cooperative cancellation signal for the current factory run. */ + signal: AbortSignal; +} + +/** + * Definition accepted by {@link defineFactory}. + * + * @experimental Part of the experimental Agent Factories surface and may + * change or be removed in future SDK or CLI releases. + */ +export interface FactoryDefinition< + TArgs extends JsonValue = JsonValue, + TResult extends JsonValue | void = JsonValue | void, +> { + meta: FactoryMeta; + run(context: FactoryContext): Promise; +} + +/** + * Opaque reusable reference to a defined factory. + * + * @experimental Part of the experimental Agent Factories surface and may + * change or be removed in future SDK or CLI releases. + */ +export interface FactoryHandle< + TArgs extends JsonValue = JsonValue, + TResult extends JsonValue | void = JsonValue | void, +> { + readonly meta: FactoryMeta; + readonly [factoryHandleBrand]: { + readonly args: TArgs; + readonly result: TResult; + }; +} + +/** + * Options for invoking a factory. + * + * @experimental Part of the experimental Agent Factories surface and may + * change or be removed in future SDK or CLI releases. + */ +export interface RunOptions { + /** Input surfaced as `context.args`. */ + args?: TArgs; + /** Optional per-invocation resource ceiling overrides. */ + limits?: FactoryLimits; + /** + * Prior run whose persisted identity, arguments, journal, and accounting should be resumed. + * + * @deprecated Use {@link SessionFactoryApi.resume} instead. + */ + resumeFromRunId?: string; +} + +/** + * Options for resuming a factory run by ID. + * + * @experimental Part of the experimental Agent Factories surface and may + * change or be removed in future SDK or CLI releases. + */ +export interface ResumeOptions { + /** Optional per-invocation resource ceiling overrides. */ + limits?: FactoryLimits; +} + +/** Machine-readable pre-execution factory resume failure. */ +export type FactoryResumeErrorCode = + | "not_found" + | "non_resumable" + | "already_active" + | "reapproval_declined" + | "no_approval_provider"; + +/** + * Friendly factory API exposed on a session. + * + * @experimental Part of the experimental Agent Factories surface and may + * change or be removed in future SDK or CLI releases. + */ +export interface SessionFactoryApi { + /** + * Run a registered factory and resolve with its run envelope. + * + * The envelope is returned for every outcome, including `error`, `halted`, + * and `cancelled` — inspect `status` and read `result` only when the run + * completed. A declined fresh run resolves with a terminal `cancelled` + * envelope. Failures that occur before a run exists (such as an unknown + * factory or an already-active session) still reject. + */ + run(name: string, options?: RunOptions): Promise; + run( + factory: FactoryHandle, + options?: RunOptions + ): Promise; + /** + * Resume a run from its persisted factory name, arguments, journal, and accounting. + * + * Resolves with the run envelope like {@link SessionFactoryApi.run}. A + * pre-execution failure, including declined reapproval, rejects with + * {@link FactoryResumeError}. + */ + resume(runId: string, options?: ResumeOptions): Promise; + /** Read the latest durable envelope for a factory run. */ + getRun(runId: string): Promise; + /** + * Wait for a run to settle and resolve with its terminal envelope. + * + * Resolves as soon as the run reaches `completed`, `error`, `halted`, or + * `cancelled`, and resolves immediately when it has already settled. A + * terminal envelope is final, so the resolved value never changes + * afterwards. + * + * This watches the run's `factory.run_updated` invalidation events and + * periodically re-reads the durable envelope so a missed event cannot + * leave the wait hanging. Pass a `signal` to stop waiting; aborting rejects + * and has no effect on the run itself, which keeps executing. Use + * {@link SessionFactoryApi.cancel} to actually stop it. + */ + waitForRun(runId: string, options?: { signal?: AbortSignal }): Promise; + /** List this session's durable factory runs in creation order. */ + listRuns(): Promise; + /** Read durable phases, direct agents, and the latest progress tail for a run. */ + getRunDetail(runId: string): Promise; + /** Page durable progress forward, backward, or from the latest tail. */ + getRunProgress( + runId: string, + options?: Omit + ): Promise; + /** Cancel a factory run and return its terminal envelope. */ + cancel(runId: string): Promise; +} + +/** + * Error thrown when a factory cannot be resumed before execution begins. + * + * @experimental Part of the experimental Agent Factories surface and may + * change or be removed in future SDK or CLI releases. + */ +export class FactoryResumeError extends Error { + constructor( + public readonly code: FactoryResumeErrorCode, + message: string + ) { + super(message); + this.name = "FactoryResumeError"; + } +} + +interface StoredFactory { + meta: FactoryMeta; + run(context: FactoryContext): Promise; +} + +const factoryHandles = new WeakMap(); + +/** Maximum accepted factory timeout in seconds, derived from Node's maximum timer delay. */ +const MAX_FACTORY_TIMEOUT_SECONDS = 2_147_483.647; +const NANO_AIU_PER_AIU = 1_000_000_000; + +function deepFreeze(value: T): T { + if (value !== null && typeof value === "object" && !Object.isFrozen(value)) { + Object.freeze(value); + for (const nested of Object.values(value)) { + deepFreeze(nested); + } + } + return value; +} + +function validateLimits(meta: FactoryMeta): void { + const limits = meta.limits; + if (!limits) { + return; + } + + for (const field of ["maxConcurrentSubagents", "maxTotalSubagents"] as const) { + const value = limits[field]; + if (value !== undefined && (!Number.isInteger(value) || value <= 0)) { + throw new Error(`Factory limit "${field}" must be a positive integer`); + } + } + + if ( + limits.timeoutSeconds !== undefined && + (!Number.isFinite(limits.timeoutSeconds) || limits.timeoutSeconds <= 0) + ) { + throw new Error( + 'Factory limit "timeoutSeconds" must be a positive, finite number of seconds' + ); + } + if ( + limits.timeoutSeconds !== undefined && + limits.timeoutSeconds > MAX_FACTORY_TIMEOUT_SECONDS + ) { + throw new Error( + `Factory limit "timeoutSeconds" must not exceed ${MAX_FACTORY_TIMEOUT_SECONDS} seconds` + ); + } + + if (limits.maxAiCredits !== undefined) { + const maxNanoAiu = Math.round(limits.maxAiCredits * NANO_AIU_PER_AIU); + if ( + !Number.isFinite(limits.maxAiCredits) || + limits.maxAiCredits <= 0 || + !Number.isSafeInteger(maxNanoAiu) || + maxNanoAiu < 1 + ) { + throw new Error( + 'Factory limit "maxAiCredits" must be a positive, finite number that rounds to a safe positive integer nano-AIU ceiling' + ); + } + } +} + +function validatePhases(meta: FactoryMeta): void { + const titles = new Set(); + for (const phase of meta.phases) { + if (phase.title.trim().length === 0) { + throw new Error("Factory phase titles must not be empty"); + } + if (titles.has(phase.title)) { + throw new Error(`Factory phase title "${phase.title}" is declared more than once`); + } + titles.add(phase.title); + } +} + +/** + * Defines an extension-authored factory and returns an opaque registration handle. + * + * @experimental Part of the experimental Agent Factories surface and may + * change or be removed in future SDK or CLI releases. + */ +export function defineFactory< + TArgs extends JsonValue = JsonValue, + TResult extends JsonValue | void = JsonValue | void, +>(definition: FactoryDefinition): FactoryHandle { + // Snapshot before validating so post-registration mutation of the caller's + // object cannot slip past the authoring-boundary checks. + const meta = deepFreeze(structuredClone(definition.meta)); + validateLimits(meta); + validatePhases(meta); + + const stored: StoredFactory = { + meta, + run: definition.run, + }; + const handle = Object.freeze({ meta }) as FactoryHandle; + + factoryHandles.set(handle, stored); + return handle; +} + +/** @internal */ +export function getFactoryDefinition(handle: FactoryHandle): StoredFactory { + const definition = factoryHandles.get(handle); + if (!definition) { + throw new Error("Invalid factory handle"); + } + return definition; +} diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index db9039238e..1adc1f4419 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -7,6 +7,8 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; import type { AbortReason, Attachment, ContextTier, EmbeddedBlobResourceContents, EmbeddedTextResourceContents, McpServerSource, McpServerStatus, PermissionPromptRequest, PermissionRule, ReasoningSummary, SessionEvent, SessionLimitsConfig, SessionMode, ShutdownType, SkillSource, UserToolSessionApproval, Verbosity } from "./session-events.js"; +export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; + /** * Initial authentication info for the session. * @@ -259,6 +261,12 @@ export type AuthInfoType = | "token" /** Authentication from a Copilot API token. */ | "copilot-api-token"; + +/** @experimental */ +export type CanvasJsonSchema = JsonValue; + +/** @experimental */ +export type CanvasActionInvokeResult = JsonValue; /** * Coarse command category for grouping and behavior: runtime built-in, skill-backed command, or SDK/client-owned command * @@ -3446,7 +3454,7 @@ export interface AgentInfo { * @experimental */ mcpServers?: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }; /** * Skill names preloaded into this agent's context. Omitted means none. @@ -3833,16 +3841,6 @@ export interface CanvasAction { description?: string; inputSchema?: CanvasJsonSchema; } -/** - * JSON Schema for canvas open input - * - * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "CanvasJsonSchema". - */ -/** @experimental */ -export interface CanvasJsonSchema { - [k: string]: unknown | undefined; -} /** * Canvas action invocation parameters. * @@ -3859,22 +3857,7 @@ export interface CanvasActionInvokeRequest { * Action name to invoke */ actionName: string; - /** - * Action input - */ - input?: { - [k: string]: unknown | undefined; - }; -} -/** - * Provider-supplied action result. - * - * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "CanvasActionInvokeResult". - */ -/** @experimental */ -export interface CanvasActionInvokeResult { - [k: string]: unknown | undefined; + input?: JsonValue; } /** * Canvas close parameters. @@ -4016,12 +3999,7 @@ export interface OpenCanvasInstance { * URL for web-rendered canvases */ url?: string; - /** - * Input supplied when the instance was opened - */ - input?: { - [k: string]: unknown | undefined; - }; + input?: JsonValue; } /** * Canvas open parameters. @@ -4043,12 +4021,7 @@ export interface CanvasOpenRequest { * Caller-supplied stable instance identifier */ instanceId: string; - /** - * Canvas open input - */ - input?: { - [k: string]: unknown | undefined; - }; + input?: JsonValue; } /** * Canvas close parameters sent to the provider. @@ -4118,12 +4091,7 @@ export interface CanvasProviderInvokeActionRequest { * Action name to invoke */ actionName: string; - /** - * Action input - */ - input?: { - [k: string]: unknown | undefined; - }; + input?: JsonValue; host?: CanvasHostContext; session?: CanvasSessionContext; } @@ -4151,12 +4119,7 @@ export interface CanvasProviderOpenRequest { * Stable caller-supplied canvas instance identifier */ instanceId: string; - /** - * Canvas open input - */ - input?: { - [k: string]: unknown | undefined; - }; + input?: JsonValue; host?: CanvasHostContext; session?: CanvasSessionContext; } @@ -4474,16 +4437,7 @@ export interface ConfigureSessionExtensionsParams { * Session to attach the extension controller delegate to. */ sessionId: string; - /** - * In-process ExtensionController delegate (CLI-only optimization). Marked internal: this field is excluded from the public SDK surface. The post-SDK extension surface exposes list/enable/disable/reload via dedicated RPCs served by the runtime. - * - * @internal - * - * @internal - */ - controller?: { - [k: string]: unknown | undefined; - }; + controller?: JsonValue; } /** * Metadata for a connected remote session. @@ -4728,7 +4682,7 @@ export interface CurrentToolMetadata { * JSON Schema for tool input */ input_schema?: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }; /** * Whether the tool is loaded on demand via tool search @@ -5068,12 +5022,7 @@ export interface ExtensionContextPushInput { * Human-readable composer pill label */ title: string; - /** - * Caller-supplied JSON payload (required, may be null but not undefined) - */ - payload: { - [k: string]: unknown | undefined; - }; + payload: JsonValue; } /** * Extensions discovered for the session, with their current status. @@ -5142,7 +5091,7 @@ export interface ExternalToolTextResultForLlm { * Optional tool-specific telemetry */ toolTelemetry?: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }; /** * Base64-encoded binary results returned to the model @@ -5182,7 +5131,7 @@ export interface ExternalToolTextResultForLlmBinaryResultsForLlm { * Optional metadata from the producing tool. */ metadata?: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }; } /** @@ -5416,12 +5365,7 @@ export interface FactoryAgentOptions { * Optional label distinguishing otherwise identical memoized agent calls. */ label?: string; - /** - * Optional JSON Schema for structured agent output. - */ - schema?: { - [k: string]: unknown | undefined; - }; + schema?: JsonValue; /** * Optional model identifier for the subagent. */ @@ -5457,12 +5401,7 @@ export interface FactoryAgentRequest { */ /** @experimental */ export interface FactoryAgentResult { - /** - * Agent result, omitted when the agent produced no result. - */ - result?: { - [k: string]: unknown | undefined; - }; + result?: JsonValue; } /** * Prompt-safe durable identity and live status for a direct factory agent. @@ -5547,12 +5486,7 @@ export interface FactoryExecuteRequest { * Opaque token identifying this factory execution attempt. */ executionToken: string; - /** - * Factory input value. - */ - args: { - [k: string]: unknown | undefined; - }; + args: JsonValue; } /** * Result returned by an extension factory closure. @@ -5562,12 +5496,7 @@ export interface FactoryExecuteRequest { */ /** @experimental */ export interface FactoryExecuteResult { - /** - * Factory result value. - */ - result?: { - [k: string]: unknown | undefined; - }; + result?: JsonValue; } /** * Parameters for paging factory progress. @@ -5644,12 +5573,7 @@ export interface FactoryJournalGetResult { * Whether the journal contained the requested key. */ hit: boolean; - /** - * Cached JSON result. The hit field distinguishes a cached JSON null from a miss. - */ - resultJson?: { - [k: string]: unknown | undefined; - }; + resultJson?: JsonValue; } /** * Parameters for storing a factory journal entry. @@ -5671,12 +5595,7 @@ export interface FactoryJournalPutRequest { * Namespaced journal key. */ key: string; - /** - * JSON result to memoize. - */ - resultJson: { - [k: string]: unknown | undefined; - }; + resultJson: JsonValue; } /** * Empty parameters for listing factory runs. @@ -5924,12 +5843,7 @@ export interface FactoryRunResult { */ runId: string; status: FactoryRunStatus; - /** - * Completed factory result. - */ - result?: { - [k: string]: unknown | undefined; - }; + result?: JsonValue; /** * Error message for an errored run. */ @@ -5939,12 +5853,7 @@ export interface FactoryRunResult { * Reason for a halted or cancelled run. */ reason?: string; - /** - * Partial journal and progress snapshot for a halted, cancelled, or errored run. - */ - snapshot?: { - [k: string]: unknown | undefined; - }; + snapshot?: JsonValue; } /** * Full factory run observability detail. @@ -5989,12 +5898,7 @@ export interface FactoryRunRequest { * Registered factory name. */ name: string; - /** - * Factory input value. - */ - args: { - [k: string]: unknown | undefined; - }; + args: JsonValue; options?: RunOptions; } /** @@ -6543,7 +6447,7 @@ export interface HistoryTruncateResult { export interface HookInvokeRequest { sessionId: string; hookType: HookType; - input: unknown; + input: JsonValue; } /** * Optional output returned by an SDK callback hook. @@ -6554,7 +6458,7 @@ export interface HookInvokeRequest { /** @experimental */ /** @internal */ export interface HookInvokeResponse { - output?: unknown; + output?: JsonValue; } /** * Installed plugin record from global state, with marketplace, version, install time, enabled state, cache path, and source. @@ -7336,7 +7240,7 @@ export interface McpAppsCallToolRequest { * Tool arguments */ arguments?: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }; /** * **Required.** Server whose ui:// view issued the request. Per SEP-1865 ('callable by the app from this server only'), the call is rejected when this differs from `serverName`, and rejected outright when missing. @@ -7481,7 +7385,7 @@ export interface McpAppsListToolsResult { * App-callable tools from the server */ tools: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }[]; } /** @@ -7542,7 +7446,7 @@ export interface McpAppsResourceContent { * Resource-level metadata (CSP, permissions, etc.) */ _meta?: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }; } /** @@ -7810,14 +7714,7 @@ export interface McpConfigUpdateRequest { /** @experimental */ /** @internal */ export interface McpConfigureGitHubRequest { - /** - * Opaque runtime auth info for GitHub MCP configuration. Marked internal: an in-process runtime shape (configureGitHubMcp is a no-op over the wire). - * - * @internal - */ - authInfo: { - [k: string]: unknown | undefined; - }; + authInfo: JsonValue; } /** * Result of configuring GitHub MCP. @@ -7900,12 +7797,7 @@ export interface McpExecuteSamplingParams { * Name of the MCP server that initiated the sampling request */ serverName: string; - /** - * The original MCP JSON-RPC request ID (string or number). Used by the runtime to correlate the inference with the originating MCP request for telemetry; this is distinct from `requestId` (which is the schema-level cancellation handle). - */ - mcpRequestId: { - [k: string]: unknown | undefined; - }; + mcpRequestId: JsonValue; request: McpExecuteSamplingRequest; } /** @@ -8256,30 +8148,9 @@ export interface McpRegisterExternalClientRequest { * Logical server name for the external client */ serverName: string; - /** - * In-process MCP Client instance. Marked internal: cannot be serialized across the JSON-RPC boundary. - * - * @internal - */ - client: { - [k: string]: unknown | undefined; - }; - /** - * In-process MCP Transport instance. Marked internal: cannot be serialized across the JSON-RPC boundary. - * - * @internal - */ - transport: { - [k: string]: unknown | undefined; - }; - /** - * In-process server config (MCPServerConfig) paired with the in-process client/transport. Marked internal alongside its companions. - * - * @internal - */ - config: { - [k: string]: unknown | undefined; - }; + client: JsonValue; + transport: JsonValue; + config: JsonValue; } /** * Opaque MCP reload configuration. @@ -8290,14 +8161,7 @@ export interface McpRegisterExternalClientRequest { /** @experimental */ /** @internal */ export interface McpReloadWithConfigRequest { - /** - * Opaque runtime MCP reload configuration. Marked internal: an in-process runtime shape (reloadMcpServers throws over the wire). - * - * @internal - */ - config: { - [k: string]: unknown | undefined; - }; + config: JsonValue; } /** * Indicates whether the auto-managed `github` MCP server was removed (false when nothing to remove). @@ -8353,13 +8217,13 @@ export interface McpResource { * Resource-level metadata */ _meta?: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }; /** * Server-provided non-standard descriptor fields preserved from the MCP response */ additionalProperties?: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }; } /** @@ -8390,7 +8254,7 @@ export interface McpResourceIcon { * Server-provided non-standard icon fields preserved from the MCP response */ additionalProperties?: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }; } /** @@ -8417,7 +8281,7 @@ export interface McpResourceAnnotations { * Server-provided non-standard annotation fields preserved from the MCP response */ additionalProperties?: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }; } /** @@ -8448,7 +8312,7 @@ export interface McpResourceContent { * Resource-level metadata (CSP, permissions, etc.) */ _meta?: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }; } /** @@ -8556,13 +8420,13 @@ export interface McpResourceTemplate { * Resource-template-level metadata */ _meta?: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }; /** * Server-provided non-standard descriptor fields preserved from the MCP response */ additionalProperties?: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }; } /** @@ -9529,7 +9393,7 @@ export interface NameSetRequest { /** @experimental */ export interface OptionsUpdateAdditionalContentExclusionPolicy { rules: OptionsUpdateAdditionalContentExclusionPolicyRule[]; - last_updated_at: unknown; + last_updated_at: JsonValue; scope: OptionsUpdateAdditionalContentExclusionPolicyScope; } /** @@ -10543,7 +10407,7 @@ export interface PermissionRulesSet { /** @experimental */ export interface PermissionsConfigureAdditionalContentExclusionPolicy { rules: PermissionsConfigureAdditionalContentExclusionPolicyRule[]; - last_updated_at: unknown; + last_updated_at: JsonValue; scope: PermissionsConfigureAdditionalContentExclusionPolicyScope; } /** @@ -11354,7 +11218,7 @@ export interface ProviderAddResult { /** * Synthesized selectable model entries for the newly added BYOK models, each under its provider-qualified selection id (`provider/id`). Empty when only providers were added. */ - models: unknown[]; + models: JsonValue[]; } /** * Custom model-provider configuration (BYOK). @@ -11986,12 +11850,7 @@ export interface QueueBeginDeferredIdleDrainResult { */ /** @experimental */ export interface QueueConsumeSystemNotificationsRequest { - /** - * Opaque runtime-owned filter object. - */ - filter: { - [k: string]: unknown | undefined; - }; + filter: JsonValue; } /** * Inputs for marking session.idle deferred in native state. @@ -12393,16 +12252,7 @@ export interface RegisterExtensionToolsParams { * Session to register extension tools on. */ sessionId: string; - /** - * In-process ExtensionLoader handle (CLI-only optimization). Marked internal: this field is excluded from the public SDK surface. When the CLI migrates to a process-separated SDK, extension discovery/launch moves entirely into the runtime — the CLI passes pure config (search paths, disabled ids) via SessionOptions instead. - * - * @internal - * - * @internal - */ - loader: { - [k: string]: unknown | undefined; - }; + loader: JsonValue; options?: SessionsRegisterExtensionToolsOnSessionOptions; } /** @@ -12413,14 +12263,7 @@ export interface RegisterExtensionToolsParams { */ /** @experimental */ export interface SessionsRegisterExtensionToolsOnSessionOptions { - /** - * In-process `() => boolean` gating callback (CLI-only optimization). Marked internal: replaced by runtime-side enable/disable RPCs in the SDK migration. - * - * @internal - */ - enabled?: { - [k: string]: unknown | undefined; - }; + enabled?: JsonValue; } /** * Handle for releasing the extension tool registration. @@ -12431,16 +12274,7 @@ export interface SessionsRegisterExtensionToolsOnSessionOptions { /** @experimental */ /** @internal */ export interface RegisterExtensionToolsResult { - /** - * In-process unsubscribe function (CLI-only optimization). Marked internal: replaced by an explicit `extensions.unregister` RPC in the SDK migration. - * - * @internal - * - * @internal - */ - unsubscribe: { - [k: string]: unknown | undefined; - }; + unsubscribe: JsonValue; } /** * Opaque handle previously returned by `registerInterest` to release. @@ -12556,14 +12390,7 @@ export interface RemoteControlStatusActive { * Whether the MC session may steer this session. */ isSteerable: boolean; - /** - * In-process prompt-manager handle (CLI-only optimization). Marked internal: this field is excluded from the public SDK surface. When the CLI migrates to a process-separated SDK, the same bidirectional prompt-routing handshake is expressed via dedicated remote-control RPCs (register/resolve) rather than a shared in-process object. - * - * @internal - */ - promptManager?: { - [k: string]: unknown | undefined; - }; + promptManager?: JsonValue; /** * True while a read-only/session-sync export is deferred, awaiting the first `user.message` before its MC session exists. Marked internal: this field is excluded from the public SDK surface and is populated only on the CLI in-process path. * @@ -13368,18 +13195,8 @@ export interface SendSystemNotificationRequest { * Notification text to deliver to the model. */ message: string; - /** - * Optional structured notification kind. - */ - kind?: { - [k: string]: unknown | undefined; - }; - /** - * Internal delivery options, including passive policy. - */ - options?: { - [k: string]: unknown | undefined; - }; + kind?: JsonValue; + options?: JsonValue; } /** * Agents discovered across user, project, plugin, and remote sources. @@ -13863,7 +13680,7 @@ export interface SessionFsSqliteQueryRequest { * Optional named bind parameters */ params?: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }; } /** @@ -13878,7 +13695,7 @@ export interface SessionFsSqliteQueryResult { * For SELECT: array of row objects. For others: empty array. */ rows: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }[]; /** * Column names from the result set @@ -13936,7 +13753,7 @@ export interface SessionFsSqliteTransactionStatement { * Optional named bind parameters. */ params?: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }; } /** @@ -14303,7 +14120,7 @@ export interface SessionModelList { /** * Available models, ordered with the most preferred default first. Includes both Copilot (CAPI) models and any registry BYOK models; a BYOK model appears under its provider-qualified selection id (`provider/id`). */ - list: unknown[]; + list: JsonValue[]; /** * Cost categories for the full CAPI catalog, including picker-disabled models that Auto may select. Metadata only; entries absent from `list` are not manually selectable. */ @@ -14312,7 +14129,7 @@ export interface SessionModelList { * Per-quota snapshots returned alongside the model list, keyed by quota type. */ quotaSnapshots?: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }; } /** @@ -14368,14 +14185,7 @@ export interface SessionOpenOptions { * Stable integration identifier for analytics. */ integrationId?: string; - /** - * ExP assignment ('flight') data injected by an SDK integrator, in the same JSON shape the Copilot CLI fetches from the experimentation service (CopilotExpAssignmentResponse). When supplied this is fed into the FeatureFlagService exactly like CLI-fetched assignments and ExP-backed flags wait for it. When absent the session does not block on ExP. - * - * @internal - */ - expAssignments?: { - [k: string]: unknown | undefined; - }; + expAssignments?: JsonValue; /** * Opt-in: self-fetch and enforce enterprise managed settings at session bootstrap. */ @@ -14625,7 +14435,7 @@ export interface ShellInitScript { /** @experimental */ export interface SessionOpenOptionsAdditionalContentExclusionPolicy { rules: SessionOpenOptionsAdditionalContentExclusionPolicyRule[]; - last_updated_at: unknown; + last_updated_at: JsonValue; scope: SessionOpenOptionsAdditionalContentExclusionPolicyScope; } /** @@ -14769,14 +14579,7 @@ export interface SessionsOpenCloud { */ owner?: string; options?: SessionOpenOptions; - /** - * In-process callback invoked when the cloud task is created (before connection). Marked internal because a function reference cannot cross the JSON-RPC boundary. Disappears in the SDK migration: the field is purely cosmetic (it flips a single CLI phase label from 'creating' to 'connecting') and the wire-clean version just drops the intermediate phase. - * - * @internal - */ - onTaskCreated?: { - [k: string]: unknown | undefined; - }; + onTaskCreated?: JsonValue; } /** * Parameters for fetching a remote session and handing it off to a new local session. @@ -14793,22 +14596,8 @@ export interface SessionsOpenHandoff { metadata: RemoteSessionMetadataValue; options?: SessionOpenOptions; taskType?: SessionsOpenHandoffTaskType; - /** - * In-process progress callback `(update) => void` invoked for each handoff step. Marked internal because a function reference cannot cross the JSON-RPC boundary. The host-side `handoffSession` is already declared as `AsyncGenerator`; the schema layer flattens it because it does not yet support streaming methods. The wire-clean replacement is to expose the AsyncGenerator directly (or use vscode-jsonrpc `$/progress` notifications) once the schema/transport layer supports it. - * - * @internal - */ - onProgress?: { - [k: string]: unknown | undefined; - }; - /** - * In-process confirmation callback `(request) => boolean | Promise` invoked when the handoff needs the caller to confirm a non-fatal blocker (e.g. a repository mismatch between the current working directory and the remote session). Returning `true` proceeds with the handoff; returning `false` (or omitting the callback) aborts it. Marked internal because a function reference cannot cross the JSON-RPC boundary, for the same reasons as `onProgress`. - * - * @internal - */ - onConfirm?: { - [k: string]: unknown | undefined; - }; + onProgress?: JsonValue; + onConfirm?: JsonValue; } /** * Result of opening a session. @@ -14823,16 +14612,7 @@ export interface SessionOpenResult { * Opened session ID. Omitted when status is `not_found`. */ sessionId?: string; - /** - * In-process SessionClientApi handle for the opened session, returned to CLI callers as a transitional shortcut. Marked internal so the public SDK surface does not expose it; SDK consumers should construct per-session clients from `sessionId` instead. - * - * @internal - * - * @internal - */ - sessionApi?: { - [k: string]: unknown | undefined; - }; + sessionApi?: JsonValue; /** * Startup prompts queued by user-level hook configs at session creation. Only populated when status is `created`; resumed sessions return an empty array. */ @@ -16771,7 +16551,7 @@ export interface Tool { * JSON Schema for the tool's input parameters */ parameters?: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }; /** * Optional instructions for how to use this tool effectively @@ -17199,22 +16979,8 @@ export interface UIEphemeralQueryRequest { * Question to answer from the current conversation context. */ question: string; - /** - * In-process streaming callback `(text) => void` invoked with each token as the model emits it. Marked internal: excluded from the public SDK surface. In a process-separated SDK this is replaced by a streaming RPC that yields chunks and a final answer. - * - * @internal - */ - onChunk?: { - [k: string]: unknown | undefined; - }; - /** - * In-process `AbortSignal` forwarded to the model client to cancel an in-flight request. Marked internal: excluded from the public SDK surface. Replaced by an explicit cancellation token + cancel RPC in the SDK migration. - * - * @internal - */ - abortSignal?: { - [k: string]: unknown | undefined; - }; + onChunk?: JsonValue; + abortSignal?: JsonValue; } /** * Transient answer generated from current conversation context. @@ -17662,18 +17428,8 @@ export interface UserRequestedShellCommandResult { */ /** @experimental */ export interface UserSettingMetadata { - /** - * The effective value: the user's value if set, otherwise the default. - */ - value: { - [k: string]: unknown | undefined; - }; - /** - * The centrally-known default for this setting (null when no default is registered). - */ - default: { - [k: string]: unknown | undefined; - }; + value: JsonValue; + default: JsonValue; /** * True when the user has not set an explicit value for this setting (i.e. it is left at its default). Reflects whether the user has overridden the key, not whether the effective value happens to equal the default — a key explicitly set to a value identical to the default still reports false. */ @@ -17702,12 +17458,7 @@ export interface UserSettingsGetResult { */ /** @experimental */ export interface UserSettingsSetRequest { - /** - * Partial user settings to write, as a free-form object keyed by setting name - */ - settings: { - [k: string]: unknown | undefined; - }; + settings: JsonValue; } /** * Outcome of writing user settings. @@ -17933,12 +17684,7 @@ export interface WorkspacesDiffRequest { */ /** @experimental */ export interface WorkspacesEnsureRequest { - /** - * Opaque workspace context supplied by the session host. - */ - context?: { - [k: string]: unknown | undefined; - }; + context?: JsonValue; } /** * Current workspace metadata for the session, including its absolute filesystem path when available. @@ -18126,12 +17872,7 @@ export interface WorkspacesTruncateSummariesRequest { */ /** @experimental */ export interface WorkspacesUpdateMetadataRequest { - /** - * Opaque workspace context supplied by the session host. - */ - context?: { - [k: string]: unknown | undefined; - }; + context?: JsonValue; /** * Optional workspace display name override. */ @@ -18191,7 +17932,7 @@ export interface SessionAgentListRequest { */ /** @experimental */ export interface SessionMcpAppsCallToolResult { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; } /** @experimental */ diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index 193dc0defb..620d65c12a 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -1,3 +1,5 @@ +export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; + /** * AUTO-GENERATED FILE - DO NOT EDIT * Generated from: session-events.schema.json @@ -644,6 +646,7 @@ export type ElicitationCompletedAction = | "decline" /** The user dismissed the request. */ | "cancel"; +export type ElicitationCompletedContent = JsonValue | undefined; /** * Reason the runtime is requesting host-provided MCP OAuth credentials */ @@ -684,6 +687,7 @@ export type McpHeadersRefreshCompletedOutcome = | "none" /** No response arrived within the bounded window. */ | "timeout"; +export type CustomNotificationPayload = JsonValue; /** * The user's auto-mode-switch choice */ @@ -3170,12 +3174,7 @@ export interface AttachmentExtensionContext { * Open canvas instance identifier when the push was bound to a canvas instance */ instanceId?: string; - /** - * Caller-supplied JSON payload - */ - payload?: { - [k: string]: unknown | undefined; - }; + payload?: JsonValue; /** * Human-readable composer pill label */ @@ -3751,12 +3750,7 @@ export interface CitationReference { */ citedText?: string; location?: CitationLocation; - /** - * Provider-native citation correlation data (e.g. Anthropic search_result_index / document_index), passed through opaquely for debugging and forward compatibility. - */ - providerMetadata?: { - [k: string]: unknown | undefined; - }; + providerMetadata?: JsonValue; /** * Identifier of the CitationSource this reference points to (CitationSource.id). */ @@ -3825,20 +3819,15 @@ export interface AssistantMessageServerTools { functionCallNamespaces?: { [k: string]: string | undefined; }; - items?: unknown[]; + items?: JsonValue[]; provider: string; - rawContentBlocks?: unknown[]; + rawContentBlocks?: JsonValue[]; } /** * A tool invocation request from the assistant */ export interface AssistantMessageToolRequest { - /** - * Arguments to pass to the tool, format depends on the tool - */ - arguments?: { - [k: string]: unknown | undefined; - }; + arguments?: JsonValue; /** * Resolved intention summary describing what this specific call does */ @@ -4538,12 +4527,7 @@ export interface ToolUserRequestedEvent { * User-initiated tool invocation request with tool name and arguments */ export interface ToolUserRequestedData { - /** - * Arguments for the tool invocation - */ - arguments?: { - [k: string]: unknown | undefined; - }; + arguments?: JsonValue; /** * Unique identifier for this tool call */ @@ -4587,12 +4571,7 @@ export interface ToolExecutionStartEvent { * Tool execution startup details including MCP server information when applicable */ export interface ToolExecutionStartData { - /** - * Arguments passed to the tool - */ - arguments?: { - [k: string]: unknown | undefined; - }; + arguments?: JsonValue; /** * When true, the tool output should be displayed expanded (verbatim) in the CLI timeline */ @@ -4805,14 +4784,7 @@ export interface ToolExecutionCompleteData { * Whether this tool call was explicitly requested by the user rather than the assistant */ isUserRequested?: boolean; - /** - * FIDES IFC label projected from tool ingress metadata (MCP `CallToolResult._meta` or synthesized built-in ingress labels). Persisted as `{ ifc: ... }` so the label survives session resume, including model-visible failure results. Experimental. - * - * @experimental - */ - mcpMeta?: { - [k: string]: unknown | undefined; - }; + mcpMeta?: JsonValue; /** * Model identifier that generated this tool call */ @@ -4841,7 +4813,7 @@ export interface ToolExecutionCompleteData { * Tool-specific telemetry data (e.g., CodeQL check counts, grep match counts) */ toolTelemetry?: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }; /** * Identifier for the agent loop turn this tool was invoked in, matching the corresponding assistant.turn_start event @@ -4889,20 +4861,8 @@ export interface ToolExecutionCompleteResult { * Full detailed tool result for UI/timeline display, preserving complete content such as diffs. Falls back to content when absent. */ detailedContent?: string; - /** - * FIDES IFC label projected from tool ingress metadata (MCP `CallToolResult._meta` or synthesized built-in ingress labels) — persisted as `{ ifc: ... }` (only the `ifc` key, not the whole `_meta`). Persisted so the FIDES IFC label survives session resume: the engine rehydrates accumulated taint by replaying these on load. Populated for ingress sources when FIDES IFC is on. Experimental. - * - * @experimental - */ - mcpMeta?: { - [k: string]: unknown | undefined; - }; - /** - * Structured content (arbitrary JSON) returned verbatim by the MCP tool - */ - structuredContent?: { - [k: string]: unknown | undefined; - }; + mcpMeta?: JsonValue; + structuredContent?: JsonValue; uiResource?: ToolExecutionCompleteUIResource; } /** @@ -4921,7 +4881,7 @@ export interface PersistedBinaryImage { * Optional metadata from the producing tool. */ metadata?: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }; /** * MIME type of the binary data @@ -4946,7 +4906,7 @@ export interface OmittedBinaryResult { * Optional metadata from the producing tool. */ metadata?: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }; /** * MIME type of the omitted binary data @@ -4976,7 +4936,7 @@ export interface BinaryAssetReference { * Optional metadata from the producing tool. */ metadata?: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }; /** * MIME type of the referenced binary data @@ -5734,12 +5694,7 @@ export interface HookStartData { * Type of hook being invoked (e.g., "preToolUse", "postToolUse", "sessionStart") */ hookType: string; - /** - * Input data passed to the hook - */ - input?: { - [k: string]: unknown | undefined; - }; + input?: JsonValue; } /** * Session event "hook.end". Hook invocation completion details including output, success status, and error information @@ -5784,12 +5739,7 @@ export interface HookEndData { * Type of hook that was invoked (e.g., "preToolUse", "postToolUse", "sessionStart") */ hookType: string; - /** - * Output data produced by the hook - */ - output?: { - [k: string]: unknown | undefined; - }; + output?: JsonValue; /** * Whether the hook completed successfully */ @@ -5910,7 +5860,7 @@ export interface BinaryAssetData { * Optional metadata from the producing tool. */ metadata?: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }; /** * MIME type of the binary asset @@ -5979,7 +5929,7 @@ export interface SystemMessageMetadata { * Template variables used when constructing the prompt */ variables?: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }; } /** @@ -6161,12 +6111,7 @@ export interface SystemNotificationInstructionDiscovered { * System notification metadata from an external host that does not match a runtime-owned notification kind. */ export interface SystemNotificationUnclassified { - /** - * Opaque metadata supplied by the external host, when present. - */ - metadata?: { - [k: string]: unknown | undefined; - }; + metadata?: JsonValue; /** * Type discriminator. Always "unclassified". */ @@ -6216,12 +6161,7 @@ export interface PermissionRequestedData { * When true, this permission was already resolved by a permissionRequest hook and requires no client action */ resolvedByHook?: boolean; - /** - * Neutral risk metadata supplied by the tool host. Consumers may display this value but must not use it to bypass the permission decision. - */ - riskAssessment?: { - [k: string]: unknown | undefined; - }; + riskAssessment?: JsonValue; } /** * Shell command permission request @@ -6401,12 +6341,7 @@ export interface PermissionRequestRead { * MCP tool invocation permission request */ export interface PermissionRequestMcp { - /** - * Arguments to pass to the MCP tool - */ - args?: { - [k: string]: unknown | undefined; - }; + args?: JsonValue; /** * Permission kind discriminator */ @@ -6504,12 +6439,7 @@ export interface PermissionRequestMemory { * Custom tool invocation permission request */ export interface PermissionRequestCustomTool { - /** - * Arguments to pass to the custom tool - */ - args?: { - [k: string]: unknown | undefined; - }; + args?: JsonValue; /** * Permission kind discriminator */ @@ -6539,12 +6469,7 @@ export interface PermissionRequestHook { * Permission kind discriminator */ kind: "hook"; - /** - * Arguments of the tool call being gated - */ - toolArgs?: { - [k: string]: unknown | undefined; - }; + toolArgs?: JsonValue; /** * Tool call ID that triggered this permission request */ @@ -6733,12 +6658,7 @@ export interface PermissionPromptRequestRead { * MCP tool invocation permission prompt */ export interface PermissionPromptRequestMcp { - /** - * Arguments to pass to the MCP tool - */ - args?: { - [k: string]: unknown | undefined; - }; + args?: JsonValue; /** * Auto-approval judge information for this request; present only when auto mode is enabled. * @@ -6850,12 +6770,7 @@ export interface PermissionPromptRequestMemory { * Custom tool invocation permission prompt */ export interface PermissionPromptRequestCustomTool { - /** - * Arguments to pass to the custom tool - */ - args?: { - [k: string]: unknown | undefined; - }; + args?: JsonValue; /** * Auto-approval judge information for this request; present only when auto mode is enabled. * @@ -6921,12 +6836,7 @@ export interface PermissionPromptRequestHook { * Prompt kind discriminator */ kind: "hook"; - /** - * Arguments of the tool call being gated - */ - toolArgs?: { - [k: string]: unknown | undefined; - }; + toolArgs?: JsonValue; /** * Tool call ID that triggered this permission request */ @@ -7429,7 +7339,7 @@ export interface ElicitationRequestedSchema { * Form field definitions, keyed by field name */ properties: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }; /** * List of required field names @@ -7486,12 +7396,6 @@ export interface ElicitationCompletedData { */ requestId: string; } -/** - * Opaque JSON value submitted for one field in accepted `elicitation.completed` form content. - */ -export interface ElicitationCompletedContent { - [k: string]: unknown | undefined; -} /** * Session event "sampling.requested". Sampling request from an MCP server; contains the server name and a requestId for correlation */ @@ -7526,12 +7430,7 @@ export interface SamplingRequestedEvent { * Sampling request from an MCP server; contains the server name and a requestId for correlation */ export interface SamplingRequestedData { - /** - * The JSON-RPC request ID from the MCP protocol - */ - mcpRequestId: { - [k: string]: unknown | undefined; - }; + mcpRequestId: JsonValue; /** * Unique identifier for this sampling request; used to respond via session.respondToSampling() */ @@ -7880,12 +7779,6 @@ export interface CustomNotificationData { */ version?: number; } -/** - * Source-defined JSON payload for the custom notification - */ -export interface CustomNotificationPayload { - [k: string]: unknown | undefined; -} /** * Optional source-defined string identifiers describing the payload subject */ @@ -7926,12 +7819,7 @@ export interface ExternalToolRequestedEvent { * External tool invocation request for client-side tool execution */ export interface ExternalToolRequestedData { - /** - * Arguments to pass to the external tool - */ - arguments?: { - [k: string]: unknown | undefined; - }; + arguments?: JsonValue; /** * Unique identifier for this request; used to respond via session.respondToExternalTool() */ @@ -8477,12 +8365,7 @@ export interface ManagedSettingsResolvedData { * Whether the server (account/org) managed-settings layer was present */ serverManaged: boolean; - /** - * The effective (resolved) managed settings values, so clients can render exactly what is enforced. Absent when no managed policy is in force. - */ - settings?: { - [k: string]: unknown | undefined; - }; + settings?: JsonValue; source: ManagedSettingsResolvedSource; } /** @@ -9325,12 +9208,7 @@ export interface CanvasOpenedData { * Host-local PNG path for the canvas icon, when supplied */ icon?: string; - /** - * Input supplied when the instance was opened - */ - input?: { - [k: string]: unknown | undefined; - }; + input?: JsonValue; /** * Stable caller-supplied canvas instance identifier */ @@ -9422,12 +9300,7 @@ export interface CanvasRegistryChangedCanvas { * Host-local PNG path for the canvas icon, when supplied */ icon?: string; - /** - * JSON Schema for canvas open input - */ - inputSchema?: { - [k: string]: unknown | undefined; - }; + inputSchema?: JsonValue; } /** * A single action within a canvas declaration, with its name, optional description, and optional input schema. @@ -9438,12 +9311,7 @@ export interface CanvasRegistryChangedCanvasAction { * Action description */ description?: string; - /** - * JSON Schema for action input - */ - inputSchema?: { - [k: string]: unknown | undefined; - }; + inputSchema?: JsonValue; /** * Action name */ @@ -9591,12 +9459,7 @@ export interface CanvasRecordedData { * Owning provider identifier */ extensionId: string; - /** - * Input supplied when the instance was opened - */ - input?: { - [k: string]: unknown | undefined; - }; + input?: JsonValue; /** * Stable caller-supplied canvas instance identifier */ @@ -9732,7 +9595,7 @@ export interface McpAppToolCallCompleteData { * Arguments passed to the tool by the app view, if any */ arguments?: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }; /** * Wall-clock duration of the underlying tools/call in milliseconds @@ -9743,7 +9606,7 @@ export interface McpAppToolCallCompleteData { * Standard MCP CallToolResult returned by the server. Present whether or not the call set isError. */ result?: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }; /** * Name of the MCP server hosting the tool diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 683adb4d5b..99df267b5b 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -12,6 +12,7 @@ export { CopilotClient } from "./client.js"; export { RuntimeConnection } from "./types.js"; export { BuiltInTools, ToolSet } from "./toolSet.js"; export { CopilotSession, type AssistantMessageEvent } from "./session.js"; +export { defineFactory, FactoryResumeError, isFactoryRunTerminal } from "./factory.js"; export { Canvas, CanvasError, @@ -93,6 +94,8 @@ export type { LargeToolOutputConfig, MemoryConfiguration, UiInputOptions, + FactoryLimits, + FactoryMeta, MCPStdioServerConfig, MCPHTTPServerConfig, MCPServerConfig, @@ -168,3 +171,26 @@ export type { TypedSessionLifecycleHandler, ZodSchema, } from "./types.js"; +export type { + RunOptions, + ResumeOptions, + FactoryResumeErrorCode, + SessionFactoryApi, + FactoryAgentOptions, + FactoryContext, + FactoryDefinition, + FactoryHandle, + FactoryJsonSchema, + JsonValue, + FactoryPipelineStage, + FactoryStepOptions, + FactoryRunResult, + FactoryRunStatus, + FactoryRunSummary, + FactoryRunDetail, + FactoryProgressPage, + FactoryProgressLine, + FactoryPhaseObservation, + FactoryPhaseStatus, + FactoryAgentSummary, +} from "./factory.js"; diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 89403ade7b..e0feb2f7e1 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -15,6 +15,7 @@ import type { CanvasActionInvokeResult, CurrentToolMetadata, McpOauthPendingRequestResponse, + FactoryLogLine, } from "./generated/rpc.js"; import { type Canvas, CanvasError } from "./canvas.js"; import type { OpenCanvasInstance } from "./generated/rpc.js"; @@ -60,6 +61,29 @@ import type { UserInputRequest, UserInputResponse, } from "./types.js"; +import { + getFactoryDefinition, + FactoryResumeError, + isFactoryRunTerminal, + type FactoryResumeErrorCode, + type FactoryRunResult, + type RunOptions, + type SessionFactoryApi, + type FactoryContext, + type FactoryHandle, + type JsonValue, + type FactoryStepOptions, +} from "./factory.js"; + +function isFactoryResumeErrorCode(value: unknown): value is FactoryResumeErrorCode { + return ( + value === "not_found" || + value === "non_resumable" || + value === "already_active" || + value === "reapproval_declined" || + value === "no_approval_provider" + ); +} /** * Convert a raw hook input received over the wire into its public-facing shape. @@ -103,6 +127,223 @@ function isOpenCanvasInstance(value: unknown): value is OpenCanvasInstance { ); } +const FACTORY_LOG_FLUSH_DELAY_MS = 10; +const MAX_FACTORY_FANOUT_ITEMS = 4096; + +function assertFactoryFanoutSize(kind: "parallel" | "pipeline", size: number): void { + if (size > MAX_FACTORY_FANOUT_ITEMS) { + throw new Error( + `${kind}() accepts at most ${MAX_FACTORY_FANOUT_ITEMS} items; got ${size}.` + ); + } +} + +async function runFactoryParallel( + thunks: Array<() => Promise | TResult> +): Promise> { + if (!Array.isArray(thunks)) { + throw new Error( + "parallel() expects an array of functions, not promises. Wrap each call: () => agent(...)" + ); + } + assertFactoryFanoutSize("parallel", thunks.length); + if (thunks.some((thunk) => typeof thunk !== "function")) { + throw new Error( + "parallel() expects an array of functions, not promises. Wrap each call: () => agent(...)" + ); + } + return Promise.all( + thunks.map((thunk) => + Promise.resolve() + .then(() => thunk()) + .catch((error) => { + // Cancellation and hard runtime failures must propagate out + // of the combinator rather than be mapped to a successful + // `null`; otherwise an aborted run, or one that hit a + // resource ceiling or durable-state failure, could be + // reported as completed. An ordinary subagent failure never + // rejects — it already resolves `null`. + if (isFactoryFatalError(error)) { + throw error; + } + return null; + }) + ) + ); +} + +async function runFactoryPipeline( + items: unknown[], + ...stages: Array< + (previous: unknown, item: unknown, index: number) => Promise | unknown + > +): Promise { + if (!Array.isArray(items)) { + throw new Error("pipeline(items, ...stages): items must be an array"); + } + assertFactoryFanoutSize("pipeline", items.length); + return Promise.all( + items.map(async (item, index) => { + let previous = item; + for (const stage of stages) { + try { + previous = await stage(previous, item, index); + } catch (error) { + // Propagate cancellation and hard runtime failures instead + // of mapping them to `null`, so an aborted stage — or one + // that hit a resource ceiling or durable-state failure — + // does not let the run report success. + if (isFactoryFatalError(error)) { + throw error; + } + return null; + } + } + return previous; + }) + ); +} + +class FactoryProgressBuffer { + private nextSeq = 0; + private pending: FactoryLogLine[] = []; + private flushTimer?: ReturnType; + private flushTail: Promise = Promise.resolve(); + private flushError: unknown; + private flushFailed = false; + private closed = false; + + constructor(private readonly send: (lines: FactoryLogLine[]) => Promise) {} + + enqueue(kind: FactoryLogLine["kind"], text: string): void { + if (this.closed) { + throw new Error("Cannot log after the factory run has settled"); + } + + this.pending.push({ seq: this.nextSeq++, kind, text }); + this.scheduleFlush(); + } + + async flush(): Promise { + this.clearFlushTimer(); + const lines = this.pending.splice(0); + if (lines.length > 0) { + this.flushTail = this.flushTail.then(async () => { + try { + await this.send(lines); + } catch (error) { + if (!this.flushFailed) { + this.flushFailed = true; + this.flushError = error; + } + } + }); + } + await this.flushTail; + if (this.flushFailed) { + throw this.flushError; + } + } + + async close(): Promise { + this.closed = true; + this.clearFlushTimer(); + const lines = this.pending.splice(0); + await this.flushTail; + if (this.flushFailed) { + throw this.flushError; + } + if (lines.length > 0) { + try { + await this.send(lines); + } catch (error) { + console.warn( + "Failed to flush final factory progress after the factory body settled", + error + ); + } + } + } + + private scheduleFlush(): void { + if (this.flushTimer !== undefined) { + return; + } + this.flushTimer = setTimeout(() => { + this.flushTimer = undefined; + void this.flush().catch(() => {}); + }, FACTORY_LOG_FLUSH_DELAY_MS); + this.flushTimer.unref?.(); + } + + private clearFlushTimer(): void { + if (this.flushTimer !== undefined) { + clearTimeout(this.flushTimer); + this.flushTimer = undefined; + } + } +} + +async function awaitFactoryOperation( + operation: () => Promise, + signal: AbortSignal +): Promise { + // The operation is a thunk so an already-aborted run never dispatches the + // RPC at all, rather than sending it and rejecting locally afterwards. + throwIfFactoryAborted(signal); + let rejectAbort: ((reason?: unknown) => void) | undefined; + const onAbort = () => + rejectAbort?.(signal.reason ?? new DOMException("Factory run was aborted", "AbortError")); + try { + return await Promise.race([ + operation(), + new Promise((_resolve, reject) => { + rejectAbort = reject; + signal.addEventListener("abort", onAbort, { once: true }); + }), + ]); + } finally { + signal.removeEventListener("abort", onAbort); + } +} + +function throwIfFactoryAborted(signal: AbortSignal): void { + if (signal.aborted) { + throw signal.reason ?? new DOMException("Factory run was aborted", "AbortError"); + } +} + +/** + * Whether an error represents factory run cancellation (an `AbortError`-shaped + * rejection from {@link awaitFactoryOperation}). Cancellation must bubble out of + * `parallel`/`pipeline` rather than being flattened into a `null` result. + */ +function isFactoryAbortError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "name" in error && + (error as { name?: unknown }).name === "AbortError" + ); +} + +/** + * Errors a factory combinator must never swallow into a `null` item. + * + * Cooperative cancellation aborts the run, and a rejected RPC is a hard + * runtime failure — a reached limit, a durable-state failure, or a dropped + * transport — that must terminate the run rather than be reported as a + * successfully-`null` item. An ordinary subagent failure does not reject; the + * runtime already resolves it as `null`. + */ +function isFactoryFatalError(error: unknown): boolean { + return ( + isFactoryAbortError(error) || + error instanceof ResponseError || + error instanceof ConnectionError + ); +} + /** Assistant message event - the final response from the assistant. */ export type AssistantMessageEvent = Extract; @@ -147,6 +388,8 @@ export class CopilotSession { private canvases: Map = new Map(); private bearerTokenProviders: Map = new Map(); private commandHandlers: Map = new Map(); + private factories = new Map>(); + private factoryAbortControllers = new Map>(); private permissionHandler?: PermissionHandler; private mcpAuthHandler?: McpAuthHandler; private userInputHandler?: UserInputHandler; @@ -164,6 +407,148 @@ export class CopilotSession { /** @internal Client session API handlers, populated by CopilotClient during create/resume. */ clientSessionApis: ClientSessionApiHandlers = {}; + /** + * Friendly factory API for running registered factories by name or handle. + * + * @experimental Part of the experimental Agent Factories surface and may + * change or be removed in future SDK or CLI releases. + */ + readonly factory: SessionFactoryApi = { + run: (async ( + nameOrHandle: string | FactoryHandle, + options?: RunOptions + ): Promise => { + const name = + typeof nameOrHandle === "string" + ? nameOrHandle + : getFactoryDefinition(nameOrHandle).meta.name; + if (options?.resumeFromRunId !== undefined) { + return this.factory.resume(options.resumeFromRunId, { + limits: options.limits, + }); + } + const envelope = await this.rpc.factory.run({ + name, + args: options?.args === undefined ? {} : options.args, + options: { + limits: options?.limits, + }, + }); + + return envelope; + }) as SessionFactoryApi["run"], + resume: (async (runId: string, options?: Parameters[1]) => { + let response; + try { + response = await this.rpc.factory.resume({ + runId, + limits: options?.limits, + }); + } catch (error) { + if ( + error instanceof ResponseError && + typeof error.data === "object" && + error.data !== null + ) { + const code = (error.data as { code?: unknown }).code; + if (isFactoryResumeErrorCode(code)) { + throw new FactoryResumeError(code, error.message); + } + } + throw error; + } + return response.run; + }) as SessionFactoryApi["resume"], + getRun: (runId) => this.rpc.factory.getRun({ runId }), + waitForRun: (runId, options) => this.waitForFactoryRun(runId, options?.signal), + listRuns: async () => (await this.rpc.factory.listRuns()).runs, + getRunDetail: (runId) => this.rpc.factory.getRunDetail({ runId }), + getRunProgress: (runId, options = {}) => + this.rpc.factory.getRunProgress({ runId, ...options }), + cancel: (runId) => this.rpc.factory.cancel({ runId }), + }; + + /** + * Resolve when a factory run reaches a terminal status. + * + * The subscription is installed *before* the first read so a transition + * landing between the two cannot be missed, and re-reads are serialized so + * overlapping invalidation events cannot interleave — the run's revision + * advances once per operation, so a burst of events is common and must + * collapse into a single in-flight read. A bounded periodic re-read keeps a + * dropped invalidation from leaving the wait pending forever. + */ + private waitForFactoryRun(runId: string, signal?: AbortSignal): Promise { + const abortError = (): unknown => + signal?.reason ?? new DOMException("Factory run wait was aborted", "AbortError"); + if (signal?.aborted === true) { + return Promise.reject(abortError()); + } + + return new Promise((resolve, reject) => { + let settled = false; + let reading = false; + let rereadRequested = false; + let pollHandle: ReturnType | undefined; + let unsubscribe: (() => void) | undefined; + let onAbort: (() => void) | undefined; + + const finish = (complete: () => void): void => { + if (settled) { + return; + } + settled = true; + if (pollHandle !== undefined) { + clearInterval(pollHandle); + } + unsubscribe?.(); + if (onAbort !== undefined) { + signal?.removeEventListener("abort", onAbort); + } + complete(); + }; + + const read = async (): Promise => { + if (settled) { + return; + } + if (reading) { + rereadRequested = true; + return; + } + reading = true; + try { + do { + rereadRequested = false; + const envelope = await this.rpc.factory.getRun({ runId }); + if (isFactoryRunTerminal(envelope.status)) { + finish(() => resolve(envelope)); + return; + } + } while (rereadRequested && !settled); + } catch (error) { + finish(() => reject(error)); + } finally { + reading = false; + } + }; + + if (signal !== undefined) { + onAbort = (): void => finish(() => reject(abortError())); + signal.addEventListener("abort", onAbort, { once: true }); + } + + unsubscribe = this.on("factory.run_updated", (event) => { + if (event.data.runId === runId) { + void read(); + } + }); + + pollHandle = setInterval(() => void read(), 5_000); + void read(); + }); + } + /** * Creates a new CopilotSession instance. * @@ -367,6 +752,13 @@ export class CopilotSession { this.autoModeSwitchHandler = undefined; this.commandHandlers.clear(); this.canvases.clear(); + this.factories.clear(); + for (const controllersForRun of this.factoryAbortControllers.values()) { + for (const controller of controllersForRun.values()) { + controller.abort(); + } + } + this.factoryAbortControllers.clear(); this.transformCallbacks?.clear(); } @@ -653,7 +1045,7 @@ export class CopilotSession { } else if (typeof rawResult === "string") { result = rawResult; } else if (isToolResultObject(rawResult)) { - result = rawResult; + result = JSON.parse(JSON.stringify(rawResult)); } else { result = JSON.stringify(rawResult); } @@ -881,6 +1273,172 @@ export class CopilotSession { }; } + /** + * Registers factory closures and reverse-RPC handlers for this session. + * + * @param factories - Factory handles declared by the joining extension. + * @internal Called by the SDK when an extension joins a session. + */ + registerFactories(factories?: FactoryHandle[]): void { + this.factories.clear(); + if (!factories || factories.length === 0) { + delete this.clientSessionApis.factory; + return; + } + + for (const handle of factories) { + const definition = getFactoryDefinition(handle); + if (this.factories.has(definition.meta.name)) { + throw new Error( + `Duplicate factory name "${definition.meta.name}". Factory names must be unique within a joinSession call.` + ); + } + this.factories.set(definition.meta.name, definition); + } + + const self = this; + this.clientSessionApis.factory = { + async execute(params) { + const definition = self.factories.get(params.name); + if (!definition) { + const message = `No factory registered with name "${params.name}"`; + throw new ResponseError(ErrorCodes.InvalidParams, message, { + code: "factory_not_found", + name: params.name, + }); + } + + const controller = new AbortController(); + // Keyed by execution token as well as run ID so overlapping + // attempts for one run stay individually addressable. + let controllersForRun = self.factoryAbortControllers.get(params.runId); + if (controllersForRun === undefined) { + controllersForRun = new Map(); + self.factoryAbortControllers.set(params.runId, controllersForRun); + } + controllersForRun.set(params.executionToken, controller); + const progress = new FactoryProgressBuffer(async (lines) => { + await self.rpc.factory.log({ + runId: params.runId, + executionToken: params.executionToken, + lines, + }); + }); + try { + const context: FactoryContext = { + runId: params.runId, + args: params.args, + session: self, + signal: controller.signal, + phase: (title: string) => { + throwIfFactoryAborted(controller.signal); + progress.enqueue("phase", title); + }, + log: (message: string) => { + throwIfFactoryAborted(controller.signal); + progress.enqueue("log", message); + }, + agent: async (prompt, options = {}) => { + await progress.flush(); + const response = await awaitFactoryOperation( + () => + self.rpc.factory.agent({ + factoryRunId: params.runId, + executionToken: params.executionToken, + prompt, + opts: { + label: options.label, + schema: options.schema, + model: options.model, + }, + }), + controller.signal + ); + return response.result ?? null; + }, + step: async ( + key: string, + producer: () => Promise | JsonValue, + options: FactoryStepOptions = {} + ): Promise => { + await progress.flush(); + if (options.volatile) { + return producer(); + } + const cached = await awaitFactoryOperation( + () => + self.rpc.factory.journal.get({ + runId: params.runId, + executionToken: params.executionToken, + key, + }), + controller.signal + ); + if (cached.hit) { + if (cached.resultJson === undefined) { + throw new Error( + `step("${key}") journal returned a hit without a result` + ); + } + assertFactoryStepResult(cached.resultJson, key); + return cached.resultJson; + } + + // Producers are best-effort at-least-once across crashes or + // concurrent callers, so authors must make side effects idempotent. + const result = await producer(); + assertFactoryStepResult(result, key); + await awaitFactoryOperation( + () => + self.rpc.factory.journal.put({ + runId: params.runId, + executionToken: params.executionToken, + key, + resultJson: result, + }), + controller.signal + ); + return result; + }, + parallel: runFactoryParallel, + pipeline: runFactoryPipeline, + factory: async () => { + throw new Error("nested factories are not supported"); + }, + }; + const result = await definition.run(context); + if (result === undefined) { + return {}; + } + assertFactoryResult(result); + return { result }; + } finally { + try { + await progress.close(); + } finally { + const controllersForRun = self.factoryAbortControllers.get(params.runId); + if (controllersForRun?.get(params.executionToken) === controller) { + controllersForRun.delete(params.executionToken); + if (controllersForRun.size === 0) { + self.factoryAbortControllers.delete(params.runId); + } + } + } + } + }, + async abort(params) { + const controllersForRun = self.factoryAbortControllers.get(params.runId); + if (controllersForRun !== undefined) { + const reason = new DOMException("Factory run was aborted", "AbortError"); + for (const controller of controllersForRun.values()) { + controller.abort(reason); + } + } + return {}; + }, + }; + } + /** * Registers per-provider {@link BearerTokenProvider} callbacks for BYOK providers * configured with managed-identity / on-demand bearer-token auth. @@ -1460,3 +2018,201 @@ function toCanvasRpcError(error: unknown): ResponseError { const message = error instanceof Error ? error.message : String(error); return new ResponseError(ErrorCodes.InternalError, message, { code, message }); } + +type FactoryResultValidationCategory = + | "unsupported_type" + | "non_finite_number" + | "negative_zero" + | "cyclic_value" + | "nested_undefined" + | "unsupported_object"; + +interface StrictJsonValidationContext { + code: "factory_result_not_json" | "factory_step_not_json"; + label: string; + allowTopLevelUndefined: boolean; +} + +function strictJsonValidationError( + context: StrictJsonValidationContext, + category: FactoryResultValidationCategory, + message: string, + path: string +): ResponseError<{ code: string; category: FactoryResultValidationCategory; path: string }> { + return new ResponseError(ErrorCodes.InternalError, message, { + code: context.code, + category, + path, + }); +} + +function assertStrictJson( + value: unknown, + context: StrictJsonValidationContext +): asserts value is JsonValue | undefined { + const ancestors = new Set(); + + const visit = (current: unknown, path: string, allowUndefined: boolean): void => { + if (current === undefined) { + if (allowUndefined) { + return; + } + throw strictJsonValidationError( + context, + "nested_undefined", + `${context.label} contains nested undefined at ${path}`, + path + ); + } + if (current === null || typeof current === "boolean" || typeof current === "string") { + return; + } + if (typeof current === "number") { + if (!Number.isFinite(current)) { + throw strictJsonValidationError( + context, + "non_finite_number", + `${context.label} contains a non-finite number at ${path}`, + path + ); + } + // JSON serializes -0 as "0", so a journaled -0 would come back as 0 + // after a resume and break the lossless replay guarantee. + if (Object.is(current, -0)) { + throw strictJsonValidationError( + context, + "negative_zero", + `${context.label} contains negative zero at ${path}; normalize it to 0`, + path + ); + } + return; + } + if ( + typeof current === "function" || + typeof current === "symbol" || + typeof current === "bigint" + ) { + throw strictJsonValidationError( + context, + "unsupported_type", + `${context.label} contains a function, symbol, or BigInt at ${path}`, + path + ); + } + if (typeof current !== "object") { + throw strictJsonValidationError( + context, + "unsupported_type", + `${context.label} contains a function, symbol, or BigInt at ${path}`, + path + ); + } + if (ancestors.has(current)) { + throw strictJsonValidationError( + context, + "cyclic_value", + `${context.label} contains a cyclic reference at ${path}`, + path + ); + } + + ancestors.add(current); + try { + if (Array.isArray(current)) { + const keys = Reflect.ownKeys(current); + if ( + keys.length !== current.length + 1 || + keys.some( + (key) => + key !== "length" && + (typeof key !== "string" || + !/^(0|[1-9]\d*)$/.test(key) || + Number(key) >= current.length) + ) + ) { + throw strictJsonValidationError( + context, + "unsupported_object", + `${context.label} contains a non-JSON array property at ${path}`, + path + ); + } + for (let index = 0; index < current.length; index++) { + const descriptor = Object.getOwnPropertyDescriptor(current, String(index)); + if ( + descriptor === undefined || + !descriptor.enumerable || + !("value" in descriptor) + ) { + throw strictJsonValidationError( + context, + "unsupported_object", + `${context.label} contains a non-JSON array property at ${path}[${index}]`, + `${path}[${index}]` + ); + } + visit(descriptor.value, `${path}[${index}]`, false); + } + return; + } + + const prototype = Object.getPrototypeOf(current); + if (prototype !== Object.prototype && prototype !== null) { + throw strictJsonValidationError( + context, + "unsupported_object", + `${context.label} contains a non-JSON object at ${path}`, + path + ); + } + for (const key of Reflect.ownKeys(current)) { + if (typeof key === "symbol") { + throw strictJsonValidationError( + context, + "unsupported_type", + `${context.label} contains a function, symbol, or BigInt at ${path}`, + path + ); + } + const propertyPath = /^[A-Za-z_$][\w$]*$/.test(key) + ? `${path}.${key}` + : `${path}[${JSON.stringify(key)}]`; + const descriptor = Object.getOwnPropertyDescriptor(current, key); + if ( + descriptor === undefined || + !descriptor.enumerable || + !("value" in descriptor) + ) { + throw strictJsonValidationError( + context, + "unsupported_object", + `${context.label} contains a non-JSON property at ${propertyPath}`, + propertyPath + ); + } + visit(descriptor.value, propertyPath, false); + } + } finally { + ancestors.delete(current); + } + }; + + visit(value, "$", context.allowTopLevelUndefined); +} + +function assertFactoryResult(value: unknown): asserts value is JsonValue | undefined { + assertStrictJson(value, { + code: "factory_result_not_json", + label: "Factory result", + allowTopLevelUndefined: true, + }); +} + +function assertFactoryStepResult(value: unknown, key: string): asserts value is JsonValue { + assertStrictJson(value, { + code: "factory_step_not_json", + label: `Factory step "${key}" result`, + allowTopLevelUndefined: false, + }); +} diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 9084e7db1c..bc41487b2f 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -18,6 +18,7 @@ import type { import type { CopilotSession } from "./session.js"; import type { GitHubTelemetryNotification, + JsonValue, ModelBillingTokenPrices, OpenCanvasInstance, RemoteSessionMode, @@ -446,7 +447,7 @@ export type ToolBinaryResult = { description?: string; }; -export type ToolTelemetry = Record | undefined>; +export type ToolTelemetry = Record; export type ToolResultObject = { textResultForLlm: string; @@ -1892,6 +1893,44 @@ export interface CanvasProviderIdentity { name?: string; } +/** + * Static resource ceilings declared by a factory before it runs. + * + * @experimental Part of the experimental Agent Factories surface and may + * change or be removed in future SDK or CLI releases. + */ +export interface FactoryLimits { + /** Maximum number of factory subagents that may run concurrently. Must be positive when present. */ + maxConcurrentSubagents?: number; + /** Maximum total number of factory subagents that may be spawned. Must be positive when present. */ + maxTotalSubagents?: number; + /** Maximum AI credits consumed by factory subagents and descendants. This post-paid ceiling is soft. */ + maxAiCredits?: number; + /** + * Maximum accumulated active-execution time, in seconds. Active execution includes the entire extension body, + * subprocess waits, queued-agent waits, and sleeps. The limit is armed from the remaining headroom when a run + * resumes; time between attempts is not counted. Must be finite and positive when present. + */ + timeoutSeconds?: number; +} + +/** + * Registration metadata for an extension-authored factory. + * + * @experimental Part of the experimental Agent Factories surface and may + * change or be removed in future SDK or CLI releases. + */ +export interface FactoryMeta { + /** Stable factory name used for invocation. */ + name: string; + /** Human-readable factory description. */ + description: string; + /** Display metadata for the progress phases the factory may report. */ + phases: Array<{ title: string; detail?: string }>; + /** Optional resource ceilings presented to the user before execution. */ + limits?: FactoryLimits; +} + /** * Provider-scoped options for the Copilot API (CAPI). * diff --git a/nodejs/test/extension.test.ts b/nodejs/test/extension.test.ts index 1baa83a3ab..e94ad2204f 100644 --- a/nodejs/test/extension.test.ts +++ b/nodejs/test/extension.test.ts @@ -18,13 +18,13 @@ describe("joinSession", () => { it("defaults onPermissionRequest to no-result", async () => { process.env.SESSION_ID = "session-123"; - const resumeSession = vi - .spyOn(CopilotClient.prototype, "resumeSession") + const resumeForExtension = vi + .spyOn(CopilotClient.prototype, "resumeSessionForExtension") .mockResolvedValue({} as any); await joinSession({ tools: [] }); - const [, config] = resumeSession.mock.calls[0]!; + const [, config] = resumeForExtension.mock.calls[0]!; expect(config.onPermissionRequest).toBeDefined(); expect(config.onPermissionRequest).toBe(defaultJoinSessionPermissionHandler); const result = await Promise.resolve( @@ -36,13 +36,13 @@ describe("joinSession", () => { it("preserves an explicit onPermissionRequest handler", async () => { process.env.SESSION_ID = "session-123"; - const resumeSession = vi - .spyOn(CopilotClient.prototype, "resumeSession") + const resumeForExtension = vi + .spyOn(CopilotClient.prototype, "resumeSessionForExtension") .mockResolvedValue({} as any); await joinSession({ onPermissionRequest: approveAll, suppressResumeEvent: false }); - const [, config] = resumeSession.mock.calls[0]!; + const [, config] = resumeForExtension.mock.calls[0]!; expect(config.onPermissionRequest).toBe(approveAll); expect(config.suppressResumeEvent).toBe(false); }); diff --git a/nodejs/test/factory.test.ts b/nodejs/test/factory.test.ts new file mode 100644 index 0000000000..19e3035bbb --- /dev/null +++ b/nodejs/test/factory.test.ts @@ -0,0 +1,1899 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { readFileSync } from "node:fs"; +import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest"; +import { ResponseError } from "vscode-jsonrpc/node.js"; +import { CopilotClient } from "../src/client.js"; +import { joinSession } from "../src/extension.js"; +import { CopilotSession } from "../src/session.js"; +import { + defineFactory, + FactoryResumeError, + isFactoryRunTerminal, + type FactoryAgentOptions, + type FactoryContext, + type FactoryDefinition, + type JsonValue, +} from "../src/factory.js"; + +/** Builds a `factory.run_updated` invalidation event for a run. */ +function runUpdatedEvent(runId: string, revision: number): Record { + return { + type: "factory.run_updated", + id: `event-${runId}-${revision}`, + parentId: null, + timestamp: new Date().toISOString(), + ephemeral: true, + data: { runId, revision }, + }; +} + +async function stopClient(client: CopilotClient): Promise { + await client.stop(); +} + +describe("factories", () => { + const originalSessionId = process.env.SESSION_ID; + + afterEach(() => { + if (originalSessionId === undefined) { + delete process.env.SESSION_ID; + } else { + process.env.SESSION_ID = originalSessionId; + } + vi.restoreAllMocks(); + }); + + it("defines a stable handle and accepts omitted limits", async () => { + const meta = { + name: "no-limits", + description: "A factory without resource limits", + phases: [], + }; + const run = vi.fn(async ({ args }: { args: unknown }) => args); + const handle = defineFactory({ meta, run }); + + expect(handle.meta).toEqual(meta); + expect(handle.meta).not.toBe(meta); + expect(Object.isFrozen(handle)).toBe(true); + expect(Object.isFrozen(handle.meta)).toBe(true); + + // The handle holds a snapshot, so mutating the caller's object after + // registration cannot desynchronize the advertised metadata. + meta.name = "mutated"; + (meta.phases as string[]).push("late"); + expect(handle.meta.name).toBe("no-limits"); + expect(handle.meta.phases).toEqual([]); + meta.name = "no-limits"; + meta.phases.length = 0; + + const session = new CopilotSession("session-1", {} as never); + session.registerFactories([handle]); + const result = await session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: meta.name, + runId: "run-1", + executionToken: "execution-token", + args: { value: 42 }, + }); + + expect(run).toHaveBeenCalledOnce(); + expect(result).toEqual({ result: { value: 42 } }); + }); + + it.each([ + [[{ title: "" }], "must not be empty"], + [[{ title: "Inspect" }, { title: "Inspect" }], "declared more than once"], + ])("rejects invalid declared phase titles", (phases, message) => { + expect(() => + defineFactory({ + meta: { + name: "invalid-phases", + description: "Invalid phase metadata", + phases, + }, + run: async () => {}, + }) + ).toThrow(message); + }); + + it("returns an absent execute result for a void factory", async () => { + const factory = defineFactory({ + meta: { + name: "void-result", + description: "Returns no result", + phases: [], + }, + run: async () => {}, + }); + const session = new CopilotSession("session-void-result", {} as never); + session.registerFactories([factory]); + + await expect( + session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "void-result", + runId: "run-void-result", + executionToken: "execution-token", + args: {}, + }) + ).resolves.toEqual({}); + }); + + it.each([42, "factory-result", [1, "two", false]])( + "returns non-object JSON factory result %j", + async (factoryResult) => { + const factory = defineFactory({ + meta: { + name: "json-result", + description: "Returns any JSON value", + phases: [], + }, + run: async () => factoryResult, + }); + const session = new CopilotSession("session-json-result", {} as never); + session.registerFactories([factory]); + + await expect( + session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "json-result", + runId: "run-json-result", + executionToken: "execution-token", + args: {}, + }) + ).resolves.toEqual({ result: factoryResult }); + } + ); + + it.each([ + ["function", { nested: () => undefined }, "$.nested"], + ["symbol", [Symbol("invalid")], "$[0]"], + ["BigInt", { nested: 1n }, "$.nested"], + ])("rejects a %s anywhere in a factory result", async (_label, factoryResult, expectedPath) => { + const factory = defineFactory({ + meta: { + name: "unsupported-result", + description: "Returns an unsupported value", + phases: [], + }, + run: async () => factoryResult as never, + }); + const session = new CopilotSession("session-unsupported-result", {} as never); + session.registerFactories([factory]); + + await expect( + session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "unsupported-result", + runId: "run-unsupported-result", + executionToken: "execution-token", + args: {}, + }) + ).rejects.toMatchObject({ + message: `Factory result contains a function, symbol, or BigInt at ${expectedPath}`, + data: { + code: "factory_result_not_json", + category: "unsupported_type", + }, + }); + }); + + it.each([ + ["NaN", Number.NaN], + ["Infinity", Number.POSITIVE_INFINITY], + ])("rejects the non-finite number %s in a factory result", async (_label, value) => { + const factory = defineFactory({ + meta: { + name: "non-finite-result", + description: "Returns a non-finite number", + phases: [], + }, + run: async () => ({ value }) as never, + }); + const session = new CopilotSession("session-non-finite-result", {} as never); + session.registerFactories([factory]); + + await expect( + session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "non-finite-result", + runId: "run-non-finite-result", + executionToken: "execution-token", + args: {}, + }) + ).rejects.toMatchObject({ + message: "Factory result contains a non-finite number at $.value", + data: { + code: "factory_result_not_json", + category: "non_finite_number", + }, + }); + }); + + it("rejects a cyclic factory result", async () => { + const factoryResult: Record = {}; + factoryResult.self = factoryResult; + const factory = defineFactory({ + meta: { + name: "cyclic-result", + description: "Returns a cycle", + phases: [], + }, + run: async () => factoryResult as never, + }); + const session = new CopilotSession("session-cyclic-result", {} as never); + session.registerFactories([factory]); + + await expect( + session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "cyclic-result", + runId: "run-cyclic-result", + executionToken: "execution-token", + args: {}, + }) + ).rejects.toMatchObject({ + message: "Factory result contains a cyclic reference at $.self", + data: { + code: "factory_result_not_json", + category: "cyclic_value", + }, + }); + }); + + it.each([ + ["object", { nested: undefined }, "$.nested"], + ["array", [undefined], "$[0]"], + ])( + "rejects nested undefined in a factory result %s", + async (_label, factoryResult, expectedPath) => { + const factory = defineFactory({ + meta: { + name: "nested-undefined-result", + description: "Returns nested undefined", + phases: [], + }, + run: async () => factoryResult as never, + }); + const session = new CopilotSession("session-nested-undefined-result", {} as never); + session.registerFactories([factory]); + + await expect( + session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "nested-undefined-result", + runId: "run-nested-undefined-result", + executionToken: "execution-token", + args: {}, + }) + ).rejects.toMatchObject({ + message: `Factory result contains nested undefined at ${expectedPath}`, + data: { + code: "factory_result_not_json", + category: "nested_undefined", + }, + }); + } + ); + + it("rejects duplicate factory names within a single registration", () => { + const run = async () => null; + const first = defineFactory({ + meta: { name: "dup", description: "first", phases: [] }, + run, + }); + const second = defineFactory({ + meta: { name: "dup", description: "second", phases: [] }, + run, + }); + + const session = new CopilotSession("session-dup", {} as never); + expect(() => session.registerFactories([first, second])).toThrow( + /Duplicate factory name "dup"/ + ); + }); + + it.each([ + ["maxConcurrentSubagents", 0], + ["maxConcurrentSubagents", 1.5], + ["maxTotalSubagents", -1], + ["maxTotalSubagents", Number.POSITIVE_INFINITY], + ["timeoutSeconds", 0], + ["timeoutSeconds", Number.NaN], + ["timeoutSeconds", Number.POSITIVE_INFINITY], + ["maxAiCredits", 0], + ["maxAiCredits", Number.NaN], + ["maxAiCredits", Number.POSITIVE_INFINITY], + ["maxAiCredits", 0.000_000_000_4], + ["maxAiCredits", (Number.MAX_SAFE_INTEGER + 2) / 1_000_000_000], + ] as const)("rejects invalid %s limit %s", (field, value) => { + const definition = { + meta: { + name: `invalid-${field}-${String(value)}`, + description: "Invalid factory", + phases: [], + limits: { [field]: value }, + }, + run: async () => null, + } as FactoryDefinition; + + expect(() => defineFactory(definition)).toThrow(/must be a positive/); + }); + + it("accepts positive fractional timeoutSeconds through the Node timer ceiling", () => { + for (const timeoutSeconds of [0.001, 1.5, 2_147_483.647]) { + expect(() => + defineFactory({ + meta: { + name: `accepted-timeout-${timeoutSeconds}`, + description: "Factory with an accepted active-execution timeout", + phases: [], + limits: { timeoutSeconds }, + }, + run: async () => null, + }) + ).not.toThrow(); + } + }); + + it("accepts AI-credit ceilings that round to a positive safe nano-AIU integer", () => { + for (const maxAiCredits of [ + 0.000_000_000_5, + 1.25, + Number.MAX_SAFE_INTEGER / 1_000_000_000, + ]) { + expect(() => + defineFactory({ + meta: { + name: `accepted-credits-${maxAiCredits}`, + description: "Factory with an accepted AI-credit ceiling", + phases: [], + limits: { maxAiCredits }, + }, + run: async () => null, + }) + ).not.toThrow(); + } + }); + + it("rejects timeoutSeconds above the Node setTimeout ceiling", () => { + const definition = { + meta: { + name: "oversized-timeout", + description: "Factory with an out-of-range timeout", + phases: [], + limits: { timeoutSeconds: 2_147_483.648 }, + }, + run: async () => null, + } as FactoryDefinition; + + expect(() => defineFactory(definition)).toThrow( + 'Factory limit "timeoutSeconds" must not exceed 2147483.647 seconds' + ); + }); + + it("documents timeoutSeconds as accumulated active-execution time in public and generated types", () => { + const publicTypes = readFileSync(new URL("../src/types.ts", import.meta.url), "utf8"); + const generatedRpc = readFileSync( + new URL("../src/generated/rpc.ts", import.meta.url), + "utf8" + ); + + expect(publicTypes).toContain("Maximum accumulated active-execution time, in seconds."); + expect(publicTypes).toContain("subprocess waits, queued-agent waits, and sleeps"); + expect(publicTypes).toContain("timeoutSeconds?: number;"); + expect(generatedRpc).toContain("Maximum accumulated active-execution time in seconds."); + expect(generatedRpc).toContain("subprocess waits, queued-agent waits, and sleeps"); + expect(generatedRpc).toContain("timeoutSeconds?: number;"); + }); + + it("serializes only factory metadata in the extension resume payload", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => stopClient(client)); + + const run = vi.fn(async () => ({ ok: true })); + const factory = defineFactory({ + meta: { + name: "registered", + description: "Registration test", + phases: [{ title: "Run" }], + limits: { maxTotalSubagents: 2 }, + }, + run, + }); + const sendRequest = vi + .spyOn( + (client as never as { connection: { sendRequest: Function } }).connection, + "sendRequest" + ) + .mockImplementation(async (method: string, params: Record) => { + if (method === "session.resume") { + const sessions = (client as never as { sessions: Map }) + .sessions; + expect( + sessions.get(params.sessionId as string)?.clientSessionApis.factory + ).toBeDefined(); + return { sessionId: params.sessionId }; + } + throw new Error(`Unexpected method: ${method}`); + }); + + await client.resumeSessionForExtension( + "session-registration", + { onPermissionRequest: () => ({ kind: "approved" }) }, + [factory] + ); + + const payload = sendRequest.mock.calls.find( + ([method]) => method === "session.resume" + )![1] as { + factories: unknown[]; + }; + expect(payload.factories).toEqual([factory.meta]); + expect(payload.factories[0]).not.toHaveProperty("run"); + expect(JSON.stringify(payload.factories)).not.toContain("async"); + }); + + it("passes factories only through the extension join path", async () => { + process.env.SESSION_ID = "session-extension"; + const factory = defineFactory({ + meta: { + name: "extension-only", + description: "Extension-only registration", + phases: [], + }, + run: async () => ({ ok: true }), + }); + const resumeSessionForExtension = vi + .spyOn(CopilotClient.prototype, "resumeSessionForExtension") + .mockResolvedValue({} as CopilotSession); + + await joinSession({ factories: [factory] }); + + expect(resumeSessionForExtension).toHaveBeenCalledWith( + "session-extension", + expect.objectContaining({ suppressResumeEvent: true }), + [factory] + ); + }); + + it("builds the factory context with the unrestricted joined session identity", async () => { + process.env.SESSION_ID = "session-context"; + const sendRequest = vi.fn(async (method: string) => { + if (method === "session.factory.log") { + return {}; + } + if (method === "session.tasks.list") { + return { tasks: [] }; + } + throw new Error(`Unexpected method: ${method}`); + }); + const joinedSession = new CopilotSession("session-context", { sendRequest } as never); + const contextSeen = Promise.withResolvers<{ + runId: string; + args: unknown; + session: CopilotSession; + signal: AbortSignal; + }>(); + const factory = defineFactory({ + meta: { + name: "context", + description: "Context test", + phases: [], + }, + run: async (context) => { + contextSeen.resolve(context); + context.phase("A"); + context.log("hi"); + const tasks = await context.session.rpc.tasks.list(); + return { ok: true, taskCount: tasks.tasks.length }; + }, + }); + vi.spyOn(CopilotClient.prototype, "resumeSessionForExtension").mockImplementation( + async (_sessionId, _config, factories) => { + joinedSession.registerFactories(factories); + return joinedSession; + } + ); + + const joinSessionResult = await joinSession({ factories: [factory] }); + const executeResult = await joinSessionResult.clientSessionApis.factory!.execute({ + sessionId: joinSessionResult.sessionId, + name: "context", + runId: "run-context", + executionToken: "execution-token", + args: { value: 42 }, + }); + const context = await contextSeen.promise; + + expect(context.runId).toBe("run-context"); + expect(context.args).toEqual({ value: 42 }); + expect(context.session).toBe(joinSessionResult); + expect(context.session.rpc).toBe(joinSessionResult.rpc); + expect(context.signal).toBeInstanceOf(AbortSignal); + expect(executeResult).toEqual({ result: { ok: true, taskCount: 0 } }); + expect(sendRequest).toHaveBeenCalledWith("session.tasks.list", { + sessionId: joinSessionResult.sessionId, + }); + expect(sendRequest).toHaveBeenCalledWith("session.factory.log", { + sessionId: joinSessionResult.sessionId, + runId: "run-context", + executionToken: "execution-token", + lines: [ + { seq: 0, kind: "phase", text: "A" }, + { seq: 1, kind: "log", text: "hi" }, + ], + }); + }); + + it("rejects nested factories without forwarding a runNested request", async () => { + const sendRequest = vi.fn(async () => { + throw new Error("Unexpected forward request"); + }); + const session = new CopilotSession("session-no-nesting", { sendRequest } as never); + const factory = defineFactory({ + meta: { + name: "no-nesting", + description: "Nested factory rejection test", + phases: [], + }, + run: async (context) => context.factory("nested", { value: 42 }), + }); + session.registerFactories([factory]); + + await expect( + session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "no-nesting", + runId: "run-no-nesting", + executionToken: "execution-token", + args: {}, + }) + ).rejects.toThrow("nested factories are not supported"); + expect(sendRequest).not.toHaveBeenCalled(); + }); + + it("flushes progress incrementally while a factory body is awaiting", async () => { + const sendRequest = vi.fn(async () => ({})); + const session = new CopilotSession("session-live-progress", { sendRequest } as never); + const body = Promise.withResolvers(); + const factory = defineFactory({ + meta: { + name: "live-progress", + description: "Incremental progress test", + phases: [], + }, + run: async ({ log }) => { + log("before await"); + await body.promise; + return "done"; + }, + }); + session.registerFactories([factory]); + + const execution = session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "live-progress", + runId: "run-live-progress", + executionToken: "execution-token", + args: {}, + }); + await vi.waitFor(() => { + expect(sendRequest).toHaveBeenCalledWith("session.factory.log", { + sessionId: session.sessionId, + runId: "run-live-progress", + executionToken: "execution-token", + lines: [{ seq: 0, kind: "log", text: "before await" }], + }); + }); + + body.resolve(); + await expect(execution).resolves.toEqual({ result: "done" }); + }); + + it("calls factory.agent with the current run id and returns its text", async () => { + const sendRequest = vi.fn(async (method: string) => { + if (method === "session.factory.agent") { + return { result: "pong" }; + } + throw new Error(`Unexpected method: ${method}`); + }); + const session = new CopilotSession("session-agent", { sendRequest } as never); + const factory = defineFactory({ + meta: { + name: "agent", + description: "Agent context test", + phases: [], + }, + run: async ({ agent }) => + agent("Reply with pong", { + label: "Pong helper", + model: "gpt-test", + schema: { type: "string" }, + effort: "high", + } as FactoryAgentOptions), + }); + session.registerFactories([factory]); + + await expect( + session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "agent", + runId: "run-agent", + executionToken: "execution-token", + args: {}, + }) + ).resolves.toEqual({ result: "pong" }); + expect(sendRequest).toHaveBeenCalledWith("session.factory.agent", { + sessionId: session.sessionId, + factoryRunId: "run-agent", + executionToken: "execution-token", + prompt: "Reply with pong", + opts: { + label: "Pong helper", + model: "gpt-test", + schema: { type: "string" }, + }, + }); + }); + + it("keeps each execution token on callbacks from overlapping contexts with the same run id", async () => { + const sendRequest = vi.fn(async (method: string) => { + if (method === "session.factory.agent") { + return { result: "agent result" }; + } + if (method === "session.factory.journal.get") { + return { hit: false }; + } + return {}; + }); + const session = new CopilotSession("session-overlapping-attempts", { + sendRequest, + } as never); + const contexts: FactoryContext[] = []; + const bodies = [Promise.withResolvers(), Promise.withResolvers()]; + const contextsReady = Promise.withResolvers(); + const factory = defineFactory({ + meta: { + name: "overlapping-attempts", + description: "Execution token capture test", + phases: [], + }, + run: async (context) => { + const invocation = contexts.length; + contexts.push(context); + if (contexts.length === 2) { + contextsReady.resolve(); + } + await bodies[invocation].promise; + return `attempt ${invocation + 1}`; + }, + }); + session.registerFactories([factory]); + const first = session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "overlapping-attempts", + runId: "shared-run", + executionToken: "old-token", + args: {}, + }); + const second = session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "overlapping-attempts", + runId: "shared-run", + executionToken: "current-token", + args: {}, + }); + await contextsReady.promise; + + contexts[0].log("stale log"); + await contexts[0].agent("stale agent"); + await contexts[0].step("stale journal", () => "stale result"); + await contexts[1].agent("current agent"); + + expect(sendRequest).toHaveBeenCalledWith( + "session.factory.log", + expect.objectContaining({ executionToken: "old-token" }) + ); + expect(sendRequest).toHaveBeenCalledWith( + "session.factory.agent", + expect.objectContaining({ executionToken: "old-token", prompt: "stale agent" }) + ); + expect(sendRequest).toHaveBeenCalledWith( + "session.factory.journal.get", + expect.objectContaining({ executionToken: "old-token", key: "stale journal" }) + ); + expect(sendRequest).toHaveBeenCalledWith( + "session.factory.journal.put", + expect.objectContaining({ executionToken: "old-token", key: "stale journal" }) + ); + expect(sendRequest).toHaveBeenCalledWith( + "session.factory.agent", + expect.objectContaining({ executionToken: "current-token", prompt: "current agent" }) + ); + + bodies[0].resolve(); + bodies[1].resolve(); + await expect(first).resolves.toEqual({ result: "attempt 1" }); + await expect(second).resolves.toEqual({ result: "attempt 2" }); + }); + + it("runs a durable step once, serves cached null, and does not cache failures", async () => { + const journal = new Map(); + const sendRequest = vi.fn( + async (method: string, params: { key?: string; resultJson?: unknown }) => { + if (method === "session.factory.journal.get") { + return journal.has(params.key!) + ? { hit: true, resultJson: journal.get(params.key!) } + : { hit: false }; + } + if (method === "session.factory.journal.put") { + journal.set(params.key!, params.resultJson); + return {}; + } + throw new Error(`Unexpected method: ${method}`); + } + ); + const session = new CopilotSession("session-step", { sendRequest } as never); + let cachedProducerCalls = 0; + let failingProducerCalls = 0; + const factory = defineFactory({ + meta: { + name: "step", + description: "Durable step context test", + phases: [], + }, + run: async ({ step }) => { + const first = await step("cached-null", async () => { + cachedProducerCalls++; + return null; + }); + const second = await step("cached-null", async () => { + cachedProducerCalls++; + return "wrong"; + }); + const failed = await step("retry", async () => { + failingProducerCalls++; + throw new Error("transient"); + }).catch(() => "failed"); + const retried = await step("retry", async () => { + failingProducerCalls++; + return "recovered"; + }); + return { first, second, failed, retried }; + }, + }); + session.registerFactories([factory]); + + await expect( + session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "step", + runId: "run-step", + executionToken: "execution-token", + args: {}, + }) + ).resolves.toEqual({ + result: { first: null, second: null, failed: "failed", retried: "recovered" }, + }); + expect(cachedProducerCalls).toBe(1); + expect(failingProducerCalls).toBe(2); + expect( + sendRequest.mock.calls.filter(([method]) => method === "session.factory.journal.put") + ).toHaveLength(2); + }); + + it.each([ + ["undefined", () => undefined], + ["NaN", () => Number.NaN], + ["Infinity", () => Number.POSITIVE_INFINITY], + ["function", () => () => undefined], + ["symbol", () => Symbol("invalid")], + ["BigInt", () => 1n], + [ + "cycle", + () => { + const value: Record = {}; + value.self = value; + return value; + }, + ], + ["non-plain object", () => new Date()], + [ + "accessor property", + () => Object.defineProperty({}, "value", { enumerable: true, get: () => "hidden" }), + ], + [ + "non-enumerable property", + () => Object.defineProperty({}, "value", { enumerable: false, value: "hidden" }), + ], + ["array hole", () => new Array(1)], + [ + "array accessor", + () => Object.defineProperty([], "0", { enumerable: true, get: () => "hidden" }), + ], + ["array extra key", () => Object.assign([1], { extra: "dropped" })], + ])("rejects a journaled step %s result", async (_label, makeValue) => { + const sendRequest = vi.fn(async (method: string) => { + if (method === "session.factory.journal.get") { + return { hit: false }; + } + throw new Error(`Unexpected method: ${method}`); + }); + const session = new CopilotSession("session-invalid-step", { sendRequest } as never); + const factory = defineFactory({ + meta: { + name: "invalid-step", + description: "Rejects lossy step values", + phases: [], + }, + run: async ({ step }) => { + await step("invalid", async () => makeValue() as never); + return "must-not-complete"; + }, + }); + session.registerFactories([factory]); + + await expect( + session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "invalid-step", + runId: "run-invalid-step", + executionToken: "execution-token", + args: {}, + }) + ).rejects.toMatchObject({ + data: { + code: "factory_step_not_json", + }, + }); + expect( + sendRequest.mock.calls.filter(([method]) => method === "session.factory.journal.put") + ).toHaveLength(0); + }); + + it("validates a journaled step cache hit before replay", async () => { + const cached = Object.assign([1], { extra: "dropped" }); + const producer = vi.fn(async () => "must-not-run"); + const sendRequest = vi.fn(async (method: string) => { + if (method === "session.factory.journal.get") { + return { hit: true, resultJson: cached }; + } + throw new Error(`Unexpected method: ${method}`); + }); + const session = new CopilotSession("session-invalid-step-cache", { sendRequest } as never); + const factory = defineFactory({ + meta: { + name: "invalid-step-cache", + description: "Rejects invalid cached values", + phases: [], + }, + run: async ({ step }) => step("cached", producer), + }); + session.registerFactories([factory]); + + await expect( + session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "invalid-step-cache", + runId: "run-invalid-step-cache", + executionToken: "execution-token", + args: {}, + }) + ).rejects.toMatchObject({ + data: { + code: "factory_step_not_json", + category: "unsupported_object", + }, + }); + expect(producer).not.toHaveBeenCalled(); + }); + + it("replays a journaled step value identically on resume", async () => { + const journal = new Map(); + const sendRequest = vi.fn( + async (method: string, params: { key?: string; resultJson?: unknown }) => { + if (method === "session.factory.journal.get") { + return journal.has(params.key!) + ? { hit: true, resultJson: journal.get(params.key!) } + : { hit: false }; + } + if (method === "session.factory.journal.put") { + journal.set(params.key!, params.resultJson); + return {}; + } + throw new Error(`Unexpected method: ${method}`); + } + ); + const producer = vi.fn(async () => ({ nested: [1, null, "same"] })); + const session = new CopilotSession("session-step-replay", { sendRequest } as never); + const factory = defineFactory({ + meta: { + name: "step-replay", + description: "Replays strict JSON", + phases: [], + }, + run: async ({ step }) => step("same", producer), + }); + session.registerFactories([factory]); + + const first = await session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "step-replay", + runId: "run-step-replay", + executionToken: "execution-token", + args: {}, + }); + const replay = await session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "step-replay", + runId: "run-step-replay", + executionToken: "execution-token", + args: {}, + }); + + expect(replay).toEqual(first); + expect(producer).toHaveBeenCalledOnce(); + }); + + it("bypasses validation and journaling for a volatile step", async () => { + const sendRequest = vi.fn(); + const session = new CopilotSession("session-volatile-step", { sendRequest } as never); + const factory = defineFactory({ + meta: { + name: "volatile-step", + description: "Allows author-opted-out volatile values", + phases: [], + }, + run: async ({ step }) => { + const value = await step("volatile", async () => (() => "not JSON") as never, { + volatile: true, + }); + expect(typeof value).toBe("function"); + return "completed"; + }, + }); + session.registerFactories([factory]); + + await expect( + session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "volatile-step", + runId: "run-volatile-step", + executionToken: "execution-token", + args: {}, + }) + ).resolves.toEqual({ result: "completed" }); + expect(sendRequest).not.toHaveBeenCalled(); + }); + + it("rejects a factory result array with an extra own key", async () => { + const factory = defineFactory({ + meta: { + name: "array-extra-result", + description: "Rejects lossy array keys", + phases: [], + }, + run: async () => Object.assign([1], { extra: 1n }) as never, + }); + const session = new CopilotSession("session-array-extra-result", {} as never); + session.registerFactories([factory]); + + await expect( + session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "array-extra-result", + runId: "run-array-extra-result", + executionToken: "execution-token", + args: {}, + }) + ).rejects.toMatchObject({ + data: { + code: "factory_result_not_json", + category: "unsupported_object", + }, + }); + }); + + it("exposes factory getRun and forwards the run id", async () => { + const envelope = { runId: "run-read", status: "error", error: "failed" }; + const sendRequest = vi.fn(async () => envelope); + const session = new CopilotSession("session-read", { sendRequest } as never); + + await expect(session.factory.getRun("run-read")).resolves.toEqual(envelope); + expect(sendRequest).toHaveBeenCalledWith("session.factory.getRun", { + sessionId: session.sessionId, + runId: "run-read", + }); + }); + + it("exposes factory observability methods and forwards paging options", async () => { + const summary = { + runId: "run-observe", + factoryName: "observe", + description: "Observe", + status: "running" as const, + revision: 4, + createdAt: 1, + startedAt: 2, + updatedAt: 3, + completedAt: null, + currentPhase: { id: "p0", ordinal: 0 }, + declaredPhaseCount: 1, + liveAgentCount: 1, + totalSpawnedAgentCount: 1, + consumed: { activeMs: 10, subagents: 1, nanoAiu: 5 }, + declaredLimits: {}, + approved: {}, + observedAt: 4, + activeSegmentStartedAt: 2, + terminal: null, + }; + const progress = { + records: [], + oldestSeq: null, + newestSeq: null, + hasMoreOlder: false, + hasMoreNewer: false, + revision: 4, + }; + const detail = { ...summary, phases: [], agents: [], progress }; + const sendRequest = vi.fn(async (method: string) => { + if (method === "session.factory.listRuns") return { runs: [summary] }; + if (method === "session.factory.getRunDetail") return detail; + return progress; + }); + const session = new CopilotSession("session-observe", { sendRequest } as never); + + await expect(session.factory.listRuns()).resolves.toEqual([summary]); + await expect(session.factory.getRunDetail("run-observe")).resolves.toEqual(detail); + await expect( + session.factory.getRunProgress("run-observe", { + phaseId: "p0", + afterSeq: 10, + limit: 50, + }) + ).resolves.toEqual(progress); + expect(sendRequest).toHaveBeenNthCalledWith(1, "session.factory.listRuns", { + sessionId: session.sessionId, + }); + expect(sendRequest).toHaveBeenNthCalledWith(2, "session.factory.getRunDetail", { + sessionId: session.sessionId, + runId: "run-observe", + }); + expect(sendRequest).toHaveBeenNthCalledWith(3, "session.factory.getRunProgress", { + sessionId: session.sessionId, + runId: "run-observe", + phaseId: "p0", + afterSeq: 10, + limit: 50, + }); + }); + + it("exposes factory cancel and forwards the run id", async () => { + const envelope = { runId: "run-cancel", status: "cancelled", reason: "cancelled" }; + const sendRequest = vi.fn(async () => envelope); + const session = new CopilotSession("session-cancel", { sendRequest } as never); + + await expect(session.factory.cancel("run-cancel")).resolves.toEqual(envelope); + expect(sendRequest).toHaveBeenCalledWith("session.factory.cancel", { + sessionId: session.sessionId, + runId: "run-cancel", + }); + }); + + it("runs parallel as a barrier and maps a throwing thunk to null", async () => { + const first = Promise.withResolvers(); + const second = Promise.withResolvers(); + const started: string[] = []; + const session = new CopilotSession("session-parallel", {} as never); + const factory = defineFactory({ + meta: { + name: "parallel", + description: "Parallel combinator test", + phases: [], + }, + run: async ({ parallel }) => + parallel([ + async () => { + started.push("first"); + return first.promise; + }, + async () => { + started.push("second"); + return second.promise; + }, + async () => { + started.push("throwing"); + throw new Error("expected"); + }, + ]), + }); + session.registerFactories([factory]); + + let settled = false; + const execution = session.clientSessionApis + .factory!.execute({ + sessionId: session.sessionId, + name: "parallel", + runId: "run-parallel", + args: {}, + }) + .finally(() => { + settled = true; + }); + await vi.waitFor(() => expect(started).toEqual(["first", "second", "throwing"])); + + second.resolve("second"); + await Promise.resolve(); + expect(settled).toBe(false); + + first.resolve("first"); + await expect(execution).resolves.toEqual({ result: ["first", "second", null] }); + }); + + it("rejects already-invoked promises passed to parallel with a clear diagnostic", async () => { + const session = new CopilotSession("session-parallel-promises", {} as never); + const factory = defineFactory({ + meta: { + name: "parallel-promises", + description: "Parallel misuse diagnostic", + phases: [], + }, + run: async ({ parallel }) => + parallel([Promise.resolve("already running")] as unknown as Array< + () => Promise + >), + }); + session.registerFactories([factory]); + + await expect( + session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "parallel-promises", + runId: "run-parallel-promises", + executionToken: "execution-token", + args: {}, + }) + ).rejects.toThrow( + "parallel() expects an array of functions, not promises. Wrap each call: () => agent(...)" + ); + }); + + it("flows pipeline items independently and drops only the item whose stage throws", async () => { + const releaseFirstItem = Promise.withResolvers(); + const secondStageStarted = Promise.withResolvers(); + const finalStageItems: string[] = []; + const session = new CopilotSession("session-pipeline", {} as never); + const factory = defineFactory({ + meta: { + name: "pipeline", + description: "Pipeline combinator test", + phases: [], + }, + run: async ({ pipeline }) => + pipeline( + ["slow", "fast", "throw"], + async (_previous, item) => { + if (item === "slow") { + await releaseFirstItem.promise; + } + if (item === "throw") { + throw new Error("expected"); + } + return `${item}-stage-1`; + }, + async (previous, item) => { + if (item === "fast") { + secondStageStarted.resolve(); + } + finalStageItems.push(item as string); + return `${previous}-stage-2`; + } + ), + }); + session.registerFactories([factory]); + + const execution = session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "pipeline", + runId: "run-pipeline", + executionToken: "execution-token", + args: {}, + }); + await secondStageStarted.promise; + expect(finalStageItems).toEqual(["fast"]); + + releaseFirstItem.resolve(); + await expect(execution).resolves.toEqual({ + result: ["slow-stage-1-stage-2", "fast-stage-1-stage-2", null], + }); + expect(finalStageItems).toEqual(["fast", "slow"]); + }); + + it("enforces the 4096-item cap for parallel and pipeline", async () => { + const session = new CopilotSession("session-fanout-cap", {} as never); + const factory = defineFactory({ + meta: { + name: "fanout-cap", + description: "Fan-out cap test", + phases: [], + }, + run: async ({ parallel, pipeline }) => { + const tooManyItems = Array.from({ length: 4097 }, () => null); + const parallelError = await parallel( + tooManyItems.map(() => async () => null) + ).catch((error: unknown) => error); + const pipelineError = await pipeline(tooManyItems).catch((error: unknown) => error); + return { + parallel: (parallelError as Error).message, + pipeline: (pipelineError as Error).message, + }; + }, + }); + session.registerFactories([factory]); + + await expect( + session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "fanout-cap", + runId: "run-fanout-cap", + executionToken: "execution-token", + args: {}, + }) + ).resolves.toEqual({ + result: { + parallel: "parallel() accepts at most 4096 items; got 4097.", + pipeline: "pipeline() accepts at most 4096 items; got 4097.", + }, + }); + }); + + it("does not deadlock nested combinators when only leaf agents use a one-slot limiter", async () => { + let active = 0; + let maxActive = 0; + let tail = Promise.resolve(); + const sendRequest = vi.fn( + async (method: string, params: { prompt: string }): Promise<{ result: string }> => { + if (method !== "session.factory.agent") { + throw new Error(`Unexpected method: ${method}`); + } + const previous = tail; + const done = Promise.withResolvers(); + tail = done.promise; + await previous; + active++; + maxActive = Math.max(maxActive, active); + await Promise.resolve(); + active--; + done.resolve(); + return { result: params.prompt }; + } + ); + const session = new CopilotSession("session-nested-combinators", { + sendRequest, + } as never); + const factory = defineFactory({ + meta: { + name: "nested-combinators", + description: "Nested combinator deadlock regression", + phases: [], + }, + run: async ({ agent, parallel, pipeline }) => + parallel([ + () => parallel([() => agent("a"), () => agent("b")]), + () => pipeline(["c"], (_previous, item) => agent(item as string)), + ]), + }); + session.registerFactories([factory]); + + await expect( + session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "nested-combinators", + runId: "run-nested-combinators", + executionToken: "execution-token", + args: {}, + }) + ).resolves.toEqual({ result: [["a", "b"], ["c"]] }); + expect(maxActive).toBe(1); + expect(sendRequest).toHaveBeenCalledTimes(3); + }); + + it("flushes buffered progress in finally when the factory body throws", async () => { + const sendRequest = vi.fn(async () => ({})); + const session = new CopilotSession("session-throw-progress", { sendRequest } as never); + const factory = defineFactory({ + meta: { + name: "throw-progress", + description: "Throwing progress test", + phases: [], + }, + run: async ({ log }) => { + log("before throw"); + throw new Error("body failed"); + }, + }); + session.registerFactories([factory]); + + await expect( + session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "throw-progress", + runId: "run-throw-progress", + executionToken: "execution-token", + args: {}, + }) + ).rejects.toThrow("body failed"); + expect(sendRequest).toHaveBeenCalledWith("session.factory.log", { + sessionId: session.sessionId, + runId: "run-throw-progress", + executionToken: "execution-token", + lines: [{ seq: 0, kind: "log", text: "before throw" }], + }); + }); + + it("keeps a completed execution successful when only the final progress flush fails", async () => { + const sendRequest = vi.fn(async (method: string) => { + if (method === "session.factory.log") { + throw new Error("final transport failure"); + } + return {}; + }); + const warning = vi.spyOn(console, "warn").mockImplementation(() => {}); + const session = new CopilotSession("session-final-flush-failure", { + sendRequest, + } as never); + const factory = defineFactory({ + meta: { + name: "final-flush-failure", + description: "Final flush failure regression test", + phases: [], + }, + run: async ({ log }) => { + log("final line"); + return "done"; + }, + }); + session.registerFactories([factory]); + + await expect( + session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "final-flush-failure", + runId: "run-final-flush-failure", + executionToken: "execution-token", + args: {}, + }) + ).resolves.toEqual({ result: "done" }); + expect(warning).toHaveBeenCalledWith( + "Failed to flush final factory progress after the factory body settled", + expect.objectContaining({ message: "final transport failure" }) + ); + }); + + it("keeps a mid-run progress flush failure fatal", async () => { + const sendRequest = vi.fn(async (method: string) => { + if (method === "session.factory.log") { + throw new Error("mid-run transport failure"); + } + if (method === "session.factory.agent") { + return { result: "must not complete" }; + } + return {}; + }); + const session = new CopilotSession("session-mid-run-flush-failure", { + sendRequest, + } as never); + const factory = defineFactory({ + meta: { + name: "mid-run-flush-failure", + description: "Mid-run flush failure regression test", + phases: [], + }, + run: async ({ agent, log }) => { + log("before agent"); + return agent("trigger a flush"); + }, + }); + session.registerFactories([factory]); + + await expect( + session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "mid-run-flush-failure", + runId: "run-mid-run-flush-failure", + executionToken: "execution-token", + args: {}, + }) + ).rejects.toThrow("mid-run transport failure"); + expect(sendRequest).not.toHaveBeenCalledWith("session.factory.agent", expect.anything()); + }); + + it("surfaces the per-run abort signal on the factory context", async () => { + const session = new CopilotSession("session-abort-signal", {} as never); + const signalSeen = Promise.withResolvers(); + const factory = defineFactory({ + meta: { + name: "abort-signal", + description: "Abort signal test", + phases: [], + }, + run: async ({ signal }) => { + signalSeen.resolve(signal); + await new Promise((resolve) => + signal.addEventListener("abort", () => resolve(), { once: true }) + ); + return signal.aborted; + }, + }); + session.registerFactories([factory]); + + const execution = session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "abort-signal", + runId: "run-abort-signal", + executionToken: "execution-token", + args: {}, + }); + const signal = await signalSeen.promise; + expect(signal.aborted).toBe(false); + + await session.clientSessionApis.factory!.abort({ + sessionId: session.sessionId, + runId: "run-abort-signal", + }); + + expect(signal.aborted).toBe(true); + await expect(execution).resolves.toEqual({ result: true }); + }); + + it("rejects an in-flight runtime-backed await when factory.abort trips the signal", async () => { + const agentResponse = Promise.withResolvers<{ result: string }>(); + const sendRequest = vi.fn(async (method: string) => { + if (method === "session.factory.agent") { + return agentResponse.promise; + } + return {}; + }); + const session = new CopilotSession("session-abort-await", { sendRequest } as never); + const factory = defineFactory({ + meta: { + name: "abort-await", + description: "Abort an in-flight factory await", + phases: [], + }, + run: async ({ agent }) => agent("wait forever"), + }); + session.registerFactories([factory]); + + const execution = session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "abort-await", + runId: "run-abort-await", + executionToken: "execution-token", + args: {}, + }); + await vi.waitFor(() => + expect(sendRequest).toHaveBeenCalledWith("session.factory.agent", expect.anything()) + ); + + await session.clientSessionApis.factory!.abort({ + sessionId: session.sessionId, + runId: "run-abort-await", + }); + + await expect(execution).rejects.toMatchObject({ name: "AbortError" }); + agentResponse.resolve({ result: "late" }); + }); + + it.each(["parallel", "pipeline"] as const)( + "propagates cancellation out of %s instead of mapping it to null", + async (combinator) => { + const agentResponse = Promise.withResolvers<{ result: string }>(); + const sendRequest = vi.fn(async (method: string) => { + if (method === "session.factory.agent") { + return agentResponse.promise; + } + return {}; + }); + const session = new CopilotSession("session-abort-parallel", { sendRequest } as never); + const factory = defineFactory({ + meta: { + name: `abort-${combinator}`, + description: "Cancellation must bubble out of a combinator", + phases: [], + }, + // If the combinator swallowed the AbortError to null, this run would + // resolve successfully with [null] despite the run being cancelled. + run: async ({ agent, parallel, pipeline }) => + combinator === "parallel" + ? parallel([() => agent("wait forever")]) + : pipeline(["wait forever"], (_previous, item) => agent(item as string)), + }); + session.registerFactories([factory]); + + const execution = session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: `abort-${combinator}`, + runId: `run-abort-${combinator}`, + executionToken: "execution-token", + args: {}, + }); + await vi.waitFor(() => + expect(sendRequest).toHaveBeenCalledWith("session.factory.agent", expect.anything()) + ); + + await session.clientSessionApis.factory!.abort({ + sessionId: session.sessionId, + runId: `run-abort-${combinator}`, + }); + + await expect(execution).rejects.toMatchObject({ name: "AbortError" }); + agentResponse.resolve({ result: "late" }); + } + ); + + it("dispatches factory.execute to the registered factory selected by name", async () => { + const firstRun = vi.fn(async () => ({ selected: "first" })); + const secondRun = vi.fn(async ({ args, log }) => { + log("executing"); + return { selected: "second", echoed: args }; + }); + const firstFactory = defineFactory({ + meta: { + name: "first", + description: "First factory", + phases: [], + }, + run: firstRun, + }); + const secondFactory = defineFactory({ + meta: { + name: "second", + description: "Second factory", + phases: [], + }, + run: secondRun, + }); + const session = new CopilotSession("session-execute", { + sendRequest: vi.fn(async () => ({})), + } as never); + session.registerFactories([firstFactory, secondFactory]); + + await expect( + session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "second", + runId: "run-echo", + executionToken: "execution-token", + args: { message: "hello" }, + }) + ).resolves.toEqual({ + result: { selected: "second", echoed: { message: "hello" } }, + }); + expect(firstRun).not.toHaveBeenCalled(); + expect(secondRun).toHaveBeenCalledOnce(); + + const error = await session.clientSessionApis + .factory!.execute({ + sessionId: session.sessionId, + name: "missing", + runId: "run-missing", + args: {}, + }) + .catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(ResponseError); + expect((error as ResponseError<{ code: string; name: string }>).data).toEqual({ + code: "factory_not_found", + name: "missing", + }); + }); + + it("runs fresh factories and routes direct and legacy resumes by ID without args", async () => { + const factory = defineFactory({ + meta: { + name: "friendly-run", + description: "Friendly run wrapper", + phases: [], + }, + run: async () => ({ unused: true }), + }); + const sendRequest = vi.fn(async (method: string, params: { name?: string }) => + method === "session.factory.resume" + ? { + factoryName: "stored-name", + run: { + runId: "run-prior", + status: "completed", + result: { name: "stored-name", persistedArgs: true }, + }, + } + : { + runId: "run-foreground", + status: "completed", + result: { name: params.name }, + } + ); + const session = new CopilotSession("session-run", { sendRequest } as never); + + await expect( + session.factory.resume("run-prior", { + limits: { maxTotalSubagents: 7 }, + }) + ).resolves.toMatchObject({ + status: "completed", + result: { name: "stored-name", persistedArgs: true }, + }); + await expect( + session.factory.run("by-name", { + args: { value: 1 }, + limits: { maxTotalSubagents: 7 }, + resumeFromRunId: "run-prior", + }) + ).resolves.toMatchObject({ + status: "completed", + result: { name: "stored-name", persistedArgs: true }, + }); + await expect(session.factory.run(factory)).resolves.toMatchObject({ + status: "completed", + result: { name: "friendly-run" }, + }); + expect(sendRequest).toHaveBeenNthCalledWith(1, "session.factory.resume", { + sessionId: session.sessionId, + runId: "run-prior", + limits: { maxTotalSubagents: 7 }, + }); + expect(sendRequest).toHaveBeenNthCalledWith(2, "session.factory.resume", { + sessionId: session.sessionId, + runId: "run-prior", + limits: { maxTotalSubagents: 7 }, + }); + expect(sendRequest).toHaveBeenNthCalledWith(3, "session.factory.run", { + sessionId: session.sessionId, + name: "friendly-run", + args: {}, + options: { limits: undefined }, + }); + }); + + it("returns the full envelope for a failed foreground run", async () => { + const envelope = { + runId: "run-error", + status: "error" as const, + error: "factory failed", + snapshot: { completed: 1 }, + }; + const session = new CopilotSession("session-error", { + sendRequest: vi.fn(async () => envelope), + } as never); + + // A run that exists resolves with its envelope; only pre-execution + // failures (no run id) reject. + await expect(session.factory.run("failing")).resolves.toEqual(envelope); + }); + + it.each([ + "not_found", + "non_resumable", + "already_active", + "reapproval_declined", + "no_approval_provider", + ] as const)( + "throws FactoryResumeError with code %s for pre-execution failures", + async (code) => { + const session = new CopilotSession("session-resume-error", { + sendRequest: vi.fn(async () => { + throw new ResponseError(-32602, `resume failed: ${code}`, { code }); + }), + } as never); + + const error = await session.factory + .resume("run-error") + .catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(FactoryResumeError); + expect((error as FactoryResumeError).code).toBe(code); + } + ); + + it("returns resumed execution failures as envelopes", async () => { + const envelope = { + runId: "run-execution-error", + status: "error" as const, + error: "resumed body failed", + }; + const session = new CopilotSession("session-resumed-run-error", { + sendRequest: vi.fn(async () => ({ factoryName: "stored-name", run: envelope })), + } as never); + + await expect(session.factory.resume("run-execution-error")).resolves.toEqual(envelope); + }); +}); + +describe("factory run settlement", () => { + it.each([ + ["completed", true], + ["error", true], + ["halted", true], + ["cancelled", true], + ["pending", false], + ["running", false], + ] as const)("classifies %s as terminal=%s", (status, expected) => { + expect(isFactoryRunTerminal(status)).toBe(expected); + }); + + it("resolves immediately when the run has already settled", async () => { + const envelope = { runId: "run-settled", status: "completed" as const, result: 42 }; + const sendRequest = vi.fn(async () => envelope); + const session = new CopilotSession("session-wait-settled", { sendRequest } as never); + + await expect(session.factory.waitForRun("run-settled")).resolves.toEqual(envelope); + expect(sendRequest).toHaveBeenCalledTimes(1); + expect(sendRequest).toHaveBeenCalledWith("session.factory.getRun", { + sessionId: session.sessionId, + runId: "run-settled", + }); + }); + + it("waits for a running run to reach a terminal status", async () => { + const running = { runId: "run-wait", status: "running" as const }; + const terminal = { runId: "run-wait", status: "completed" as const, result: "done" }; + let current: unknown = running; + const sendRequest = vi.fn(async () => current); + const session = new CopilotSession("session-wait-running", { sendRequest } as never); + + const settled = session.factory.waitForRun("run-wait"); + // The first read observed a running envelope, so the wait is still pending. + await vi.waitFor(() => expect(sendRequest).toHaveBeenCalledTimes(1)); + + // An invalidation event for an unrelated run must not trigger a re-read. + (session as never as { _dispatchEvent(event: unknown): void })._dispatchEvent( + runUpdatedEvent("some-other-run", 2) + ); + expect(sendRequest).toHaveBeenCalledTimes(1); + + current = terminal; + (session as never as { _dispatchEvent(event: unknown): void })._dispatchEvent( + runUpdatedEvent("run-wait", 3) + ); + + await expect(settled).resolves.toEqual(terminal); + }); + + it("periodically re-reads when a terminal invalidation is missed", async () => { + vi.useFakeTimers(); + const running = { runId: "run-poll", status: "running" as const }; + const terminal = { runId: "run-poll", status: "completed" as const, result: "polled" }; + let current: unknown = running; + const sendRequest = vi.fn(async () => current); + const session = new CopilotSession("session-wait-poll", { sendRequest } as never); + + try { + const settled = session.factory.waitForRun("run-poll"); + await vi.waitFor(() => expect(sendRequest).toHaveBeenCalledTimes(1)); + + current = terminal; + await vi.advanceTimersByTimeAsync(5_000); + + await expect(settled).resolves.toEqual(terminal); + expect(sendRequest).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it("stops watching once the run settles", async () => { + const running = { runId: "run-unsub", status: "running" as const }; + const terminal = { runId: "run-unsub", status: "error" as const, error: "body failed" }; + let current: unknown = running; + const sendRequest = vi.fn(async () => current); + const session = new CopilotSession("session-wait-unsub", { sendRequest } as never); + const handlersFor = (): Set | undefined => + ( + session as never as { + typedEventHandlers: Map>; + } + ).typedEventHandlers.get("factory.run_updated"); + + const settled = session.factory.waitForRun("run-unsub"); + await vi.waitFor(() => expect(sendRequest).toHaveBeenCalledTimes(1)); + expect(handlersFor()?.size ?? 0).toBe(1); + + current = terminal; + (session as never as { _dispatchEvent(event: unknown): void })._dispatchEvent( + runUpdatedEvent("run-unsub", 2) + ); + await expect(settled).resolves.toEqual(terminal); + + // The subscription must be released, or every completed wait leaks a + // listener for the lifetime of the session. + expect(handlersFor()?.size ?? 0).toBe(0); + + const callsAtSettlement = sendRequest.mock.calls.length; + // A late event for a settled run must not provoke another read. + (session as never as { _dispatchEvent(event: unknown): void })._dispatchEvent( + runUpdatedEvent("run-unsub", 3) + ); + expect(sendRequest).toHaveBeenCalledTimes(callsAtSettlement); + }); + + it("rejects when the signal is already aborted and never reads", async () => { + const sendRequest = vi.fn(async () => ({ runId: "run-pre", status: "running" })); + const session = new CopilotSession("session-wait-pre-abort", { sendRequest } as never); + + await expect( + session.factory.waitForRun("run-pre", { signal: AbortSignal.abort() }) + ).rejects.toThrow(); + expect(sendRequest).not.toHaveBeenCalled(); + }); + + it("rejects when aborted while waiting, leaving the run untouched", async () => { + const sendRequest = vi.fn(async () => ({ runId: "run-abort", status: "running" })); + const session = new CopilotSession("session-wait-abort", { sendRequest } as never); + const controller = new AbortController(); + + const settled = session.factory.waitForRun("run-abort", { signal: controller.signal }); + await vi.waitFor(() => expect(sendRequest).toHaveBeenCalledTimes(1)); + + controller.abort(); + await expect(settled).rejects.toThrow(); + // Aborting the wait must not cancel the run. + expect(sendRequest).not.toHaveBeenCalledWith("session.factory.cancel", expect.anything()); + }); + + it("propagates a read failure", async () => { + const sendRequest = vi.fn(async () => { + throw new Error("factory_storage_unavailable"); + }); + const session = new CopilotSession("session-wait-error", { sendRequest } as never); + + await expect(session.factory.waitForRun("run-broken")).rejects.toThrow( + "factory_storage_unavailable" + ); + }); + + it("collapses a burst of invalidation events into one in-flight read", async () => { + const running = { runId: "run-burst", status: "running" as const }; + const terminal = { runId: "run-burst", status: "completed" as const }; + let release: (() => void) | undefined; + const gate = new Promise((resolve) => (release = resolve)); + let readCount = 0; + const sendRequest = vi.fn(async () => { + readCount += 1; + if (readCount === 2) { + await gate; + } + // Reads 1 and 2 observe a running run; only the coalesced third + // read observes the terminal one. + return readCount >= 3 ? terminal : running; + }); + const session = new CopilotSession("session-wait-burst", { sendRequest } as never); + + const settled = session.factory.waitForRun("run-burst"); + await vi.waitFor(() => expect(sendRequest).toHaveBeenCalledTimes(1)); + + const dispatch = (revision: number): void => + (session as never as { _dispatchEvent(event: unknown): void })._dispatchEvent( + runUpdatedEvent("run-burst", revision) + ); + + // Second read is held open while three more events arrive; they must + // collapse into a single follow-up read rather than three. + dispatch(2); + await vi.waitFor(() => expect(sendRequest).toHaveBeenCalledTimes(2)); + dispatch(3); + dispatch(4); + dispatch(5); + expect(sendRequest).toHaveBeenCalledTimes(2); + + release?.(); + await expect(settled).resolves.toEqual(terminal); + // One initial read, the held read, and exactly one coalesced re-read + // standing in for all three queued events. + expect(sendRequest).toHaveBeenCalledTimes(3); + }); +}); diff --git a/nodejs/test/session-event-types.test.ts b/nodejs/test/session-event-types.test.ts index 37b49f8a48..f20c2db338 100644 --- a/nodejs/test/session-event-types.test.ts +++ b/nodejs/test/session-event-types.test.ts @@ -50,6 +50,9 @@ import type { UserMessageAgentMode, Attachment, WorkingDirectoryContextHostType, + FactoryContext, + FactoryDefinition, + JsonValue, } from "../src/index.js"; /** @@ -80,6 +83,17 @@ type _AssistantMessageEventStaysAlignedWithSessionEventUnion = _AssertEqual< Extract >; const _assistantMessageEventAlignmentCheck: _AssistantMessageEventStaysAlignedWithSessionEventUnion = true; +type _DefaultFactoryArgsAreJsonValue = _AssertEqual; +const _defaultFactoryArgsCheck: _DefaultFactoryArgsAreJsonValue = true; +type _DefaultFactoryResultIsJsonValueOrVoid = _AssertEqual< + Awaited>, + JsonValue | void +>; +const _defaultFactoryResultCheck: _DefaultFactoryResultIsJsonValueOrVoid = true; +// @ts-expect-error Factory arguments must be representable on the JSON wire. +type _FactoryArgsRejectUndefined = FactoryContext; +// @ts-expect-error Factory results must be JSON values or top-level void. +type _FactoryResultRejectsFunction = FactoryDefinition void>; describe("Session event type exports (#1156)", () => { it("exposes the headline ToolExecutionStartData type with a usable shape", () => { @@ -97,7 +111,7 @@ describe("Session event type exports (#1156)", () => { expect(data.toolName).toBe("shell"); expect(data.toolCallId).toBe("call-1"); - expect(data.arguments?.command).toBe("ls"); + expect(data.arguments).toEqual({ command: "ls" }); expect(data.mcpServerName).toBe("filesystem"); expect(data.mcpToolName).toBe("list_dir"); expect(data.turnId).toBe("turn-1"); diff --git a/nodejs/test/typescript-codegen.test.ts b/nodejs/test/typescript-codegen.test.ts index 248b60968b..9745428097 100644 --- a/nodejs/test/typescript-codegen.test.ts +++ b/nodejs/test/typescript-codegen.test.ts @@ -2,7 +2,10 @@ import type { JSONSchema7 } from "json-schema"; import { compile } from "json-schema-to-typescript"; import { describe, expect, it } from "vitest"; -import { normalizeSchemaForTypeScript } from "../../scripts/codegen/typescript.ts"; +import { + normalizeSchemaForTypeScript, + TYPESCRIPT_JSON_VALUE_DECLARATION, +} from "../../scripts/codegen/typescript.ts"; describe("typescript schema codegen", () => { it("emits JSDoc comments for described enum values", async () => { @@ -43,4 +46,37 @@ describe("typescript schema codegen", () => { ); expect(code).toContain('inlineMode: /** Use a direct value. */ "direct" | "indirect";'); }); + + it("maps opaque JSON fields to the recursive JsonValue type", async () => { + const schema: JSONSchema7 = { + title: "OpaqueContainer", + type: "object", + additionalProperties: false, + properties: { + payload: { + "x-opaque-json": true, + }, + }, + required: ["payload"], + }; + + const normalized = normalizeSchemaForTypeScript(schema); + expect(normalized.properties?.payload).toEqual({ + tsType: "JsonValue", + }); + + const code = await compile(normalized, "OpaqueContainer", { + bannerComment: "", + style: { semi: true, singleQuote: false }, + additionalProperties: false, + }); + + expect(`${TYPESCRIPT_JSON_VALUE_DECLARATION}\n\n${code.trim()}`).toBe( + `export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; + +export interface OpaqueContainer { + payload: JsonValue; +}` + ); + }); }); diff --git a/scripts/codegen/typescript.ts b/scripts/codegen/typescript.ts index f82e67abe6..42757c6648 100644 --- a/scripts/codegen/typescript.ts +++ b/scripts/codegen/typescript.ts @@ -38,9 +38,10 @@ import { isVoidSchema, isSchemaExperimental, isSchemaInternal, + isOpaqueJson, + stripOpaqueJsonMarker, appendPropertyMarkerTagsToDescriptions, getEnumValueDescriptions, - stripOpaqueJsonMarker, loadSchemaJson, fixBrandCasing, type ApiSchema, @@ -52,6 +53,8 @@ const TS_EXPERIMENTAL_JSDOC = "/** @experimental */"; const EXTERNAL_SCHEMA_TS_IMPORT: Record = { "session-events.schema.json": "./session-events.js", }; +export const TYPESCRIPT_JSON_VALUE_DECLARATION = + "export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };"; function tsExperimentalJSDoc(indent = ""): string { return `${indent}${TS_EXPERIMENTAL_JSDOC}`; @@ -252,6 +255,30 @@ function collectRpcMethods(node: Record): RpcMethod[] { return results; } +/** + * JSON Schema keywords that describe real structure. A node carrying any of + * these is not an unconstrained value, even when it is also marked + * `x-opaque-json`. + */ +const STRUCTURAL_SCHEMA_KEYWORDS = [ + "anyOf", + "oneOf", + "allOf", + "not", + "type", + "properties", + "patternProperties", + "items", + "prefixItems", + "enum", + "const", + "$ref", +] as const; + +function hasStructuralConstraints(schema: Record): boolean { + return STRUCTURAL_SCHEMA_KEYWORDS.some((keyword) => schema[keyword] !== undefined); +} + export function normalizeSchemaForTypeScript(schema: JSONSchema7): JSONSchema7 { const root = structuredClone(schema) as JSONSchema7 & { definitions?: Record; @@ -286,11 +313,17 @@ export function normalizeSchemaForTypeScript(schema: JSONSchema7): JSONSchema7 { Object.entries(value as Record).map(([key, child]) => [key, rewrite(child)]) ) as Record; - // The TypeScript codegen doesn't distinguish opaque JSON from any - // other unconstrained value, so drop the marker before feeding the - // schema to json-schema-to-typescript. C# codegen reads the marker - // from its own (un-normalized) view of the schema and emits - // `JsonElement` instead. + // Only a genuinely unconstrained opaque node becomes `JsonValue`. Many + // schemas carry `x-opaque-json` alongside real structure (an `anyOf` + // union, or `type`/`properties`) so that codegens which model opaque + // JSON natively can do so without discarding that structure; collapsing + // those to `JsonValue` would silently erase public contracts such as + // `McpServerConfig` or `ExternalToolResult`. For those, drop the marker + // and let json-schema-to-typescript emit the real shape, exactly as + // this codegen did before opaque JSON was representable. + if (isOpaqueJson(rewritten as JSONSchema7) && !hasStructuralConstraints(rewritten)) { + return { tsType: "JsonValue" }; + } stripOpaqueJsonMarker(rewritten); const enumValueDescriptions = getEnumValueDescriptions(rewritten as JSONSchema7); @@ -394,7 +427,7 @@ async function generateSessionEvents(schemaPath?: string): Promise { strictIndexSignatures: true, }); - let annotatedTs = annotateTypeScriptTypes(ts, experimentalDefinitionNames(definitionCollections), TS_EXPERIMENTAL_JSDOC); + let annotatedTs = `${TYPESCRIPT_JSON_VALUE_DECLARATION}\n\n${annotateTypeScriptTypes(ts, experimentalDefinitionNames(definitionCollections), TS_EXPERIMENTAL_JSDOC)}`; // Add @internal JSDoc annotations for session-event types marked // `visibility: "internal"` in the schema. The tag drives `stripInternal` // so the whole type is dropped from the published .d.ts. @@ -546,6 +579,8 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; if (externalSchemaRefs.size > 0) { lines.push(""); } + lines.push(TYPESCRIPT_JSON_VALUE_DECLARATION); + lines.push(""); const allMethods = [...collectRpcMethods(schema.server || {}), ...collectRpcMethods(schema.session || {})]; const clientSessionMethods = collectRpcMethods(schema.clientSession || {}); From ae624a301c06443a9f2e165b8348a7186d3b1b0f Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Thu, 30 Jul 2026 00:27:56 -0700 Subject: [PATCH 02/13] CCR 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve documentation metadata when a schema node collapses to `JsonValue`. Returning a bare `{ tsType }` replaced the whole node, so genuinely opaque declarations lost their JSDoc while their siblings kept theirs — visible on `FactoryAgentOptions.schema` among others. Strip only the opaque marker and the now-meaningless type keywords, keeping the description in place. Correct the `waitForRun` description in the factories guide: it claimed no timer polling, but a low-frequency periodic re-read runs alongside the subscription so a dropped invalidation cannot leave the wait pending. Mark `FactoryResumeErrorCode` `@experimental`, consistent with the rest of the factory surface. --- nodejs/docs/factories.md | 2 +- nodejs/src/factory.ts | 7 +- nodejs/src/generated/rpc.ts | 179 ++++++++++++++++++++++++- nodejs/src/generated/session-events.ts | 85 ++++++++++++ scripts/codegen/typescript.ts | 10 +- 5 files changed, 278 insertions(+), 5 deletions(-) diff --git a/nodejs/docs/factories.md b/nodejs/docs/factories.md index 025d44442a..cd5dbd4f8f 100644 --- a/nodejs/docs/factories.md +++ b/nodejs/docs/factories.md @@ -221,7 +221,7 @@ if (settled.status === "completed") { } ``` -It watches `factory.run_updated` and re-reads the durable envelope rather than polling on a timer, and it collapses a burst of invalidation events into a single in-flight read. Pass a `signal` to stop waiting: +It watches `factory.run_updated` and re-reads the durable envelope on each invalidation, collapsing a burst of events into a single in-flight read. A low-frequency periodic re-read runs alongside the subscription, so a dropped or missing invalidation degrades into a slightly late resolution rather than an unbounded wait. Pass a `signal` to stop waiting: ```ts const controller = new AbortController(); diff --git a/nodejs/src/factory.ts b/nodejs/src/factory.ts index 8f8592aaa9..017d701f16 100644 --- a/nodejs/src/factory.ts +++ b/nodejs/src/factory.ts @@ -219,7 +219,12 @@ export interface ResumeOptions { limits?: FactoryLimits; } -/** Machine-readable pre-execution factory resume failure. */ +/** + * Machine-readable pre-execution factory resume failure. + * + * @experimental Part of the experimental Agent Factories surface and may + * change or be removed in future SDK or CLI releases. + */ export type FactoryResumeErrorCode = | "not_found" | "non_resumable" diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 1adc1f4419..2732ab5e95 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -261,10 +261,20 @@ export type AuthInfoType = | "token" /** Authentication from a Copilot API token. */ | "copilot-api-token"; - +/** + * JSON Schema for canvas open input + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "CanvasJsonSchema". + */ /** @experimental */ export type CanvasJsonSchema = JsonValue; - +/** + * Provider-supplied action result. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "CanvasActionInvokeResult". + */ /** @experimental */ export type CanvasActionInvokeResult = JsonValue; /** @@ -3857,6 +3867,9 @@ export interface CanvasActionInvokeRequest { * Action name to invoke */ actionName: string; + /** + * Action input + */ input?: JsonValue; } /** @@ -3999,6 +4012,9 @@ export interface OpenCanvasInstance { * URL for web-rendered canvases */ url?: string; + /** + * Input supplied when the instance was opened + */ input?: JsonValue; } /** @@ -4021,6 +4037,9 @@ export interface CanvasOpenRequest { * Caller-supplied stable instance identifier */ instanceId: string; + /** + * Canvas open input + */ input?: JsonValue; } /** @@ -4091,6 +4110,9 @@ export interface CanvasProviderInvokeActionRequest { * Action name to invoke */ actionName: string; + /** + * Action input + */ input?: JsonValue; host?: CanvasHostContext; session?: CanvasSessionContext; @@ -4119,6 +4141,9 @@ export interface CanvasProviderOpenRequest { * Stable caller-supplied canvas instance identifier */ instanceId: string; + /** + * Canvas open input + */ input?: JsonValue; host?: CanvasHostContext; session?: CanvasSessionContext; @@ -4437,6 +4462,13 @@ export interface ConfigureSessionExtensionsParams { * Session to attach the extension controller delegate to. */ sessionId: string; + /** + * In-process ExtensionController delegate (CLI-only optimization). Marked internal: this field is excluded from the public SDK surface. The post-SDK extension surface exposes list/enable/disable/reload via dedicated RPCs served by the runtime. + * + * @internal + * + * @internal + */ controller?: JsonValue; } /** @@ -5022,6 +5054,9 @@ export interface ExtensionContextPushInput { * Human-readable composer pill label */ title: string; + /** + * Caller-supplied JSON payload (required, may be null but not undefined) + */ payload: JsonValue; } /** @@ -5365,6 +5400,9 @@ export interface FactoryAgentOptions { * Optional label distinguishing otherwise identical memoized agent calls. */ label?: string; + /** + * Optional JSON Schema for structured agent output. + */ schema?: JsonValue; /** * Optional model identifier for the subagent. @@ -5401,6 +5439,9 @@ export interface FactoryAgentRequest { */ /** @experimental */ export interface FactoryAgentResult { + /** + * Agent result, omitted when the agent produced no result. + */ result?: JsonValue; } /** @@ -5486,6 +5527,9 @@ export interface FactoryExecuteRequest { * Opaque token identifying this factory execution attempt. */ executionToken: string; + /** + * Factory input value. + */ args: JsonValue; } /** @@ -5496,6 +5540,9 @@ export interface FactoryExecuteRequest { */ /** @experimental */ export interface FactoryExecuteResult { + /** + * Factory result value. + */ result?: JsonValue; } /** @@ -5573,6 +5620,9 @@ export interface FactoryJournalGetResult { * Whether the journal contained the requested key. */ hit: boolean; + /** + * Cached JSON result. The hit field distinguishes a cached JSON null from a miss. + */ resultJson?: JsonValue; } /** @@ -5595,6 +5645,9 @@ export interface FactoryJournalPutRequest { * Namespaced journal key. */ key: string; + /** + * JSON result to memoize. + */ resultJson: JsonValue; } /** @@ -5843,6 +5896,9 @@ export interface FactoryRunResult { */ runId: string; status: FactoryRunStatus; + /** + * Completed factory result. + */ result?: JsonValue; /** * Error message for an errored run. @@ -5853,6 +5909,9 @@ export interface FactoryRunResult { * Reason for a halted or cancelled run. */ reason?: string; + /** + * Partial journal and progress snapshot for a halted, cancelled, or errored run. + */ snapshot?: JsonValue; } /** @@ -5898,6 +5957,9 @@ export interface FactoryRunRequest { * Registered factory name. */ name: string; + /** + * Factory input value. + */ args: JsonValue; options?: RunOptions; } @@ -7714,6 +7776,11 @@ export interface McpConfigUpdateRequest { /** @experimental */ /** @internal */ export interface McpConfigureGitHubRequest { + /** + * Opaque runtime auth info for GitHub MCP configuration. Marked internal: an in-process runtime shape (configureGitHubMcp is a no-op over the wire). + * + * @internal + */ authInfo: JsonValue; } /** @@ -7797,6 +7864,9 @@ export interface McpExecuteSamplingParams { * Name of the MCP server that initiated the sampling request */ serverName: string; + /** + * The original MCP JSON-RPC request ID (string or number). Used by the runtime to correlate the inference with the originating MCP request for telemetry; this is distinct from `requestId` (which is the schema-level cancellation handle). + */ mcpRequestId: JsonValue; request: McpExecuteSamplingRequest; } @@ -8148,8 +8218,23 @@ export interface McpRegisterExternalClientRequest { * Logical server name for the external client */ serverName: string; + /** + * In-process MCP Client instance. Marked internal: cannot be serialized across the JSON-RPC boundary. + * + * @internal + */ client: JsonValue; + /** + * In-process MCP Transport instance. Marked internal: cannot be serialized across the JSON-RPC boundary. + * + * @internal + */ transport: JsonValue; + /** + * In-process server config (MCPServerConfig) paired with the in-process client/transport. Marked internal alongside its companions. + * + * @internal + */ config: JsonValue; } /** @@ -8161,6 +8246,11 @@ export interface McpRegisterExternalClientRequest { /** @experimental */ /** @internal */ export interface McpReloadWithConfigRequest { + /** + * Opaque runtime MCP reload configuration. Marked internal: an in-process runtime shape (reloadMcpServers throws over the wire). + * + * @internal + */ config: JsonValue; } /** @@ -11850,6 +11940,9 @@ export interface QueueBeginDeferredIdleDrainResult { */ /** @experimental */ export interface QueueConsumeSystemNotificationsRequest { + /** + * Opaque runtime-owned filter object. + */ filter: JsonValue; } /** @@ -12252,6 +12345,13 @@ export interface RegisterExtensionToolsParams { * Session to register extension tools on. */ sessionId: string; + /** + * In-process ExtensionLoader handle (CLI-only optimization). Marked internal: this field is excluded from the public SDK surface. When the CLI migrates to a process-separated SDK, extension discovery/launch moves entirely into the runtime — the CLI passes pure config (search paths, disabled ids) via SessionOptions instead. + * + * @internal + * + * @internal + */ loader: JsonValue; options?: SessionsRegisterExtensionToolsOnSessionOptions; } @@ -12263,6 +12363,11 @@ export interface RegisterExtensionToolsParams { */ /** @experimental */ export interface SessionsRegisterExtensionToolsOnSessionOptions { + /** + * In-process `() => boolean` gating callback (CLI-only optimization). Marked internal: replaced by runtime-side enable/disable RPCs in the SDK migration. + * + * @internal + */ enabled?: JsonValue; } /** @@ -12274,6 +12379,13 @@ export interface SessionsRegisterExtensionToolsOnSessionOptions { /** @experimental */ /** @internal */ export interface RegisterExtensionToolsResult { + /** + * In-process unsubscribe function (CLI-only optimization). Marked internal: replaced by an explicit `extensions.unregister` RPC in the SDK migration. + * + * @internal + * + * @internal + */ unsubscribe: JsonValue; } /** @@ -12390,6 +12502,11 @@ export interface RemoteControlStatusActive { * Whether the MC session may steer this session. */ isSteerable: boolean; + /** + * In-process prompt-manager handle (CLI-only optimization). Marked internal: this field is excluded from the public SDK surface. When the CLI migrates to a process-separated SDK, the same bidirectional prompt-routing handshake is expressed via dedicated remote-control RPCs (register/resolve) rather than a shared in-process object. + * + * @internal + */ promptManager?: JsonValue; /** * True while a read-only/session-sync export is deferred, awaiting the first `user.message` before its MC session exists. Marked internal: this field is excluded from the public SDK surface and is populated only on the CLI in-process path. @@ -13195,7 +13312,13 @@ export interface SendSystemNotificationRequest { * Notification text to deliver to the model. */ message: string; + /** + * Optional structured notification kind. + */ kind?: JsonValue; + /** + * Internal delivery options, including passive policy. + */ options?: JsonValue; } /** @@ -14185,6 +14308,11 @@ export interface SessionOpenOptions { * Stable integration identifier for analytics. */ integrationId?: string; + /** + * ExP assignment ('flight') data injected by an SDK integrator, in the same JSON shape the Copilot CLI fetches from the experimentation service (CopilotExpAssignmentResponse). When supplied this is fed into the FeatureFlagService exactly like CLI-fetched assignments and ExP-backed flags wait for it. When absent the session does not block on ExP. + * + * @internal + */ expAssignments?: JsonValue; /** * Opt-in: self-fetch and enforce enterprise managed settings at session bootstrap. @@ -14579,6 +14707,11 @@ export interface SessionsOpenCloud { */ owner?: string; options?: SessionOpenOptions; + /** + * In-process callback invoked when the cloud task is created (before connection). Marked internal because a function reference cannot cross the JSON-RPC boundary. Disappears in the SDK migration: the field is purely cosmetic (it flips a single CLI phase label from 'creating' to 'connecting') and the wire-clean version just drops the intermediate phase. + * + * @internal + */ onTaskCreated?: JsonValue; } /** @@ -14596,7 +14729,17 @@ export interface SessionsOpenHandoff { metadata: RemoteSessionMetadataValue; options?: SessionOpenOptions; taskType?: SessionsOpenHandoffTaskType; + /** + * In-process progress callback `(update) => void` invoked for each handoff step. Marked internal because a function reference cannot cross the JSON-RPC boundary. The host-side `handoffSession` is already declared as `AsyncGenerator`; the schema layer flattens it because it does not yet support streaming methods. The wire-clean replacement is to expose the AsyncGenerator directly (or use vscode-jsonrpc `$/progress` notifications) once the schema/transport layer supports it. + * + * @internal + */ onProgress?: JsonValue; + /** + * In-process confirmation callback `(request) => boolean | Promise` invoked when the handoff needs the caller to confirm a non-fatal blocker (e.g. a repository mismatch between the current working directory and the remote session). Returning `true` proceeds with the handoff; returning `false` (or omitting the callback) aborts it. Marked internal because a function reference cannot cross the JSON-RPC boundary, for the same reasons as `onProgress`. + * + * @internal + */ onConfirm?: JsonValue; } /** @@ -14612,6 +14755,13 @@ export interface SessionOpenResult { * Opened session ID. Omitted when status is `not_found`. */ sessionId?: string; + /** + * In-process SessionClientApi handle for the opened session, returned to CLI callers as a transitional shortcut. Marked internal so the public SDK surface does not expose it; SDK consumers should construct per-session clients from `sessionId` instead. + * + * @internal + * + * @internal + */ sessionApi?: JsonValue; /** * Startup prompts queued by user-level hook configs at session creation. Only populated when status is `created`; resumed sessions return an empty array. @@ -16979,7 +17129,17 @@ export interface UIEphemeralQueryRequest { * Question to answer from the current conversation context. */ question: string; + /** + * In-process streaming callback `(text) => void` invoked with each token as the model emits it. Marked internal: excluded from the public SDK surface. In a process-separated SDK this is replaced by a streaming RPC that yields chunks and a final answer. + * + * @internal + */ onChunk?: JsonValue; + /** + * In-process `AbortSignal` forwarded to the model client to cancel an in-flight request. Marked internal: excluded from the public SDK surface. Replaced by an explicit cancellation token + cancel RPC in the SDK migration. + * + * @internal + */ abortSignal?: JsonValue; } /** @@ -17428,7 +17588,13 @@ export interface UserRequestedShellCommandResult { */ /** @experimental */ export interface UserSettingMetadata { + /** + * The effective value: the user's value if set, otherwise the default. + */ value: JsonValue; + /** + * The centrally-known default for this setting (null when no default is registered). + */ default: JsonValue; /** * True when the user has not set an explicit value for this setting (i.e. it is left at its default). Reflects whether the user has overridden the key, not whether the effective value happens to equal the default — a key explicitly set to a value identical to the default still reports false. @@ -17458,6 +17624,9 @@ export interface UserSettingsGetResult { */ /** @experimental */ export interface UserSettingsSetRequest { + /** + * Partial user settings to write, as a free-form object keyed by setting name + */ settings: JsonValue; } /** @@ -17684,6 +17853,9 @@ export interface WorkspacesDiffRequest { */ /** @experimental */ export interface WorkspacesEnsureRequest { + /** + * Opaque workspace context supplied by the session host. + */ context?: JsonValue; } /** @@ -17872,6 +18044,9 @@ export interface WorkspacesTruncateSummariesRequest { */ /** @experimental */ export interface WorkspacesUpdateMetadataRequest { + /** + * Opaque workspace context supplied by the session host. + */ context?: JsonValue; /** * Optional workspace display name override. diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index 620d65c12a..e8d94c537a 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -646,6 +646,9 @@ export type ElicitationCompletedAction = | "decline" /** The user dismissed the request. */ | "cancel"; +/** + * Opaque JSON value submitted for one field in accepted `elicitation.completed` form content. + */ export type ElicitationCompletedContent = JsonValue | undefined; /** * Reason the runtime is requesting host-provided MCP OAuth credentials @@ -687,6 +690,9 @@ export type McpHeadersRefreshCompletedOutcome = | "none" /** No response arrived within the bounded window. */ | "timeout"; +/** + * Source-defined JSON payload for the custom notification + */ export type CustomNotificationPayload = JsonValue; /** * The user's auto-mode-switch choice @@ -3174,6 +3180,9 @@ export interface AttachmentExtensionContext { * Open canvas instance identifier when the push was bound to a canvas instance */ instanceId?: string; + /** + * Caller-supplied JSON payload + */ payload?: JsonValue; /** * Human-readable composer pill label @@ -3750,6 +3759,9 @@ export interface CitationReference { */ citedText?: string; location?: CitationLocation; + /** + * Provider-native citation correlation data (e.g. Anthropic search_result_index / document_index), passed through opaquely for debugging and forward compatibility. + */ providerMetadata?: JsonValue; /** * Identifier of the CitationSource this reference points to (CitationSource.id). @@ -3827,6 +3839,9 @@ export interface AssistantMessageServerTools { * A tool invocation request from the assistant */ export interface AssistantMessageToolRequest { + /** + * Arguments to pass to the tool, format depends on the tool + */ arguments?: JsonValue; /** * Resolved intention summary describing what this specific call does @@ -4527,6 +4542,9 @@ export interface ToolUserRequestedEvent { * User-initiated tool invocation request with tool name and arguments */ export interface ToolUserRequestedData { + /** + * Arguments for the tool invocation + */ arguments?: JsonValue; /** * Unique identifier for this tool call @@ -4571,6 +4589,9 @@ export interface ToolExecutionStartEvent { * Tool execution startup details including MCP server information when applicable */ export interface ToolExecutionStartData { + /** + * Arguments passed to the tool + */ arguments?: JsonValue; /** * When true, the tool output should be displayed expanded (verbatim) in the CLI timeline @@ -4784,6 +4805,11 @@ export interface ToolExecutionCompleteData { * Whether this tool call was explicitly requested by the user rather than the assistant */ isUserRequested?: boolean; + /** + * FIDES IFC label projected from tool ingress metadata (MCP `CallToolResult._meta` or synthesized built-in ingress labels). Persisted as `{ ifc: ... }` so the label survives session resume, including model-visible failure results. Experimental. + * + * @experimental + */ mcpMeta?: JsonValue; /** * Model identifier that generated this tool call @@ -4861,7 +4887,15 @@ export interface ToolExecutionCompleteResult { * Full detailed tool result for UI/timeline display, preserving complete content such as diffs. Falls back to content when absent. */ detailedContent?: string; + /** + * FIDES IFC label projected from tool ingress metadata (MCP `CallToolResult._meta` or synthesized built-in ingress labels) — persisted as `{ ifc: ... }` (only the `ifc` key, not the whole `_meta`). Persisted so the FIDES IFC label survives session resume: the engine rehydrates accumulated taint by replaying these on load. Populated for ingress sources when FIDES IFC is on. Experimental. + * + * @experimental + */ mcpMeta?: JsonValue; + /** + * Structured content (arbitrary JSON) returned verbatim by the MCP tool + */ structuredContent?: JsonValue; uiResource?: ToolExecutionCompleteUIResource; } @@ -5694,6 +5728,9 @@ export interface HookStartData { * Type of hook being invoked (e.g., "preToolUse", "postToolUse", "sessionStart") */ hookType: string; + /** + * Input data passed to the hook + */ input?: JsonValue; } /** @@ -5739,6 +5776,9 @@ export interface HookEndData { * Type of hook that was invoked (e.g., "preToolUse", "postToolUse", "sessionStart") */ hookType: string; + /** + * Output data produced by the hook + */ output?: JsonValue; /** * Whether the hook completed successfully @@ -6111,6 +6151,9 @@ export interface SystemNotificationInstructionDiscovered { * System notification metadata from an external host that does not match a runtime-owned notification kind. */ export interface SystemNotificationUnclassified { + /** + * Opaque metadata supplied by the external host, when present. + */ metadata?: JsonValue; /** * Type discriminator. Always "unclassified". @@ -6161,6 +6204,9 @@ export interface PermissionRequestedData { * When true, this permission was already resolved by a permissionRequest hook and requires no client action */ resolvedByHook?: boolean; + /** + * Neutral risk metadata supplied by the tool host. Consumers may display this value but must not use it to bypass the permission decision. + */ riskAssessment?: JsonValue; } /** @@ -6341,6 +6387,9 @@ export interface PermissionRequestRead { * MCP tool invocation permission request */ export interface PermissionRequestMcp { + /** + * Arguments to pass to the MCP tool + */ args?: JsonValue; /** * Permission kind discriminator @@ -6439,6 +6488,9 @@ export interface PermissionRequestMemory { * Custom tool invocation permission request */ export interface PermissionRequestCustomTool { + /** + * Arguments to pass to the custom tool + */ args?: JsonValue; /** * Permission kind discriminator @@ -6469,6 +6521,9 @@ export interface PermissionRequestHook { * Permission kind discriminator */ kind: "hook"; + /** + * Arguments of the tool call being gated + */ toolArgs?: JsonValue; /** * Tool call ID that triggered this permission request @@ -6658,6 +6713,9 @@ export interface PermissionPromptRequestRead { * MCP tool invocation permission prompt */ export interface PermissionPromptRequestMcp { + /** + * Arguments to pass to the MCP tool + */ args?: JsonValue; /** * Auto-approval judge information for this request; present only when auto mode is enabled. @@ -6770,6 +6828,9 @@ export interface PermissionPromptRequestMemory { * Custom tool invocation permission prompt */ export interface PermissionPromptRequestCustomTool { + /** + * Arguments to pass to the custom tool + */ args?: JsonValue; /** * Auto-approval judge information for this request; present only when auto mode is enabled. @@ -6836,6 +6897,9 @@ export interface PermissionPromptRequestHook { * Prompt kind discriminator */ kind: "hook"; + /** + * Arguments of the tool call being gated + */ toolArgs?: JsonValue; /** * Tool call ID that triggered this permission request @@ -7430,6 +7494,9 @@ export interface SamplingRequestedEvent { * Sampling request from an MCP server; contains the server name and a requestId for correlation */ export interface SamplingRequestedData { + /** + * The JSON-RPC request ID from the MCP protocol + */ mcpRequestId: JsonValue; /** * Unique identifier for this sampling request; used to respond via session.respondToSampling() @@ -7819,6 +7886,9 @@ export interface ExternalToolRequestedEvent { * External tool invocation request for client-side tool execution */ export interface ExternalToolRequestedData { + /** + * Arguments to pass to the external tool + */ arguments?: JsonValue; /** * Unique identifier for this request; used to respond via session.respondToExternalTool() @@ -8365,6 +8435,9 @@ export interface ManagedSettingsResolvedData { * Whether the server (account/org) managed-settings layer was present */ serverManaged: boolean; + /** + * The effective (resolved) managed settings values, so clients can render exactly what is enforced. Absent when no managed policy is in force. + */ settings?: JsonValue; source: ManagedSettingsResolvedSource; } @@ -9208,6 +9281,9 @@ export interface CanvasOpenedData { * Host-local PNG path for the canvas icon, when supplied */ icon?: string; + /** + * Input supplied when the instance was opened + */ input?: JsonValue; /** * Stable caller-supplied canvas instance identifier @@ -9300,6 +9376,9 @@ export interface CanvasRegistryChangedCanvas { * Host-local PNG path for the canvas icon, when supplied */ icon?: string; + /** + * JSON Schema for canvas open input + */ inputSchema?: JsonValue; } /** @@ -9311,6 +9390,9 @@ export interface CanvasRegistryChangedCanvasAction { * Action description */ description?: string; + /** + * JSON Schema for action input + */ inputSchema?: JsonValue; /** * Action name @@ -9459,6 +9541,9 @@ export interface CanvasRecordedData { * Owning provider identifier */ extensionId: string; + /** + * Input supplied when the instance was opened + */ input?: JsonValue; /** * Stable caller-supplied canvas instance identifier diff --git a/scripts/codegen/typescript.ts b/scripts/codegen/typescript.ts index 42757c6648..db9e05dea6 100644 --- a/scripts/codegen/typescript.ts +++ b/scripts/codegen/typescript.ts @@ -322,7 +322,15 @@ export function normalizeSchemaForTypeScript(schema: JSONSchema7): JSONSchema7 { // and let json-schema-to-typescript emit the real shape, exactly as // this codegen did before opaque JSON was representable. if (isOpaqueJson(rewritten as JSONSchema7) && !hasStructuralConstraints(rewritten)) { - return { tsType: "JsonValue" }; + // Keep the node's documentation metadata — replacing it wholesale + // would drop `description` and the stability annotations, so the + // generated declaration would lose its JSDoc while its siblings + // keep theirs. + stripOpaqueJsonMarker(rewritten); + rewritten.tsType = "JsonValue"; + delete rewritten.type; + delete rewritten.additionalProperties; + return rewritten; } stripOpaqueJsonMarker(rewritten); From 52fd91d135e48e3284409a498ac26a7b5cd730aa Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Thu, 30 Jul 2026 00:46:37 -0700 Subject: [PATCH 03/13] CCR 2 Check the abort signal before running a volatile step producer. `step()` awaits a progress flush before dispatching, which is an await point, so `factory.abort` can land between entering the call and running the producer. The journaled path is covered because its next operation goes through `awaitFactoryOperation`, which checks the signal before dispatching; the volatile path returned `producer()` directly and so could start new extension work on an already-cancelled run. The regression test aborts mid-run and asserts the producer never runs. Removing the guard fails it. --- nodejs/src/session.ts | 6 ++++++ nodejs/test/factory.test.ts | 43 +++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index e0feb2f7e1..97f1622ebb 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -1363,6 +1363,12 @@ export class CopilotSession { ): Promise => { await progress.flush(); if (options.volatile) { + // The flush above is an await point, so an abort can land + // between entering step() and running the producer. The + // journaled branch is covered by awaitFactoryOperation; + // this one has to check for itself, or a cancelled run + // would still start new extension work. + throwIfFactoryAborted(controller.signal); return producer(); } const cached = await awaitFactoryOperation( diff --git a/nodejs/test/factory.test.ts b/nodejs/test/factory.test.ts index 19e3035bbb..f883f480b8 100644 --- a/nodejs/test/factory.test.ts +++ b/nodejs/test/factory.test.ts @@ -971,6 +971,49 @@ describe("factories", () => { expect(sendRequest).not.toHaveBeenCalled(); }); + it("does not start a volatile step producer after the run is aborted", async () => { + const sendRequest = vi.fn(); + const session = new CopilotSession("session-volatile-abort", { sendRequest } as never); + let producerRan = false; + const factory = defineFactory({ + meta: { + name: "volatile-abort", + description: "Volatile steps honour cancellation", + phases: [], + }, + run: async ({ step, runId }) => { + // Abort mid-run, then attempt a volatile step. The producer must + // not run: cancellation has to stop new extension work starting, + // exactly as it does on the journaled path. + await session.clientSessionApis.factory!.abort({ + sessionId: session.sessionId, + runId, + }); + await step( + "volatile", + () => { + producerRan = true; + return "should not happen"; + }, + { volatile: true } + ); + return "completed"; + }, + }); + session.registerFactories([factory]); + + await expect( + session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "volatile-abort", + runId: "run-volatile-abort", + executionToken: "execution-token", + args: {}, + }) + ).rejects.toThrow(); + expect(producerRan).toBe(false); + }); + it("rejects a factory result array with an extra own key", async () => { const factory = defineFactory({ meta: { From 7d4b565db1aaacd0ea5984df50f211863d37038f Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Thu, 30 Jul 2026 12:47:50 +0000 Subject: [PATCH 04/13] Unref the waitForRun re-read interval --- nodejs/src/session.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 97f1622ebb..60cf507d7a 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -545,6 +545,9 @@ export class CopilotSession { }); pollHandle = setInterval(() => void read(), 5_000); + // The re-read is a safety net, not work the process owes anyone: an + // outstanding wait must never keep Node alive on its own. + pollHandle.unref?.(); void read(); }); } From 492a2b9f7dcb587fdb995d78b57101b62cacf101 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 30 Jul 2026 14:09:00 +0000 Subject: [PATCH 05/13] Add factory end-to-end coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/test/e2e/factory.e2e.test.ts | 65 +++++++++++++++++++ .../test/e2e/fixtures/factory-extension.mjs | 14 ++++ test/harness/replayingCapiProxy.ts | 1 + 3 files changed, 80 insertions(+) create mode 100644 nodejs/test/e2e/factory.e2e.test.ts create mode 100644 nodejs/test/e2e/fixtures/factory-extension.mjs diff --git a/nodejs/test/e2e/factory.e2e.test.ts b/nodejs/test/e2e/factory.e2e.test.ts new file mode 100644 index 0000000000..cf24c9f850 --- /dev/null +++ b/nodejs/test/e2e/factory.e2e.test.ts @@ -0,0 +1,65 @@ +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { copyFile, mkdir } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, it } from "vitest"; +import { approveAll } from "../../src/index.js"; +import { createSdkTestContext, DEFAULT_GITHUB_TOKEN } from "./harness/sdkTestContext.js"; +import { retry } from "./harness/sdkTestHelper.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const factoryTestContext = await createSdkTestContext({ + copilotClientOptions: { + env: { + COPILOT_CLI_ENABLED_FEATURE_FLAGS: "EXTENSIONS,AGENT_FACTORIES", + }, + }, +}); + +it("runs an extension-authored factory across the SDK process boundary", async () => { + const { copilotClient, openAiEndpoint, workDir } = factoryTestContext; + + await openAiEndpoint.setCopilotUserByToken(DEFAULT_GITHUB_TOKEN, { + login: "factory-e2e-user", + copilot_plan: "individual_pro", + token_based_billing: true, + }); + + const extensionDir = join(workDir, ".github", "extensions", "factory-smoke"); + const readyFile = join(extensionDir, "ready"); + await mkdir(extensionDir, { recursive: true }); + await copyFile( + join(__dirname, "fixtures", "factory-extension.mjs"), + join(extensionDir, "extension.mjs") + ); + execFileSync("git", ["init", "--quiet"], { cwd: workDir }); + + await using session = await copilotClient.createSession({ + requestExtensions: true, + extensionSdkPath: resolve(__dirname, "..", "..", "dist"), + onPermissionRequest: approveAll, + onElicitationRequest: async () => ({ + action: "accept", + content: { action: "approve" }, + }), + }); + + await retry( + "wait for the factory extension to join the session", + async () => { + expect(existsSync(readyFile)).toBe(true); + }, + 300, + 100 + ); + + const result = await session.factory.run("argument-echo", { + args: { source: "sdk-e2e", count: 11 }, + }); + + expect(result).toMatchObject({ + status: "completed", + result: { source: "sdk-e2e", count: 11 }, + }); +}); diff --git a/nodejs/test/e2e/fixtures/factory-extension.mjs b/nodejs/test/e2e/fixtures/factory-extension.mjs new file mode 100644 index 0000000000..fab95a90f1 --- /dev/null +++ b/nodejs/test/e2e/fixtures/factory-extension.mjs @@ -0,0 +1,14 @@ +import { writeFileSync } from "node:fs"; +import { defineFactory, joinSession } from "@github/copilot-sdk/extension"; + +const argumentEcho = defineFactory({ + meta: { + name: "argument-echo", + description: "Return the invocation arguments verbatim.", + phases: [], + }, + run: async ({ args }) => args, +}); + +await joinSession({ factories: [argumentEcho] }); +writeFileSync(new URL("./ready", import.meta.url), "ready"); diff --git a/test/harness/replayingCapiProxy.ts b/test/harness/replayingCapiProxy.ts index 5e07449f73..9b83cfe794 100644 --- a/test/harness/replayingCapiProxy.ts +++ b/test/harness/replayingCapiProxy.ts @@ -1747,6 +1747,7 @@ export type ToolResultNormalizer = { export type CopilotUserResponse = { login: string; copilot_plan?: string; + token_based_billing?: boolean; is_mcp_enabled?: boolean; endpoints?: { api?: string; From 3d1963f7f02e87d1b7c0a79420f27c493ede420e Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 30 Jul 2026 14:09:00 +0000 Subject: [PATCH 06/13] Skip factory E2E for in-process runtime Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/test/e2e/factory.e2e.test.ts | 108 +++++++++++++++------------- 1 file changed, 60 insertions(+), 48 deletions(-) diff --git a/nodejs/test/e2e/factory.e2e.test.ts b/nodejs/test/e2e/factory.e2e.test.ts index cf24c9f850..547ecbbd5d 100644 --- a/nodejs/test/e2e/factory.e2e.test.ts +++ b/nodejs/test/e2e/factory.e2e.test.ts @@ -5,61 +5,73 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { expect, it } from "vitest"; import { approveAll } from "../../src/index.js"; -import { createSdkTestContext, DEFAULT_GITHUB_TOKEN } from "./harness/sdkTestContext.js"; +import { + createSdkTestContext, + DEFAULT_GITHUB_TOKEN, + isInProcessTransport, +} from "./harness/sdkTestContext.js"; import { retry } from "./harness/sdkTestHelper.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const factoryTestContext = await createSdkTestContext({ - copilotClientOptions: { - env: { - COPILOT_CLI_ENABLED_FEATURE_FLAGS: "EXTENSIONS,AGENT_FACTORIES", - }, - }, -}); +const factoryTestContext = isInProcessTransport + ? undefined + : await createSdkTestContext({ + copilotClientOptions: { + env: { + COPILOT_CLI_ENABLED_FEATURE_FLAGS: "EXTENSIONS,AGENT_FACTORIES", + }, + }, + }); -it("runs an extension-authored factory across the SDK process boundary", async () => { - const { copilotClient, openAiEndpoint, workDir } = factoryTestContext; +it.skipIf(isInProcessTransport)( + "runs an extension-authored factory across the SDK process boundary", + async () => { + if (!factoryTestContext) { + throw new Error("Factory E2E requires the stdio transport"); + } + const { copilotClient, openAiEndpoint, workDir } = factoryTestContext; - await openAiEndpoint.setCopilotUserByToken(DEFAULT_GITHUB_TOKEN, { - login: "factory-e2e-user", - copilot_plan: "individual_pro", - token_based_billing: true, - }); + await openAiEndpoint.setCopilotUserByToken(DEFAULT_GITHUB_TOKEN, { + login: "factory-e2e-user", + copilot_plan: "individual_pro", + token_based_billing: true, + }); - const extensionDir = join(workDir, ".github", "extensions", "factory-smoke"); - const readyFile = join(extensionDir, "ready"); - await mkdir(extensionDir, { recursive: true }); - await copyFile( - join(__dirname, "fixtures", "factory-extension.mjs"), - join(extensionDir, "extension.mjs") - ); - execFileSync("git", ["init", "--quiet"], { cwd: workDir }); + const extensionDir = join(workDir, ".github", "extensions", "factory-smoke"); + const readyFile = join(extensionDir, "ready"); + await mkdir(extensionDir, { recursive: true }); + await copyFile( + join(__dirname, "fixtures", "factory-extension.mjs"), + join(extensionDir, "extension.mjs") + ); + execFileSync("git", ["init", "--quiet"], { cwd: workDir }); - await using session = await copilotClient.createSession({ - requestExtensions: true, - extensionSdkPath: resolve(__dirname, "..", "..", "dist"), - onPermissionRequest: approveAll, - onElicitationRequest: async () => ({ - action: "accept", - content: { action: "approve" }, - }), - }); + await using session = await copilotClient.createSession({ + requestExtensions: true, + extensionSdkPath: resolve(__dirname, "..", "..", "dist"), + onPermissionRequest: approveAll, + onElicitationRequest: async () => ({ + action: "accept", + content: { action: "approve" }, + }), + }); - await retry( - "wait for the factory extension to join the session", - async () => { - expect(existsSync(readyFile)).toBe(true); - }, - 300, - 100 - ); + await retry( + "wait for the factory extension to join the session", + async () => { + expect(existsSync(readyFile)).toBe(true); + }, + 300, + 100 + ); - const result = await session.factory.run("argument-echo", { - args: { source: "sdk-e2e", count: 11 }, - }); + const result = await session.factory.run("argument-echo", { + args: { source: "sdk-e2e", count: 11 }, + }); - expect(result).toMatchObject({ - status: "completed", - result: { source: "sdk-e2e", count: 11 }, - }); -}); + expect(result).toMatchObject({ + status: "completed", + result: { source: "sdk-e2e", count: 11 }, + }); + } +); From 6566a4d0e1243f6828ee0faefed4b1cb4051c09a Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Thu, 30 Jul 2026 07:07:43 -0700 Subject: [PATCH 07/13] Document how a factory's arguments reach the agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There is no declared schema for `ctx.args`: `FactoryMeta` carries only name, description, phases and limits, and the `run_factory` tool's `args` parameter is untyped, forwarded verbatim. The description is therefore the only channel telling an agent what to supply, which the guide did not say and its example did not demonstrate — nothing in "Review changed files and verify the findings" hints that `ctx.args.files` is a string array. State the constraint explicitly and show the expected shape in the example's description. --- nodejs/docs/factories.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/nodejs/docs/factories.md b/nodejs/docs/factories.md index cd5dbd4f8f..c40e51de88 100644 --- a/nodejs/docs/factories.md +++ b/nodejs/docs/factories.md @@ -12,7 +12,9 @@ import { defineFactory, joinSession } from "@github/copilot-sdk/extension"; const reviewChanged = defineFactory({ meta: { name: "review-changed", - description: "Review changed files and verify the findings", + description: + "Review changed files and verify the findings. " + + "args: { files: string[] } — the paths to review.", phases: [{ title: "Review" }, { title: "Verify" }], limits: { maxConcurrentSubagents: 3, @@ -41,6 +43,8 @@ const session = await joinSession({ factories: [reviewChanged] }); Factory metadata contains a stable `name`, a human-readable `description`, declared `phases`, and optional `limits`. Phase entries contain a `title` and optional `detail`. +There is no declared schema for `ctx.args`. The `run_factory` tool forwards `args` verbatim and its parameter is untyped, so **the `description` is the only thing telling an agent what arguments to supply** — state the expected shape there whenever a factory reads `ctx.args`, as the example above does. Arguments supplied by an extension calling `session.factory.run(...)` directly are typed through `defineFactory`, but that typing does not reach the model. A factory that reads `ctx.args` should validate it rather than assume a shape. + `defineFactory` accepts a `run(context)` function returning `Promise`, where `TResult` is `JsonValue | void`. Objects, arrays, strings, numbers, booleans, and `null` are valid results. Returning `undefined` completes the factory with no result. Other non-JSON values are rejected. ## Factory context From 8d59bea65eb05d2cb0a5f189f44eb8048bea5e0b Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Thu, 30 Jul 2026 08:09:34 -0700 Subject: [PATCH 08/13] Scope the JsonValue result correction to the factory surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts `scripts/codegen/typescript.ts` and the generated output to `main` verbatim, so `x-opaque-json` keeps its existing meaning for every consumer. Treating the marker as `JsonValue` globally retyped 69 declarations across unrelated wire types, which is too broad a change to carry in this PR. A straight revert is not free, though: the runtime returns any JSON value as a factory result, while the schema models the field as an opaque node that the generator renders as an object. Without a correction the surface cannot type a `null`, string, number, or array result, which contradicts the documented contract and the void-result and non-object-result E2E scenarios. So the correction now lives on the factory surface alone — `FactoryRunResult` omits the generated `result` and re-declares it as `JsonValue`, with casts confined to the RPC boundary. When the schema grows metadata distinguishing an opaque JSON value from an opaque in-process value, regenerating will produce `JsonValue` directly and this override and its casts delete outright. --- nodejs/src/client.ts | 2 +- nodejs/src/factory.ts | 14 +- nodejs/src/generated/rpc.ts | 256 ++++++++++++++++--------- nodejs/src/generated/session-events.ts | 144 +++++++++----- nodejs/src/session.ts | 39 +++- nodejs/src/types.ts | 2 +- nodejs/test/typescript-codegen.test.ts | 38 +--- scripts/codegen/typescript.ts | 57 +----- 8 files changed, 320 insertions(+), 232 deletions(-) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 0b054fd293..2e13cd79bd 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -35,10 +35,10 @@ import { } from "./generated/rpc.js"; import type { GitHubTelemetryNotification, - JsonValue, OpenCanvasInstance, SessionUpdateOptionsParams, } from "./generated/rpc.js"; +import type { JsonValue } from "./factory.js"; import { getSdkProtocolVersion } from "./sdkProtocolVersion.js"; import { CopilotSession } from "./session.js"; import type { FfiRuntimeHost } from "./ffiRuntimeHost.js"; diff --git a/nodejs/src/factory.ts b/nodejs/src/factory.ts index 017d701f16..aadad541ff 100644 --- a/nodejs/src/factory.ts +++ b/nodejs/src/factory.ts @@ -6,7 +6,7 @@ import type { FactoryGetRunProgressRequest, FactoryProgressPage, FactoryRunDetail, - FactoryRunResult, + FactoryRunResult as WireFactoryRunResult, FactoryRunStatus, FactoryRunSummary, } from "./generated/rpc.js"; @@ -17,7 +17,18 @@ import type { FactoryLimits, FactoryMeta } from "./types.js"; * The terminal envelope describing a factory run's outcome (status, result, * reason). Re-exported so consumers can name the type returned by * {@link SessionFactoryApi} methods. + * + * `result` is re-typed here rather than taken from the generated wire type. The + * runtime returns any JSON value — including `null`, a string, a number, or an + * array — but the schema models the field as an opaque node, which the + * generator renders as an object. Narrowing the correction to this surface + * keeps the `x-opaque-json` handling unchanged for every other consumer. */ +export type FactoryRunResult = Omit & { + /** Completed factory result. */ + result?: JsonValue; +}; + export type { FactoryAgentSummary, FactoryPhaseStatus, @@ -25,7 +36,6 @@ export type { FactoryProgressLine, FactoryProgressPage, FactoryRunDetail, - FactoryRunResult, FactoryRunStatus, FactoryRunSummary, } from "./generated/rpc.js"; diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 2732ab5e95..db9039238e 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -7,8 +7,6 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; import type { AbortReason, Attachment, ContextTier, EmbeddedBlobResourceContents, EmbeddedTextResourceContents, McpServerSource, McpServerStatus, PermissionPromptRequest, PermissionRule, ReasoningSummary, SessionEvent, SessionLimitsConfig, SessionMode, ShutdownType, SkillSource, UserToolSessionApproval, Verbosity } from "./session-events.js"; -export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; - /** * Initial authentication info for the session. * @@ -261,22 +259,6 @@ export type AuthInfoType = | "token" /** Authentication from a Copilot API token. */ | "copilot-api-token"; -/** - * JSON Schema for canvas open input - * - * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "CanvasJsonSchema". - */ -/** @experimental */ -export type CanvasJsonSchema = JsonValue; -/** - * Provider-supplied action result. - * - * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "CanvasActionInvokeResult". - */ -/** @experimental */ -export type CanvasActionInvokeResult = JsonValue; /** * Coarse command category for grouping and behavior: runtime built-in, skill-backed command, or SDK/client-owned command * @@ -3464,7 +3446,7 @@ export interface AgentInfo { * @experimental */ mcpServers?: { - [k: string]: JsonValue | undefined; + [k: string]: unknown | undefined; }; /** * Skill names preloaded into this agent's context. Omitted means none. @@ -3851,6 +3833,16 @@ export interface CanvasAction { description?: string; inputSchema?: CanvasJsonSchema; } +/** + * JSON Schema for canvas open input + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "CanvasJsonSchema". + */ +/** @experimental */ +export interface CanvasJsonSchema { + [k: string]: unknown | undefined; +} /** * Canvas action invocation parameters. * @@ -3870,7 +3862,19 @@ export interface CanvasActionInvokeRequest { /** * Action input */ - input?: JsonValue; + input?: { + [k: string]: unknown | undefined; + }; +} +/** + * Provider-supplied action result. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "CanvasActionInvokeResult". + */ +/** @experimental */ +export interface CanvasActionInvokeResult { + [k: string]: unknown | undefined; } /** * Canvas close parameters. @@ -4015,7 +4019,9 @@ export interface OpenCanvasInstance { /** * Input supplied when the instance was opened */ - input?: JsonValue; + input?: { + [k: string]: unknown | undefined; + }; } /** * Canvas open parameters. @@ -4040,7 +4046,9 @@ export interface CanvasOpenRequest { /** * Canvas open input */ - input?: JsonValue; + input?: { + [k: string]: unknown | undefined; + }; } /** * Canvas close parameters sent to the provider. @@ -4113,7 +4121,9 @@ export interface CanvasProviderInvokeActionRequest { /** * Action input */ - input?: JsonValue; + input?: { + [k: string]: unknown | undefined; + }; host?: CanvasHostContext; session?: CanvasSessionContext; } @@ -4144,7 +4154,9 @@ export interface CanvasProviderOpenRequest { /** * Canvas open input */ - input?: JsonValue; + input?: { + [k: string]: unknown | undefined; + }; host?: CanvasHostContext; session?: CanvasSessionContext; } @@ -4469,7 +4481,9 @@ export interface ConfigureSessionExtensionsParams { * * @internal */ - controller?: JsonValue; + controller?: { + [k: string]: unknown | undefined; + }; } /** * Metadata for a connected remote session. @@ -4714,7 +4728,7 @@ export interface CurrentToolMetadata { * JSON Schema for tool input */ input_schema?: { - [k: string]: JsonValue | undefined; + [k: string]: unknown | undefined; }; /** * Whether the tool is loaded on demand via tool search @@ -5057,7 +5071,9 @@ export interface ExtensionContextPushInput { /** * Caller-supplied JSON payload (required, may be null but not undefined) */ - payload: JsonValue; + payload: { + [k: string]: unknown | undefined; + }; } /** * Extensions discovered for the session, with their current status. @@ -5126,7 +5142,7 @@ export interface ExternalToolTextResultForLlm { * Optional tool-specific telemetry */ toolTelemetry?: { - [k: string]: JsonValue | undefined; + [k: string]: unknown | undefined; }; /** * Base64-encoded binary results returned to the model @@ -5166,7 +5182,7 @@ export interface ExternalToolTextResultForLlmBinaryResultsForLlm { * Optional metadata from the producing tool. */ metadata?: { - [k: string]: JsonValue | undefined; + [k: string]: unknown | undefined; }; } /** @@ -5403,7 +5419,9 @@ export interface FactoryAgentOptions { /** * Optional JSON Schema for structured agent output. */ - schema?: JsonValue; + schema?: { + [k: string]: unknown | undefined; + }; /** * Optional model identifier for the subagent. */ @@ -5442,7 +5460,9 @@ export interface FactoryAgentResult { /** * Agent result, omitted when the agent produced no result. */ - result?: JsonValue; + result?: { + [k: string]: unknown | undefined; + }; } /** * Prompt-safe durable identity and live status for a direct factory agent. @@ -5530,7 +5550,9 @@ export interface FactoryExecuteRequest { /** * Factory input value. */ - args: JsonValue; + args: { + [k: string]: unknown | undefined; + }; } /** * Result returned by an extension factory closure. @@ -5543,7 +5565,9 @@ export interface FactoryExecuteResult { /** * Factory result value. */ - result?: JsonValue; + result?: { + [k: string]: unknown | undefined; + }; } /** * Parameters for paging factory progress. @@ -5623,7 +5647,9 @@ export interface FactoryJournalGetResult { /** * Cached JSON result. The hit field distinguishes a cached JSON null from a miss. */ - resultJson?: JsonValue; + resultJson?: { + [k: string]: unknown | undefined; + }; } /** * Parameters for storing a factory journal entry. @@ -5648,7 +5674,9 @@ export interface FactoryJournalPutRequest { /** * JSON result to memoize. */ - resultJson: JsonValue; + resultJson: { + [k: string]: unknown | undefined; + }; } /** * Empty parameters for listing factory runs. @@ -5899,7 +5927,9 @@ export interface FactoryRunResult { /** * Completed factory result. */ - result?: JsonValue; + result?: { + [k: string]: unknown | undefined; + }; /** * Error message for an errored run. */ @@ -5912,7 +5942,9 @@ export interface FactoryRunResult { /** * Partial journal and progress snapshot for a halted, cancelled, or errored run. */ - snapshot?: JsonValue; + snapshot?: { + [k: string]: unknown | undefined; + }; } /** * Full factory run observability detail. @@ -5960,7 +5992,9 @@ export interface FactoryRunRequest { /** * Factory input value. */ - args: JsonValue; + args: { + [k: string]: unknown | undefined; + }; options?: RunOptions; } /** @@ -6509,7 +6543,7 @@ export interface HistoryTruncateResult { export interface HookInvokeRequest { sessionId: string; hookType: HookType; - input: JsonValue; + input: unknown; } /** * Optional output returned by an SDK callback hook. @@ -6520,7 +6554,7 @@ export interface HookInvokeRequest { /** @experimental */ /** @internal */ export interface HookInvokeResponse { - output?: JsonValue; + output?: unknown; } /** * Installed plugin record from global state, with marketplace, version, install time, enabled state, cache path, and source. @@ -7302,7 +7336,7 @@ export interface McpAppsCallToolRequest { * Tool arguments */ arguments?: { - [k: string]: JsonValue | undefined; + [k: string]: unknown | undefined; }; /** * **Required.** Server whose ui:// view issued the request. Per SEP-1865 ('callable by the app from this server only'), the call is rejected when this differs from `serverName`, and rejected outright when missing. @@ -7447,7 +7481,7 @@ export interface McpAppsListToolsResult { * App-callable tools from the server */ tools: { - [k: string]: JsonValue | undefined; + [k: string]: unknown | undefined; }[]; } /** @@ -7508,7 +7542,7 @@ export interface McpAppsResourceContent { * Resource-level metadata (CSP, permissions, etc.) */ _meta?: { - [k: string]: JsonValue | undefined; + [k: string]: unknown | undefined; }; } /** @@ -7781,7 +7815,9 @@ export interface McpConfigureGitHubRequest { * * @internal */ - authInfo: JsonValue; + authInfo: { + [k: string]: unknown | undefined; + }; } /** * Result of configuring GitHub MCP. @@ -7867,7 +7903,9 @@ export interface McpExecuteSamplingParams { /** * The original MCP JSON-RPC request ID (string or number). Used by the runtime to correlate the inference with the originating MCP request for telemetry; this is distinct from `requestId` (which is the schema-level cancellation handle). */ - mcpRequestId: JsonValue; + mcpRequestId: { + [k: string]: unknown | undefined; + }; request: McpExecuteSamplingRequest; } /** @@ -8223,19 +8261,25 @@ export interface McpRegisterExternalClientRequest { * * @internal */ - client: JsonValue; + client: { + [k: string]: unknown | undefined; + }; /** * In-process MCP Transport instance. Marked internal: cannot be serialized across the JSON-RPC boundary. * * @internal */ - transport: JsonValue; + transport: { + [k: string]: unknown | undefined; + }; /** * In-process server config (MCPServerConfig) paired with the in-process client/transport. Marked internal alongside its companions. * * @internal */ - config: JsonValue; + config: { + [k: string]: unknown | undefined; + }; } /** * Opaque MCP reload configuration. @@ -8251,7 +8295,9 @@ export interface McpReloadWithConfigRequest { * * @internal */ - config: JsonValue; + config: { + [k: string]: unknown | undefined; + }; } /** * Indicates whether the auto-managed `github` MCP server was removed (false when nothing to remove). @@ -8307,13 +8353,13 @@ export interface McpResource { * Resource-level metadata */ _meta?: { - [k: string]: JsonValue | undefined; + [k: string]: unknown | undefined; }; /** * Server-provided non-standard descriptor fields preserved from the MCP response */ additionalProperties?: { - [k: string]: JsonValue | undefined; + [k: string]: unknown | undefined; }; } /** @@ -8344,7 +8390,7 @@ export interface McpResourceIcon { * Server-provided non-standard icon fields preserved from the MCP response */ additionalProperties?: { - [k: string]: JsonValue | undefined; + [k: string]: unknown | undefined; }; } /** @@ -8371,7 +8417,7 @@ export interface McpResourceAnnotations { * Server-provided non-standard annotation fields preserved from the MCP response */ additionalProperties?: { - [k: string]: JsonValue | undefined; + [k: string]: unknown | undefined; }; } /** @@ -8402,7 +8448,7 @@ export interface McpResourceContent { * Resource-level metadata (CSP, permissions, etc.) */ _meta?: { - [k: string]: JsonValue | undefined; + [k: string]: unknown | undefined; }; } /** @@ -8510,13 +8556,13 @@ export interface McpResourceTemplate { * Resource-template-level metadata */ _meta?: { - [k: string]: JsonValue | undefined; + [k: string]: unknown | undefined; }; /** * Server-provided non-standard descriptor fields preserved from the MCP response */ additionalProperties?: { - [k: string]: JsonValue | undefined; + [k: string]: unknown | undefined; }; } /** @@ -9483,7 +9529,7 @@ export interface NameSetRequest { /** @experimental */ export interface OptionsUpdateAdditionalContentExclusionPolicy { rules: OptionsUpdateAdditionalContentExclusionPolicyRule[]; - last_updated_at: JsonValue; + last_updated_at: unknown; scope: OptionsUpdateAdditionalContentExclusionPolicyScope; } /** @@ -10497,7 +10543,7 @@ export interface PermissionRulesSet { /** @experimental */ export interface PermissionsConfigureAdditionalContentExclusionPolicy { rules: PermissionsConfigureAdditionalContentExclusionPolicyRule[]; - last_updated_at: JsonValue; + last_updated_at: unknown; scope: PermissionsConfigureAdditionalContentExclusionPolicyScope; } /** @@ -11308,7 +11354,7 @@ export interface ProviderAddResult { /** * Synthesized selectable model entries for the newly added BYOK models, each under its provider-qualified selection id (`provider/id`). Empty when only providers were added. */ - models: JsonValue[]; + models: unknown[]; } /** * Custom model-provider configuration (BYOK). @@ -11943,7 +11989,9 @@ export interface QueueConsumeSystemNotificationsRequest { /** * Opaque runtime-owned filter object. */ - filter: JsonValue; + filter: { + [k: string]: unknown | undefined; + }; } /** * Inputs for marking session.idle deferred in native state. @@ -12352,7 +12400,9 @@ export interface RegisterExtensionToolsParams { * * @internal */ - loader: JsonValue; + loader: { + [k: string]: unknown | undefined; + }; options?: SessionsRegisterExtensionToolsOnSessionOptions; } /** @@ -12368,7 +12418,9 @@ export interface SessionsRegisterExtensionToolsOnSessionOptions { * * @internal */ - enabled?: JsonValue; + enabled?: { + [k: string]: unknown | undefined; + }; } /** * Handle for releasing the extension tool registration. @@ -12386,7 +12438,9 @@ export interface RegisterExtensionToolsResult { * * @internal */ - unsubscribe: JsonValue; + unsubscribe: { + [k: string]: unknown | undefined; + }; } /** * Opaque handle previously returned by `registerInterest` to release. @@ -12507,7 +12561,9 @@ export interface RemoteControlStatusActive { * * @internal */ - promptManager?: JsonValue; + promptManager?: { + [k: string]: unknown | undefined; + }; /** * True while a read-only/session-sync export is deferred, awaiting the first `user.message` before its MC session exists. Marked internal: this field is excluded from the public SDK surface and is populated only on the CLI in-process path. * @@ -13315,11 +13371,15 @@ export interface SendSystemNotificationRequest { /** * Optional structured notification kind. */ - kind?: JsonValue; + kind?: { + [k: string]: unknown | undefined; + }; /** * Internal delivery options, including passive policy. */ - options?: JsonValue; + options?: { + [k: string]: unknown | undefined; + }; } /** * Agents discovered across user, project, plugin, and remote sources. @@ -13803,7 +13863,7 @@ export interface SessionFsSqliteQueryRequest { * Optional named bind parameters */ params?: { - [k: string]: JsonValue | undefined; + [k: string]: unknown | undefined; }; } /** @@ -13818,7 +13878,7 @@ export interface SessionFsSqliteQueryResult { * For SELECT: array of row objects. For others: empty array. */ rows: { - [k: string]: JsonValue | undefined; + [k: string]: unknown | undefined; }[]; /** * Column names from the result set @@ -13876,7 +13936,7 @@ export interface SessionFsSqliteTransactionStatement { * Optional named bind parameters. */ params?: { - [k: string]: JsonValue | undefined; + [k: string]: unknown | undefined; }; } /** @@ -14243,7 +14303,7 @@ export interface SessionModelList { /** * Available models, ordered with the most preferred default first. Includes both Copilot (CAPI) models and any registry BYOK models; a BYOK model appears under its provider-qualified selection id (`provider/id`). */ - list: JsonValue[]; + list: unknown[]; /** * Cost categories for the full CAPI catalog, including picker-disabled models that Auto may select. Metadata only; entries absent from `list` are not manually selectable. */ @@ -14252,7 +14312,7 @@ export interface SessionModelList { * Per-quota snapshots returned alongside the model list, keyed by quota type. */ quotaSnapshots?: { - [k: string]: JsonValue | undefined; + [k: string]: unknown | undefined; }; } /** @@ -14313,7 +14373,9 @@ export interface SessionOpenOptions { * * @internal */ - expAssignments?: JsonValue; + expAssignments?: { + [k: string]: unknown | undefined; + }; /** * Opt-in: self-fetch and enforce enterprise managed settings at session bootstrap. */ @@ -14563,7 +14625,7 @@ export interface ShellInitScript { /** @experimental */ export interface SessionOpenOptionsAdditionalContentExclusionPolicy { rules: SessionOpenOptionsAdditionalContentExclusionPolicyRule[]; - last_updated_at: JsonValue; + last_updated_at: unknown; scope: SessionOpenOptionsAdditionalContentExclusionPolicyScope; } /** @@ -14712,7 +14774,9 @@ export interface SessionsOpenCloud { * * @internal */ - onTaskCreated?: JsonValue; + onTaskCreated?: { + [k: string]: unknown | undefined; + }; } /** * Parameters for fetching a remote session and handing it off to a new local session. @@ -14734,13 +14798,17 @@ export interface SessionsOpenHandoff { * * @internal */ - onProgress?: JsonValue; + onProgress?: { + [k: string]: unknown | undefined; + }; /** * In-process confirmation callback `(request) => boolean | Promise` invoked when the handoff needs the caller to confirm a non-fatal blocker (e.g. a repository mismatch between the current working directory and the remote session). Returning `true` proceeds with the handoff; returning `false` (or omitting the callback) aborts it. Marked internal because a function reference cannot cross the JSON-RPC boundary, for the same reasons as `onProgress`. * * @internal */ - onConfirm?: JsonValue; + onConfirm?: { + [k: string]: unknown | undefined; + }; } /** * Result of opening a session. @@ -14762,7 +14830,9 @@ export interface SessionOpenResult { * * @internal */ - sessionApi?: JsonValue; + sessionApi?: { + [k: string]: unknown | undefined; + }; /** * Startup prompts queued by user-level hook configs at session creation. Only populated when status is `created`; resumed sessions return an empty array. */ @@ -16701,7 +16771,7 @@ export interface Tool { * JSON Schema for the tool's input parameters */ parameters?: { - [k: string]: JsonValue | undefined; + [k: string]: unknown | undefined; }; /** * Optional instructions for how to use this tool effectively @@ -17134,13 +17204,17 @@ export interface UIEphemeralQueryRequest { * * @internal */ - onChunk?: JsonValue; + onChunk?: { + [k: string]: unknown | undefined; + }; /** * In-process `AbortSignal` forwarded to the model client to cancel an in-flight request. Marked internal: excluded from the public SDK surface. Replaced by an explicit cancellation token + cancel RPC in the SDK migration. * * @internal */ - abortSignal?: JsonValue; + abortSignal?: { + [k: string]: unknown | undefined; + }; } /** * Transient answer generated from current conversation context. @@ -17591,11 +17665,15 @@ export interface UserSettingMetadata { /** * The effective value: the user's value if set, otherwise the default. */ - value: JsonValue; + value: { + [k: string]: unknown | undefined; + }; /** * The centrally-known default for this setting (null when no default is registered). */ - default: JsonValue; + default: { + [k: string]: unknown | undefined; + }; /** * True when the user has not set an explicit value for this setting (i.e. it is left at its default). Reflects whether the user has overridden the key, not whether the effective value happens to equal the default — a key explicitly set to a value identical to the default still reports false. */ @@ -17627,7 +17705,9 @@ export interface UserSettingsSetRequest { /** * Partial user settings to write, as a free-form object keyed by setting name */ - settings: JsonValue; + settings: { + [k: string]: unknown | undefined; + }; } /** * Outcome of writing user settings. @@ -17856,7 +17936,9 @@ export interface WorkspacesEnsureRequest { /** * Opaque workspace context supplied by the session host. */ - context?: JsonValue; + context?: { + [k: string]: unknown | undefined; + }; } /** * Current workspace metadata for the session, including its absolute filesystem path when available. @@ -18047,7 +18129,9 @@ export interface WorkspacesUpdateMetadataRequest { /** * Opaque workspace context supplied by the session host. */ - context?: JsonValue; + context?: { + [k: string]: unknown | undefined; + }; /** * Optional workspace display name override. */ @@ -18107,7 +18191,7 @@ export interface SessionAgentListRequest { */ /** @experimental */ export interface SessionMcpAppsCallToolResult { - [k: string]: JsonValue | undefined; + [k: string]: unknown | undefined; } /** @experimental */ diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index e8d94c537a..193dc0defb 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -1,5 +1,3 @@ -export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; - /** * AUTO-GENERATED FILE - DO NOT EDIT * Generated from: session-events.schema.json @@ -646,10 +644,6 @@ export type ElicitationCompletedAction = | "decline" /** The user dismissed the request. */ | "cancel"; -/** - * Opaque JSON value submitted for one field in accepted `elicitation.completed` form content. - */ -export type ElicitationCompletedContent = JsonValue | undefined; /** * Reason the runtime is requesting host-provided MCP OAuth credentials */ @@ -690,10 +684,6 @@ export type McpHeadersRefreshCompletedOutcome = | "none" /** No response arrived within the bounded window. */ | "timeout"; -/** - * Source-defined JSON payload for the custom notification - */ -export type CustomNotificationPayload = JsonValue; /** * The user's auto-mode-switch choice */ @@ -3183,7 +3173,9 @@ export interface AttachmentExtensionContext { /** * Caller-supplied JSON payload */ - payload?: JsonValue; + payload?: { + [k: string]: unknown | undefined; + }; /** * Human-readable composer pill label */ @@ -3762,7 +3754,9 @@ export interface CitationReference { /** * Provider-native citation correlation data (e.g. Anthropic search_result_index / document_index), passed through opaquely for debugging and forward compatibility. */ - providerMetadata?: JsonValue; + providerMetadata?: { + [k: string]: unknown | undefined; + }; /** * Identifier of the CitationSource this reference points to (CitationSource.id). */ @@ -3831,9 +3825,9 @@ export interface AssistantMessageServerTools { functionCallNamespaces?: { [k: string]: string | undefined; }; - items?: JsonValue[]; + items?: unknown[]; provider: string; - rawContentBlocks?: JsonValue[]; + rawContentBlocks?: unknown[]; } /** * A tool invocation request from the assistant @@ -3842,7 +3836,9 @@ export interface AssistantMessageToolRequest { /** * Arguments to pass to the tool, format depends on the tool */ - arguments?: JsonValue; + arguments?: { + [k: string]: unknown | undefined; + }; /** * Resolved intention summary describing what this specific call does */ @@ -4545,7 +4541,9 @@ export interface ToolUserRequestedData { /** * Arguments for the tool invocation */ - arguments?: JsonValue; + arguments?: { + [k: string]: unknown | undefined; + }; /** * Unique identifier for this tool call */ @@ -4592,7 +4590,9 @@ export interface ToolExecutionStartData { /** * Arguments passed to the tool */ - arguments?: JsonValue; + arguments?: { + [k: string]: unknown | undefined; + }; /** * When true, the tool output should be displayed expanded (verbatim) in the CLI timeline */ @@ -4810,7 +4810,9 @@ export interface ToolExecutionCompleteData { * * @experimental */ - mcpMeta?: JsonValue; + mcpMeta?: { + [k: string]: unknown | undefined; + }; /** * Model identifier that generated this tool call */ @@ -4839,7 +4841,7 @@ export interface ToolExecutionCompleteData { * Tool-specific telemetry data (e.g., CodeQL check counts, grep match counts) */ toolTelemetry?: { - [k: string]: JsonValue | undefined; + [k: string]: unknown | undefined; }; /** * Identifier for the agent loop turn this tool was invoked in, matching the corresponding assistant.turn_start event @@ -4892,11 +4894,15 @@ export interface ToolExecutionCompleteResult { * * @experimental */ - mcpMeta?: JsonValue; + mcpMeta?: { + [k: string]: unknown | undefined; + }; /** * Structured content (arbitrary JSON) returned verbatim by the MCP tool */ - structuredContent?: JsonValue; + structuredContent?: { + [k: string]: unknown | undefined; + }; uiResource?: ToolExecutionCompleteUIResource; } /** @@ -4915,7 +4921,7 @@ export interface PersistedBinaryImage { * Optional metadata from the producing tool. */ metadata?: { - [k: string]: JsonValue | undefined; + [k: string]: unknown | undefined; }; /** * MIME type of the binary data @@ -4940,7 +4946,7 @@ export interface OmittedBinaryResult { * Optional metadata from the producing tool. */ metadata?: { - [k: string]: JsonValue | undefined; + [k: string]: unknown | undefined; }; /** * MIME type of the omitted binary data @@ -4970,7 +4976,7 @@ export interface BinaryAssetReference { * Optional metadata from the producing tool. */ metadata?: { - [k: string]: JsonValue | undefined; + [k: string]: unknown | undefined; }; /** * MIME type of the referenced binary data @@ -5731,7 +5737,9 @@ export interface HookStartData { /** * Input data passed to the hook */ - input?: JsonValue; + input?: { + [k: string]: unknown | undefined; + }; } /** * Session event "hook.end". Hook invocation completion details including output, success status, and error information @@ -5779,7 +5787,9 @@ export interface HookEndData { /** * Output data produced by the hook */ - output?: JsonValue; + output?: { + [k: string]: unknown | undefined; + }; /** * Whether the hook completed successfully */ @@ -5900,7 +5910,7 @@ export interface BinaryAssetData { * Optional metadata from the producing tool. */ metadata?: { - [k: string]: JsonValue | undefined; + [k: string]: unknown | undefined; }; /** * MIME type of the binary asset @@ -5969,7 +5979,7 @@ export interface SystemMessageMetadata { * Template variables used when constructing the prompt */ variables?: { - [k: string]: JsonValue | undefined; + [k: string]: unknown | undefined; }; } /** @@ -6154,7 +6164,9 @@ export interface SystemNotificationUnclassified { /** * Opaque metadata supplied by the external host, when present. */ - metadata?: JsonValue; + metadata?: { + [k: string]: unknown | undefined; + }; /** * Type discriminator. Always "unclassified". */ @@ -6207,7 +6219,9 @@ export interface PermissionRequestedData { /** * Neutral risk metadata supplied by the tool host. Consumers may display this value but must not use it to bypass the permission decision. */ - riskAssessment?: JsonValue; + riskAssessment?: { + [k: string]: unknown | undefined; + }; } /** * Shell command permission request @@ -6390,7 +6404,9 @@ export interface PermissionRequestMcp { /** * Arguments to pass to the MCP tool */ - args?: JsonValue; + args?: { + [k: string]: unknown | undefined; + }; /** * Permission kind discriminator */ @@ -6491,7 +6507,9 @@ export interface PermissionRequestCustomTool { /** * Arguments to pass to the custom tool */ - args?: JsonValue; + args?: { + [k: string]: unknown | undefined; + }; /** * Permission kind discriminator */ @@ -6524,7 +6542,9 @@ export interface PermissionRequestHook { /** * Arguments of the tool call being gated */ - toolArgs?: JsonValue; + toolArgs?: { + [k: string]: unknown | undefined; + }; /** * Tool call ID that triggered this permission request */ @@ -6716,7 +6736,9 @@ export interface PermissionPromptRequestMcp { /** * Arguments to pass to the MCP tool */ - args?: JsonValue; + args?: { + [k: string]: unknown | undefined; + }; /** * Auto-approval judge information for this request; present only when auto mode is enabled. * @@ -6831,7 +6853,9 @@ export interface PermissionPromptRequestCustomTool { /** * Arguments to pass to the custom tool */ - args?: JsonValue; + args?: { + [k: string]: unknown | undefined; + }; /** * Auto-approval judge information for this request; present only when auto mode is enabled. * @@ -6900,7 +6924,9 @@ export interface PermissionPromptRequestHook { /** * Arguments of the tool call being gated */ - toolArgs?: JsonValue; + toolArgs?: { + [k: string]: unknown | undefined; + }; /** * Tool call ID that triggered this permission request */ @@ -7403,7 +7429,7 @@ export interface ElicitationRequestedSchema { * Form field definitions, keyed by field name */ properties: { - [k: string]: JsonValue | undefined; + [k: string]: unknown | undefined; }; /** * List of required field names @@ -7460,6 +7486,12 @@ export interface ElicitationCompletedData { */ requestId: string; } +/** + * Opaque JSON value submitted for one field in accepted `elicitation.completed` form content. + */ +export interface ElicitationCompletedContent { + [k: string]: unknown | undefined; +} /** * Session event "sampling.requested". Sampling request from an MCP server; contains the server name and a requestId for correlation */ @@ -7497,7 +7529,9 @@ export interface SamplingRequestedData { /** * The JSON-RPC request ID from the MCP protocol */ - mcpRequestId: JsonValue; + mcpRequestId: { + [k: string]: unknown | undefined; + }; /** * Unique identifier for this sampling request; used to respond via session.respondToSampling() */ @@ -7846,6 +7880,12 @@ export interface CustomNotificationData { */ version?: number; } +/** + * Source-defined JSON payload for the custom notification + */ +export interface CustomNotificationPayload { + [k: string]: unknown | undefined; +} /** * Optional source-defined string identifiers describing the payload subject */ @@ -7889,7 +7929,9 @@ export interface ExternalToolRequestedData { /** * Arguments to pass to the external tool */ - arguments?: JsonValue; + arguments?: { + [k: string]: unknown | undefined; + }; /** * Unique identifier for this request; used to respond via session.respondToExternalTool() */ @@ -8438,7 +8480,9 @@ export interface ManagedSettingsResolvedData { /** * The effective (resolved) managed settings values, so clients can render exactly what is enforced. Absent when no managed policy is in force. */ - settings?: JsonValue; + settings?: { + [k: string]: unknown | undefined; + }; source: ManagedSettingsResolvedSource; } /** @@ -9284,7 +9328,9 @@ export interface CanvasOpenedData { /** * Input supplied when the instance was opened */ - input?: JsonValue; + input?: { + [k: string]: unknown | undefined; + }; /** * Stable caller-supplied canvas instance identifier */ @@ -9379,7 +9425,9 @@ export interface CanvasRegistryChangedCanvas { /** * JSON Schema for canvas open input */ - inputSchema?: JsonValue; + inputSchema?: { + [k: string]: unknown | undefined; + }; } /** * A single action within a canvas declaration, with its name, optional description, and optional input schema. @@ -9393,7 +9441,9 @@ export interface CanvasRegistryChangedCanvasAction { /** * JSON Schema for action input */ - inputSchema?: JsonValue; + inputSchema?: { + [k: string]: unknown | undefined; + }; /** * Action name */ @@ -9544,7 +9594,9 @@ export interface CanvasRecordedData { /** * Input supplied when the instance was opened */ - input?: JsonValue; + input?: { + [k: string]: unknown | undefined; + }; /** * Stable caller-supplied canvas instance identifier */ @@ -9680,7 +9732,7 @@ export interface McpAppToolCallCompleteData { * Arguments passed to the tool by the app view, if any */ arguments?: { - [k: string]: JsonValue | undefined; + [k: string]: unknown | undefined; }; /** * Wall-clock duration of the underlying tools/call in milliseconds @@ -9691,7 +9743,7 @@ export interface McpAppToolCallCompleteData { * Standard MCP CallToolResult returned by the server. Present whether or not the call set isError. */ result?: { - [k: string]: JsonValue | undefined; + [k: string]: unknown | undefined; }; /** * Name of the MCP server hosting the tool diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 60cf507d7a..1e2acd6cc9 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -16,6 +16,10 @@ import type { CurrentToolMetadata, McpOauthPendingRequestResponse, FactoryLogLine, + FactoryRunRequest, + FactoryExecuteResult, + FactoryJournalPutRequest, + FactoryRunResult as WireFactoryRunResult, } from "./generated/rpc.js"; import { type Canvas, CanvasError } from "./canvas.js"; import type { OpenCanvasInstance } from "./generated/rpc.js"; @@ -284,6 +288,20 @@ class FactoryProgressBuffer { } } +/** + * Reconcile the generated envelope with the public one. + * + * The two are identical at runtime. They differ only in how `result` is typed: + * the runtime returns any JSON value, but the schema models the field as an + * opaque node, which the generator renders as an object. {@link FactoryRunResult} + * corrects that for the factory surface without changing `x-opaque-json` + * handling for any other consumer, so the boundary needs a cast rather than a + * conversion. + */ +function toPublicFactoryRunResult(envelope: WireFactoryRunResult): FactoryRunResult { + return envelope as FactoryRunResult; +} + async function awaitFactoryOperation( operation: () => Promise, signal: AbortSignal @@ -429,13 +447,15 @@ export class CopilotSession { } const envelope = await this.rpc.factory.run({ name, - args: options?.args === undefined ? {} : options.args, + args: (options?.args === undefined + ? {} + : options.args) as FactoryRunRequest["args"], options: { limits: options?.limits, }, }); - return envelope; + return toPublicFactoryRunResult(envelope); }) as SessionFactoryApi["run"], resume: (async (runId: string, options?: Parameters[1]) => { let response; @@ -457,15 +477,15 @@ export class CopilotSession { } throw error; } - return response.run; + return toPublicFactoryRunResult(response.run); }) as SessionFactoryApi["resume"], - getRun: (runId) => this.rpc.factory.getRun({ runId }), + getRun: async (runId) => toPublicFactoryRunResult(await this.rpc.factory.getRun({ runId })), waitForRun: (runId, options) => this.waitForFactoryRun(runId, options?.signal), listRuns: async () => (await this.rpc.factory.listRuns()).runs, getRunDetail: (runId) => this.rpc.factory.getRunDetail({ runId }), getRunProgress: (runId, options = {}) => this.rpc.factory.getRunProgress({ runId, ...options }), - cancel: (runId) => this.rpc.factory.cancel({ runId }), + cancel: async (runId) => toPublicFactoryRunResult(await this.rpc.factory.cancel({ runId })), }; /** @@ -522,7 +542,7 @@ export class CopilotSession { rereadRequested = false; const envelope = await this.rpc.factory.getRun({ runId }); if (isFactoryRunTerminal(envelope.status)) { - finish(() => resolve(envelope)); + finish(() => resolve(toPublicFactoryRunResult(envelope))); return; } } while (rereadRequested && !settled); @@ -1330,7 +1350,7 @@ export class CopilotSession { try { const context: FactoryContext = { runId: params.runId, - args: params.args, + args: params.args as JsonValue, session: self, signal: controller.signal, phase: (title: string) => { @@ -1403,7 +1423,8 @@ export class CopilotSession { runId: params.runId, executionToken: params.executionToken, key, - resultJson: result, + resultJson: + result as FactoryJournalPutRequest["resultJson"], }), controller.signal ); @@ -1420,7 +1441,7 @@ export class CopilotSession { return {}; } assertFactoryResult(result); - return { result }; + return { result } as FactoryExecuteResult; } finally { try { await progress.close(); diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index bc41487b2f..fd537a1c91 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -18,12 +18,12 @@ import type { import type { CopilotSession } from "./session.js"; import type { GitHubTelemetryNotification, - JsonValue, ModelBillingTokenPrices, OpenCanvasInstance, RemoteSessionMode, CurrentToolMetadata, } from "./generated/rpc.js"; +import type { JsonValue } from "./factory.js"; import type { ToolSet } from "./toolSet.js"; export type { RemoteSessionMode } from "./generated/rpc.js"; export type { CurrentToolMetadata } from "./generated/rpc.js"; diff --git a/nodejs/test/typescript-codegen.test.ts b/nodejs/test/typescript-codegen.test.ts index 9745428097..248b60968b 100644 --- a/nodejs/test/typescript-codegen.test.ts +++ b/nodejs/test/typescript-codegen.test.ts @@ -2,10 +2,7 @@ import type { JSONSchema7 } from "json-schema"; import { compile } from "json-schema-to-typescript"; import { describe, expect, it } from "vitest"; -import { - normalizeSchemaForTypeScript, - TYPESCRIPT_JSON_VALUE_DECLARATION, -} from "../../scripts/codegen/typescript.ts"; +import { normalizeSchemaForTypeScript } from "../../scripts/codegen/typescript.ts"; describe("typescript schema codegen", () => { it("emits JSDoc comments for described enum values", async () => { @@ -46,37 +43,4 @@ describe("typescript schema codegen", () => { ); expect(code).toContain('inlineMode: /** Use a direct value. */ "direct" | "indirect";'); }); - - it("maps opaque JSON fields to the recursive JsonValue type", async () => { - const schema: JSONSchema7 = { - title: "OpaqueContainer", - type: "object", - additionalProperties: false, - properties: { - payload: { - "x-opaque-json": true, - }, - }, - required: ["payload"], - }; - - const normalized = normalizeSchemaForTypeScript(schema); - expect(normalized.properties?.payload).toEqual({ - tsType: "JsonValue", - }); - - const code = await compile(normalized, "OpaqueContainer", { - bannerComment: "", - style: { semi: true, singleQuote: false }, - additionalProperties: false, - }); - - expect(`${TYPESCRIPT_JSON_VALUE_DECLARATION}\n\n${code.trim()}`).toBe( - `export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; - -export interface OpaqueContainer { - payload: JsonValue; -}` - ); - }); }); diff --git a/scripts/codegen/typescript.ts b/scripts/codegen/typescript.ts index db9e05dea6..f82e67abe6 100644 --- a/scripts/codegen/typescript.ts +++ b/scripts/codegen/typescript.ts @@ -38,10 +38,9 @@ import { isVoidSchema, isSchemaExperimental, isSchemaInternal, - isOpaqueJson, - stripOpaqueJsonMarker, appendPropertyMarkerTagsToDescriptions, getEnumValueDescriptions, + stripOpaqueJsonMarker, loadSchemaJson, fixBrandCasing, type ApiSchema, @@ -53,8 +52,6 @@ const TS_EXPERIMENTAL_JSDOC = "/** @experimental */"; const EXTERNAL_SCHEMA_TS_IMPORT: Record = { "session-events.schema.json": "./session-events.js", }; -export const TYPESCRIPT_JSON_VALUE_DECLARATION = - "export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };"; function tsExperimentalJSDoc(indent = ""): string { return `${indent}${TS_EXPERIMENTAL_JSDOC}`; @@ -255,30 +252,6 @@ function collectRpcMethods(node: Record): RpcMethod[] { return results; } -/** - * JSON Schema keywords that describe real structure. A node carrying any of - * these is not an unconstrained value, even when it is also marked - * `x-opaque-json`. - */ -const STRUCTURAL_SCHEMA_KEYWORDS = [ - "anyOf", - "oneOf", - "allOf", - "not", - "type", - "properties", - "patternProperties", - "items", - "prefixItems", - "enum", - "const", - "$ref", -] as const; - -function hasStructuralConstraints(schema: Record): boolean { - return STRUCTURAL_SCHEMA_KEYWORDS.some((keyword) => schema[keyword] !== undefined); -} - export function normalizeSchemaForTypeScript(schema: JSONSchema7): JSONSchema7 { const root = structuredClone(schema) as JSONSchema7 & { definitions?: Record; @@ -313,25 +286,11 @@ export function normalizeSchemaForTypeScript(schema: JSONSchema7): JSONSchema7 { Object.entries(value as Record).map(([key, child]) => [key, rewrite(child)]) ) as Record; - // Only a genuinely unconstrained opaque node becomes `JsonValue`. Many - // schemas carry `x-opaque-json` alongside real structure (an `anyOf` - // union, or `type`/`properties`) so that codegens which model opaque - // JSON natively can do so without discarding that structure; collapsing - // those to `JsonValue` would silently erase public contracts such as - // `McpServerConfig` or `ExternalToolResult`. For those, drop the marker - // and let json-schema-to-typescript emit the real shape, exactly as - // this codegen did before opaque JSON was representable. - if (isOpaqueJson(rewritten as JSONSchema7) && !hasStructuralConstraints(rewritten)) { - // Keep the node's documentation metadata — replacing it wholesale - // would drop `description` and the stability annotations, so the - // generated declaration would lose its JSDoc while its siblings - // keep theirs. - stripOpaqueJsonMarker(rewritten); - rewritten.tsType = "JsonValue"; - delete rewritten.type; - delete rewritten.additionalProperties; - return rewritten; - } + // The TypeScript codegen doesn't distinguish opaque JSON from any + // other unconstrained value, so drop the marker before feeding the + // schema to json-schema-to-typescript. C# codegen reads the marker + // from its own (un-normalized) view of the schema and emits + // `JsonElement` instead. stripOpaqueJsonMarker(rewritten); const enumValueDescriptions = getEnumValueDescriptions(rewritten as JSONSchema7); @@ -435,7 +394,7 @@ async function generateSessionEvents(schemaPath?: string): Promise { strictIndexSignatures: true, }); - let annotatedTs = `${TYPESCRIPT_JSON_VALUE_DECLARATION}\n\n${annotateTypeScriptTypes(ts, experimentalDefinitionNames(definitionCollections), TS_EXPERIMENTAL_JSDOC)}`; + let annotatedTs = annotateTypeScriptTypes(ts, experimentalDefinitionNames(definitionCollections), TS_EXPERIMENTAL_JSDOC); // Add @internal JSDoc annotations for session-event types marked // `visibility: "internal"` in the schema. The tag drives `stripInternal` // so the whole type is dropped from the published .d.ts. @@ -587,8 +546,6 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; if (externalSchemaRefs.size > 0) { lines.push(""); } - lines.push(TYPESCRIPT_JSON_VALUE_DECLARATION); - lines.push(""); const allMethods = [...collectRpcMethods(schema.server || {}), ...collectRpcMethods(schema.session || {})]; const clientSessionMethods = collectRpcMethods(schema.clientSession || {}); From 168a6a832d25dbbf0388fe87d3178db540d058d8 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Thu, 30 Jul 2026 08:25:07 -0700 Subject: [PATCH 09/13] Point the factory result override at its removal issue The `FactoryRunResult` override and its boundary cast are scaffolding for a schema limitation, not a permanent part of the surface. Name the tracking issue in both docblocks so whoever regenerates after the schema change knows exactly what to delete. --- nodejs/src/factory.ts | 6 ++++++ nodejs/src/session.ts | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/nodejs/src/factory.ts b/nodejs/src/factory.ts index aadad541ff..687bf3bd02 100644 --- a/nodejs/src/factory.ts +++ b/nodejs/src/factory.ts @@ -23,6 +23,12 @@ import type { FactoryLimits, FactoryMeta } from "./types.js"; * array — but the schema models the field as an opaque node, which the * generator renders as an object. Narrowing the correction to this surface * keeps the `x-opaque-json` handling unchanged for every other consumer. + * + * This override is temporary. Once the schema distinguishes an opaque JSON + * value from an opaque in-process value and that ships in a CLI release, + * regenerating produces the right type directly, and this declaration, the + * `toPublicFactoryRunResult` boundary helper, and the casts around it should + * all be deleted. Tracked by github/copilot-agent-runtime#14122. */ export type FactoryRunResult = Omit & { /** Completed factory result. */ diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 1e2acd6cc9..a53121559d 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -297,6 +297,10 @@ class FactoryProgressBuffer { * corrects that for the factory surface without changing `x-opaque-json` * handling for any other consumer, so the boundary needs a cast rather than a * conversion. + * + * Delete this along with the {@link FactoryRunResult} override once the schema + * distinguishes opaque JSON values from opaque in-process values — + * github/copilot-agent-runtime#14122. */ function toPublicFactoryRunResult(envelope: WireFactoryRunResult): FactoryRunResult { return envelope as FactoryRunResult; From 3d661c25e83ae5d7b701a924713a04a90341f2f7 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Thu, 30 Jul 2026 08:47:49 -0700 Subject: [PATCH 10/13] CCR 3 Type the handle's metadata as deeply readonly. `defineFactory` deep-freezes the snapshot it stores, but `FactoryHandle.meta` was typed as a mutable `FactoryMeta`, so `handle.meta.name = "..."` and `handle.meta.phases.push(...)` compiled and then threw at runtime. The test asserts both halves: the mutation is a type error and it also throws. --- nodejs/src/factory.ts | 17 +++++++++++++++-- nodejs/test/factory.test.ts | 12 ++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/nodejs/src/factory.ts b/nodejs/src/factory.ts index 687bf3bd02..32eb170589 100644 --- a/nodejs/src/factory.ts +++ b/nodejs/src/factory.ts @@ -194,11 +194,24 @@ export interface FactoryDefinition< * @experimental Part of the experimental Agent Factories surface and may * change or be removed in future SDK or CLI releases. */ +/** + * A deeply immutable view of a value. + * + * `defineFactory` deep-freezes the metadata it stores, so the handle's view of + * it has to be readonly all the way down or `handle.meta.name = "..."` and + * `handle.meta.phases.push(...)` would compile and then throw at runtime. + */ +type DeepReadonly = T extends (infer U)[] + ? readonly DeepReadonly[] + : T extends object + ? { readonly [K in keyof T]: DeepReadonly } + : T; + export interface FactoryHandle< TArgs extends JsonValue = JsonValue, TResult extends JsonValue | void = JsonValue | void, > { - readonly meta: FactoryMeta; + readonly meta: DeepReadonly; readonly [factoryHandleBrand]: { readonly args: TArgs; readonly result: TResult; @@ -422,7 +435,7 @@ export function defineFactory< meta, run: definition.run, }; - const handle = Object.freeze({ meta }) as FactoryHandle; + const handle = Object.freeze({ meta }) as unknown as FactoryHandle; factoryHandles.set(handle, stored); return handle; diff --git a/nodejs/test/factory.test.ts b/nodejs/test/factory.test.ts index f883f480b8..0282cc4a58 100644 --- a/nodejs/test/factory.test.ts +++ b/nodejs/test/factory.test.ts @@ -69,6 +69,18 @@ describe("factories", () => { meta.name = "no-limits"; meta.phases.length = 0; + // The stored metadata is deep-frozen, so the handle's view of it must be + // readonly all the way down. Assert both halves: the mutation is a type + // error, and it also throws at runtime. + expect(() => { + // @ts-expect-error handle.meta is deeply readonly. + handle.meta.name = "mutated"; + }).toThrow(TypeError); + expect(() => { + // @ts-expect-error handle.meta.phases is a readonly array. + handle.meta.phases.push({ title: "late" }); + }).toThrow(TypeError); + const session = new CopilotSession("session-1", {} as never); session.registerFactories([handle]); const result = await session.clientSessionApis.factory!.execute({ From 566b39e7743eb1fb3e757fc929b6a78ecf1c1fba Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Thu, 30 Jul 2026 09:01:44 -0700 Subject: [PATCH 11/13] CCR 4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correct the public contracts for `parallel` and `pipeline`. Both said a thrown thunk or stage becomes `null`, but cancellation and hard runtime failures (`ResponseError`, `ConnectionError`) propagate and reject the whole call — an author reading only the old wording would omit run-level error handling. Fixed in the JSDoc and in the guide. Restore the `@experimental` marker on `FactoryHandle`: the `DeepReadonly` alias added in the previous commit landed between the docblock and the interface, so the warning attached to the internal alias instead of the exported type. Describe `FactoryRunResult` as the run envelope rather than a terminal one, since `getRun` returns it for pending and running runs too, and give it the `@experimental` marker the rest of the surface carries. --- nodejs/docs/factories.md | 4 ++-- nodejs/src/factory.ts | 39 ++++++++++++++++++++++++++++----------- 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/nodejs/docs/factories.md b/nodejs/docs/factories.md index c40e51de88..0c1f0f09a0 100644 --- a/nodejs/docs/factories.md +++ b/nodejs/docs/factories.md @@ -54,8 +54,8 @@ The `run()` context provides: - `ctx.runId`: Stable ID reused across resumed attempts. - `ctx.args`: Invocation arguments, forwarded verbatim. When the caller omits `args`, this is `{}` rather than `undefined`. - `ctx.agent(prompt, options?)`: Runs one factory-owned subagent. Options are exactly `label`, `schema`, and `model`. See [Subagent calls](#subagent-calls). -- `ctx.parallel(thunks)`: Runs thunks concurrently and awaits all of them (a barrier). A thunk that throws becomes `null` in the result array, except cooperative cancellation propagates. Rejects above 4096 items. -- `ctx.pipeline(items, ...stages)`: Flows each item through every stage without a barrier between stages, so one item can be in a later stage while another is still in an earlier one. Each stage is called as `(previous, item, index)`, where `previous` is the prior stage's result and `item` is the original input. A stage that throws drops that item to `null` and skips its remaining stages. Rejects above 4096 items. +- `ctx.parallel(thunks)`: Runs thunks concurrently and awaits all of them (a barrier). A thunk that throws becomes `null` in the result array, so one failed item does not lose the rest. Cancellation and hard runtime failures (`ResponseError`, `ConnectionError`) are the exception — those propagate and reject the whole call, because they mean the run itself is in trouble rather than one item having failed. Handle them at run level; do not assume every failure arrives as a `null`. Rejects above 4096 items. +- `ctx.pipeline(items, ...stages)`: Flows each item through every stage without a barrier between stages, so one item can be in a later stage while another is still in an earlier one. Each stage is called as `(previous, item, index)`, where `previous` is the prior stage's result and `item` is the original input. A stage that throws drops that item to `null` and skips its remaining stages, with the same exception for cancellation and hard runtime failures. Rejects above 4096 items. - `ctx.phase(title)`: Starts a named progress phase. This sets a single run-global value, so calling it from inside concurrent `parallel`/`pipeline` stages races. Call it at run-level transitions and distinguish concurrent work by `label` instead. - `ctx.log(message)`: Appends a progress line. When a factory bounds its own coverage (top-N, sampling), log what was dropped. - `ctx.step(key, producer, options?)`: Journals the producer's JSON result under a stable key so a resume replays it without re-running the producer. A journaled (default) producer must return a JSON-serializable value; `undefined` or a non-JSON value is rejected. Pass `{ volatile: true }` to bypass the journal and run the producer every time. diff --git a/nodejs/src/factory.ts b/nodejs/src/factory.ts index 32eb170589..53c0aeca82 100644 --- a/nodejs/src/factory.ts +++ b/nodejs/src/factory.ts @@ -14,9 +14,9 @@ import type { CopilotSession } from "./session.js"; import type { FactoryLimits, FactoryMeta } from "./types.js"; /** - * The terminal envelope describing a factory run's outcome (status, result, - * reason). Re-exported so consumers can name the type returned by - * {@link SessionFactoryApi} methods. + * The envelope describing a factory run: its identity, status, and — once it + * has completed — its result. `getRun` returns this for an in-flight run too, + * so `status` may be `pending` or `running` and the outcome fields absent. * * `result` is re-typed here rather than taken from the generated wire type. The * runtime returns any JSON value — including `null`, a string, a number, or an @@ -29,6 +29,9 @@ import type { FactoryLimits, FactoryMeta } from "./types.js"; * regenerating produces the right type directly, and this declaration, the * `toPublicFactoryRunResult` boundary helper, and the casts around it should * all be deleted. Tracked by github/copilot-agent-runtime#14122. + * + * @experimental Part of the experimental Agent Factories surface and may + * change or be removed in future SDK or CLI releases. */ export type FactoryRunResult = Omit & { /** Completed factory result. */ @@ -154,11 +157,25 @@ export interface FactoryContext { producer: () => Promise | JsonValue, options?: FactoryStepOptions ): Promise; - /** Run thunks concurrently, returning null for a thunk that throws. */ + /** + * Run thunks concurrently and await all of them. + * + * A thunk that throws becomes `null` in the result array, so one failed + * item does not lose the rest. Cancellation and hard runtime failures + * (`ResponseError`, `ConnectionError`) are the exception: those propagate + * and reject the whole call, because they mean the run itself is in + * trouble rather than one item having failed. + */ parallel( thunks: Array<() => Promise | TResult> ): Promise>; - /** Run each item through every stage without barriers between stages. */ + /** + * Run each item through every stage without barriers between stages. + * + * A stage that throws drops that item to `null` and skips its remaining + * stages. As with {@link FactoryContext.parallel}, cancellation and hard + * runtime failures propagate instead of being recorded per item. + */ pipeline(items: unknown[], ...stages: FactoryPipelineStage[]): Promise; /** Start a named factory progress phase. */ phase(title: string): void; @@ -188,12 +205,6 @@ export interface FactoryDefinition< run(context: FactoryContext): Promise; } -/** - * Opaque reusable reference to a defined factory. - * - * @experimental Part of the experimental Agent Factories surface and may - * change or be removed in future SDK or CLI releases. - */ /** * A deeply immutable view of a value. * @@ -207,6 +218,12 @@ type DeepReadonly = T extends (infer U)[] ? { readonly [K in keyof T]: DeepReadonly } : T; +/** + * Opaque reusable reference to a defined factory. + * + * @experimental Part of the experimental Agent Factories surface and may + * change or be removed in future SDK or CLI releases. + */ export interface FactoryHandle< TArgs extends JsonValue = JsonValue, TResult extends JsonValue | void = JsonValue | void, From 85a443e32e93382e380861391b63d8248f3a4292 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Thu, 30 Jul 2026 09:19:24 -0700 Subject: [PATCH 12/13] CCR 5 Stop deep-cloning values through JSON on the way to the wire. The hooks-invoke response and the tool-result path were round-tripped through `JSON.parse(JSON.stringify(...))`, added only to satisfy the `JsonValue` tightening. That is a behaviour change for a typing reason: the round trip throws on a BigInt or a cycle, and silently rewrites `NaN`/`Infinity` to `null` and drops `undefined` members. Now that the generated types are back to their previous shapes, the `ToolTelemetry` and hooks-invoke signatures revert to `unknown` as well, both call sites return the original value as they did before, and the imports the round trips required are gone. `client.ts` and `types.ts` now carry only factory additions. --- nodejs/src/client.ts | 5 ++--- nodejs/src/session.ts | 2 +- nodejs/src/types.ts | 3 +-- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 2e13cd79bd..6d99ce49e3 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -38,7 +38,6 @@ import type { OpenCanvasInstance, SessionUpdateOptionsParams, } from "./generated/rpc.js"; -import type { JsonValue } from "./factory.js"; import { getSdkProtocolVersion } from "./sdkProtocolVersion.js"; import { CopilotSession } from "./session.js"; import type { FfiRuntimeHost } from "./ffiRuntimeHost.js"; @@ -3002,7 +3001,7 @@ export class CopilotClient { sessionId: string; hookType: string; input: unknown; - }): Promise<{ output?: JsonValue }> { + }): Promise<{ output?: unknown }> { if ( !params || typeof params.sessionId !== "string" || @@ -3017,7 +3016,7 @@ export class CopilotClient { } const output = await session._handleHooksInvoke(params.hookType, params.input); - return output === undefined ? {} : { output: JSON.parse(JSON.stringify(output)) }; + return { output }; } private async handleSystemMessageTransform(params: { diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index a53121559d..017fe7bb1c 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -1072,7 +1072,7 @@ export class CopilotSession { } else if (typeof rawResult === "string") { result = rawResult; } else if (isToolResultObject(rawResult)) { - result = JSON.parse(JSON.stringify(rawResult)); + result = rawResult; } else { result = JSON.stringify(rawResult); } diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index fd537a1c91..31a1afed88 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -23,7 +23,6 @@ import type { RemoteSessionMode, CurrentToolMetadata, } from "./generated/rpc.js"; -import type { JsonValue } from "./factory.js"; import type { ToolSet } from "./toolSet.js"; export type { RemoteSessionMode } from "./generated/rpc.js"; export type { CurrentToolMetadata } from "./generated/rpc.js"; @@ -447,7 +446,7 @@ export type ToolBinaryResult = { description?: string; }; -export type ToolTelemetry = Record; +export type ToolTelemetry = Record | undefined>; export type ToolResultObject = { textResultForLlm: string; From b88655193454fc85ed38e2e95175e2b015ff878b Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Thu, 30 Jul 2026 09:31:01 -0700 Subject: [PATCH 13/13] CCR 6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Install the abort listener before dispatching a factory operation. `awaitFactoryOperation` evaluated `operation()` as the first element of the race, so the listener was only attached afterwards. The window is synchronous and hard to hit in practice, but it does not need to exist: an abort raised while the thunk is starting would find no listener and the race would then wait on an operation whose run had already been cancelled. The listener is now attached first, and the aborted check moved after it, so the ordering guarantees both properties directly — no abort can be missed, and an already-aborted run still never dispatches. --- nodejs/src/session.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 017fe7bb1c..8b946c7860 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -312,18 +312,19 @@ async function awaitFactoryOperation( ): Promise { // The operation is a thunk so an already-aborted run never dispatches the // RPC at all, rather than sending it and rejecting locally afterwards. - throwIfFactoryAborted(signal); let rejectAbort: ((reason?: unknown) => void) | undefined; + const abortPromise = new Promise((_resolve, reject) => { + rejectAbort = reject; + }); const onAbort = () => rejectAbort?.(signal.reason ?? new DOMException("Factory run was aborted", "AbortError")); + // Register before the abort check and before dispatching, so an abort can + // neither be missed by a not-yet-attached listener nor start work on an + // already-cancelled run. + signal.addEventListener("abort", onAbort, { once: true }); try { - return await Promise.race([ - operation(), - new Promise((_resolve, reject) => { - rejectAbort = reject; - signal.addEventListener("abort", onAbort, { once: true }); - }), - ]); + throwIfFactoryAborted(signal); + return await Promise.race([operation(), abortPromise]); } finally { signal.removeEventListener("abort", onAbort); }