diff --git a/nodejs/docs/factories.md b/nodejs/docs/factories.md index 0c1f0f09a..a22767905 100644 --- a/nodejs/docs/factories.md +++ b/nodejs/docs/factories.md @@ -53,7 +53,7 @@ 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.agent(prompt, options?)`: Runs one factory-owned subagent. Options are exactly `label`, `schema`, `model`, `agent`, `reasoningEffort`, and `contextTier`. 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, 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. @@ -61,7 +61,7 @@ The `run()` context provides: - `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.session`: The session returned by `joinSession`. It refuses calls that start or resume a factory run. Call `extensions_manage` with `operation: "guide"` to read more about the session APIs. - `ctx.signal`: Cooperative cancellation signal for extension work and subprocesses. - `ctx.factory(...)`: Always rejects because nested factories are not supported. @@ -156,7 +156,7 @@ session.factory.resume( ): 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`. +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`. SDK-initiated `run` and `resume` do not request permission, so they have no declined outcome. The model's `run_factory` tool requests permission before the durable row exists; declining it creates no run row. An SDK-initiated run is refused only when the session already has its maximum number of active top-level runs. Pre-execution resume failures throw `FactoryResumeError`, whose `code` is one of `not_found`, `non_resumable`, `already_active`, `factory_already_running`, `factory_limits_invalid`, `factory_session_disposed`, `factory_storage_unavailable`, or `factory_storage_corrupt`. 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. @@ -210,7 +210,7 @@ const page = await session.factory.getRunProgress(runId, { }); ``` -- `listRuns()` returns summaries in durable creation order. +- `listRuns()` returns the newest default page of this session's durable factory runs. - `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. diff --git a/nodejs/src/factory.ts b/nodejs/src/factory.ts index 53c0aeca8..8ad1c7acb 100644 --- a/nodejs/src/factory.ts +++ b/nodejs/src/factory.ts @@ -6,38 +6,15 @@ import type { FactoryGetRunProgressRequest, FactoryProgressPage, FactoryRunDetail, - FactoryRunResult as WireFactoryRunResult, + FactoryRunResult, FactoryRunStatus, FactoryRunSummary, } from "./generated/rpc.js"; +import type { ContextTier } from "./generated/session-events.js"; import type { CopilotSession } from "./session.js"; import type { FactoryLimits, FactoryMeta } from "./types.js"; -/** - * 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 - * 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. - * - * @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. */ - result?: JsonValue; -}; - +export type { FactoryRunResult }; export type { FactoryAgentSummary, FactoryPhaseStatus, @@ -115,8 +92,20 @@ export interface FactoryAgentOptions { label?: string; schema?: FactoryJsonSchema; model?: string; + reasoningEffort?: string; + contextTier?: ContextTier; + agent?: string; } +export const FACTORY_AGENT_OPTION_KEYS = [ + "label", + "schema", + "model", + "reasoningEffort", + "contextTier", + "agent", +] as const; + /** * Options for a durable factory step. * @@ -185,7 +174,10 @@ export interface FactoryContext { factory(name: string, args?: JsonValue): Promise; /** Caller-supplied input, forwarded verbatim. */ args: TArgs; - /** The same full session instance returned by `joinSession`. */ + /** + * The session instance returned by `joinSession`. It refuses calls that + * start or resume a factory run. + */ session: CopilotSession; /** Cooperative cancellation signal for the current factory run. */ signal: AbortSignal; @@ -275,8 +267,11 @@ export type FactoryResumeErrorCode = | "not_found" | "non_resumable" | "already_active" - | "reapproval_declined" - | "no_approval_provider"; + | "factory_already_running" + | "factory_limits_invalid" + | "factory_session_disposed" + | "factory_storage_unavailable" + | "factory_storage_corrupt"; /** * Friendly factory API exposed on a session. @@ -290,9 +285,12 @@ export interface SessionFactoryApi { * * 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. + * completed. SDK-initiated runs do not request permission, so they have no + * declined outcome. The model's `run_factory` tool requests permission + * before a durable row exists; declining it creates no run row. Failures + * that occur before a run exists (such as an unknown factory or attempting + * to start a run while the session is at its active top-level run limit) + * still reject. */ run(name: string, options?: RunOptions): Promise; run( @@ -302,9 +300,9 @@ export interface SessionFactoryApi { /** * 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}. + * Resolves with the run envelope like {@link SessionFactoryApi.run}. + * SDK-initiated resumes do not request permission. A pre-execution failure + * with a documented resume code rejects with {@link FactoryResumeError}. */ resume(runId: string, options?: ResumeOptions): Promise; /** Read the latest durable envelope for a factory run. */ @@ -324,7 +322,9 @@ export interface SessionFactoryApi { * {@link SessionFactoryApi.cancel} to actually stop it. */ waitForRun(runId: string, options?: { signal?: AbortSignal }): Promise; - /** List this session's durable factory runs in creation order. */ + /** + * List the newest default page of this session's durable factory runs. + */ listRuns(): Promise; /** Read durable phases, direct agents, and the latest progress tail for a run. */ getRunDetail(runId: string): Promise; diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 042a7d0bb..cefc8ef4d 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -7,6 +7,16 @@ 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"; +/** A value that can be represented losslessly on the SDK JSON wire. */ +export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; + +/** + * A value that lives only in this process and never crosses the JSON-RPC + * boundary, such as a callback or a host object handle. + * @internal + */ +export type OpaqueInProcessValue = unknown; + /** * Initial authentication info for the session. * @@ -259,6 +269,22 @@ 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 * @@ -3605,7 +3631,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. @@ -4009,16 +4035,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. * @@ -4038,19 +4054,7 @@ export interface CanvasActionInvokeRequest { /** * 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. @@ -4195,9 +4199,7 @@ export interface OpenCanvasInstance { /** * Input supplied when the instance was opened */ - input?: { - [k: string]: unknown | undefined; - }; + input?: JsonValue; } /** * Canvas open parameters. @@ -4222,9 +4224,7 @@ export interface CanvasOpenRequest { /** * Canvas open input */ - input?: { - [k: string]: unknown | undefined; - }; + input?: JsonValue; } /** * Canvas close parameters sent to the provider. @@ -4297,9 +4297,7 @@ export interface CanvasProviderInvokeActionRequest { /** * Action input */ - input?: { - [k: string]: unknown | undefined; - }; + input?: JsonValue; host?: CanvasHostContext; session?: CanvasSessionContext; } @@ -4330,9 +4328,7 @@ export interface CanvasProviderOpenRequest { /** * Canvas open input */ - input?: { - [k: string]: unknown | undefined; - }; + input?: JsonValue; host?: CanvasHostContext; session?: CanvasSessionContext; } @@ -4657,9 +4653,7 @@ export interface ConfigureSessionExtensionsParams { * * @internal */ - controller?: { - [k: string]: unknown | undefined; - }; + controller?: OpaqueInProcessValue; } /** * Metadata for a connected remote session. @@ -4904,7 +4898,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 @@ -5334,9 +5328,7 @@ export interface ExtensionContextPushInput { /** * Caller-supplied JSON payload (required, may be null but not undefined) */ - payload: { - [k: string]: unknown | undefined; - }; + payload: JsonValue; } /** * Opaque integrator-owned process launch profile for one extension entrypoint. @@ -5460,7 +5452,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 @@ -5500,7 +5492,7 @@ export interface ExternalToolTextResultForLlmBinaryResultsForLlm { * Optional metadata from the producing tool. */ metadata?: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }; } /** @@ -5737,9 +5729,7 @@ export interface FactoryAgentOptions { /** * Optional JSON Schema for structured agent output. */ - schema?: { - [k: string]: unknown | undefined; - }; + schema?: JsonValue; /** * Optional model identifier for the subagent. */ @@ -5787,9 +5777,7 @@ 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. @@ -5877,9 +5865,7 @@ export interface FactoryExecuteRequest { /** * Factory input value. */ - args: { - [k: string]: unknown | undefined; - }; + args: JsonValue; } /** * Result returned by an extension factory closure. @@ -5892,9 +5878,7 @@ export interface FactoryExecuteResult { /** * Factory result value. */ - result?: { - [k: string]: unknown | undefined; - }; + result?: JsonValue; } /** * Parameters for paging factory progress. @@ -5974,9 +5958,7 @@ export interface FactoryJournalGetResult { /** * 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. @@ -6001,9 +5983,7 @@ export interface FactoryJournalPutRequest { /** * JSON result to memoize. */ - resultJson: { - [k: string]: unknown | undefined; - }; + resultJson: JsonValue; } /** * Parameters for paging factory runs. @@ -6283,9 +6263,7 @@ export interface FactoryRunResult { /** * Completed factory result. */ - result?: { - [k: string]: unknown | undefined; - }; + result?: JsonValue; /** * Error message for an errored run. */ @@ -6298,9 +6276,7 @@ export interface FactoryRunResult { /** * Partial journal and progress snapshot for a halted, cancelled, or errored run. */ - snapshot?: { - [k: string]: unknown | undefined; - }; + snapshot?: JsonValue; } /** * Full factory run observability detail. @@ -6348,9 +6324,7 @@ export interface FactoryRunRequest { /** * Factory input value. */ - args: { - [k: string]: unknown | undefined; - }; + args: JsonValue; options?: RunOptions; } /** @@ -6925,7 +6899,7 @@ export interface HistoryTruncateResult { export interface HookInvokeRequest { sessionId: string; hookType: HookType; - input: unknown; + input: JsonValue; } /** * Optional output returned by an SDK callback hook. @@ -6936,7 +6910,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. @@ -7568,9 +7542,7 @@ export interface ManagedSettingsReadResult { /** * Validated, canonical managed-settings JSON. Omitted when no managed settings were discovered or when discovered settings failed validation. */ - settingsJson?: { - [k: string]: unknown | undefined; - }; + settingsJson?: JsonValue; /** * Discovery or validation error text when managed settings could not be read safely. */ @@ -7741,7 +7713,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. @@ -7886,7 +7858,7 @@ export interface McpAppsListToolsResult { * App-callable tools from the server */ tools: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }[]; } /** @@ -7947,7 +7919,7 @@ export interface McpAppsResourceContent { * Resource-level metadata (CSP, permissions, etc.) */ _meta?: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }; } /** @@ -8220,9 +8192,7 @@ export interface McpConfigureGitHubRequest { * * @internal */ - authInfo: { - [k: string]: unknown | undefined; - }; + authInfo: OpaqueInProcessValue; } /** * Result of configuring GitHub MCP. @@ -8308,9 +8278,7 @@ 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: { - [k: string]: unknown | undefined; - }; + mcpRequestId: JsonValue; request: McpExecuteSamplingRequest; } /** @@ -8683,25 +8651,19 @@ export interface McpRegisterExternalClientRequest { * * @internal */ - client: { - [k: string]: unknown | undefined; - }; + client: OpaqueInProcessValue; /** * In-process MCP Transport instance. Marked internal: cannot be serialized across the JSON-RPC boundary. * * @internal */ - transport: { - [k: string]: unknown | undefined; - }; + transport: OpaqueInProcessValue; /** * In-process server config (MCPServerConfig) paired with the in-process client/transport. Marked internal alongside its companions. * * @internal */ - config: { - [k: string]: unknown | undefined; - }; + config: OpaqueInProcessValue; } /** * Opaque MCP reload configuration. @@ -8717,9 +8679,7 @@ export interface McpReloadWithConfigRequest { * * @internal */ - config: { - [k: string]: unknown | undefined; - }; + config: OpaqueInProcessValue; } /** * Indicates whether the auto-managed `github` MCP server was removed (false when nothing to remove). @@ -8775,13 +8735,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; }; } /** @@ -8812,7 +8772,7 @@ export interface McpResourceIcon { * Server-provided non-standard icon fields preserved from the MCP response */ additionalProperties?: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }; } /** @@ -8839,7 +8799,7 @@ export interface McpResourceAnnotations { * Server-provided non-standard annotation fields preserved from the MCP response */ additionalProperties?: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }; } /** @@ -8870,7 +8830,7 @@ export interface McpResourceContent { * Resource-level metadata (CSP, permissions, etc.) */ _meta?: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }; } /** @@ -8978,13 +8938,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; }; } /** @@ -9947,7 +9907,7 @@ export interface NameSetRequest { /** @experimental */ export interface OptionsUpdateAdditionalContentExclusionPolicy { rules: OptionsUpdateAdditionalContentExclusionPolicyRule[]; - last_updated_at: unknown; + last_updated_at: JsonValue; scope: OptionsUpdateAdditionalContentExclusionPolicyScope; } /** @@ -11025,7 +10985,7 @@ export interface PermissionRulesSet { /** @experimental */ export interface PermissionsConfigureAdditionalContentExclusionPolicy { rules: PermissionsConfigureAdditionalContentExclusionPolicyRule[]; - last_updated_at: unknown; + last_updated_at: JsonValue; scope: PermissionsConfigureAdditionalContentExclusionPolicyScope; } /** @@ -11836,7 +11796,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). @@ -12471,9 +12431,7 @@ export interface QueueConsumeSystemNotificationsRequest { /** * Opaque runtime-owned filter object. */ - filter: { - [k: string]: unknown | undefined; - }; + filter: JsonValue; } /** * Inputs for marking session.idle deferred in native state. @@ -12882,9 +12840,7 @@ export interface RegisterExtensionToolsParams { * * @internal */ - loader: { - [k: string]: unknown | undefined; - }; + loader: OpaqueInProcessValue; options?: SessionsRegisterExtensionToolsOnSessionOptions; } /** @@ -12900,9 +12856,7 @@ export interface SessionsRegisterExtensionToolsOnSessionOptions { * * @internal */ - enabled?: { - [k: string]: unknown | undefined; - }; + enabled?: OpaqueInProcessValue; } /** * Handle for releasing the extension tool registration. @@ -12920,9 +12874,7 @@ export interface RegisterExtensionToolsResult { * * @internal */ - unsubscribe: { - [k: string]: unknown | undefined; - }; + unsubscribe: OpaqueInProcessValue; } /** * Opaque handle previously returned by `registerInterest` to release. @@ -13043,9 +12995,7 @@ export interface RemoteControlStatusActive { * * @internal */ - promptManager?: { - [k: string]: unknown | undefined; - }; + promptManager?: OpaqueInProcessValue; /** * 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. * @@ -13867,15 +13817,11 @@ export interface SendSystemNotificationRequest { /** * Optional structured notification kind. */ - kind?: { - [k: string]: unknown | undefined; - }; + kind?: JsonValue; /** * Internal delivery options, including passive policy. */ - options?: { - [k: string]: unknown | undefined; - }; + options?: JsonValue; } /** * Agents discovered across user, project, plugin, and remote sources. @@ -14363,7 +14309,7 @@ export interface SessionFsSqliteQueryRequest { * Optional named bind parameters */ params?: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }; } /** @@ -14378,7 +14324,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 @@ -14436,7 +14382,7 @@ export interface SessionFsSqliteTransactionStatement { * Optional named bind parameters. */ params?: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }; } /** @@ -14839,7 +14785,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. */ @@ -14848,7 +14794,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; }; } /** @@ -14909,9 +14855,7 @@ export interface SessionOpenOptions { * * @internal */ - expAssignments?: { - [k: string]: unknown | undefined; - }; + expAssignments?: JsonValue; /** * Opt-in: self-fetch and enforce enterprise managed settings at session bootstrap. */ @@ -15166,7 +15110,7 @@ export interface ShellInitScript { /** @experimental */ export interface SessionOpenOptionsAdditionalContentExclusionPolicy { rules: SessionOpenOptionsAdditionalContentExclusionPolicyRule[]; - last_updated_at: unknown; + last_updated_at: JsonValue; scope: SessionOpenOptionsAdditionalContentExclusionPolicyScope; } /** @@ -15315,9 +15259,7 @@ export interface SessionsOpenCloud { * * @internal */ - onTaskCreated?: { - [k: string]: unknown | undefined; - }; + onTaskCreated?: OpaqueInProcessValue; } /** * Parameters for fetching a remote session and handing it off to a new local session. @@ -15339,17 +15281,13 @@ export interface SessionsOpenHandoff { * * @internal */ - onProgress?: { - [k: string]: unknown | undefined; - }; + onProgress?: OpaqueInProcessValue; /** * 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; - }; + onConfirm?: OpaqueInProcessValue; } /** * Result of opening a session. @@ -15371,9 +15309,7 @@ export interface SessionOpenResult { * * @internal */ - sessionApi?: { - [k: string]: unknown | undefined; - }; + sessionApi?: OpaqueInProcessValue; /** * Startup prompts queued by user-level hook configs at session creation. Only populated when status is `created`; resumed sessions return an empty array. */ @@ -17316,7 +17252,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 @@ -17749,17 +17685,13 @@ export interface UIEphemeralQueryRequest { * * @internal */ - onChunk?: { - [k: string]: unknown | undefined; - }; + onChunk?: OpaqueInProcessValue; /** * 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; - }; + abortSignal?: OpaqueInProcessValue; } /** * Transient answer generated from current conversation context. @@ -18210,15 +18142,11 @@ export interface UserSettingMetadata { /** * The effective value: the user's value if set, otherwise the default. */ - value: { - [k: string]: unknown | undefined; - }; + value: JsonValue; /** * The centrally-known default for this setting (null when no default is registered). */ - default: { - [k: string]: unknown | undefined; - }; + 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. */ @@ -18250,9 +18178,7 @@ 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. @@ -18481,9 +18407,7 @@ 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. @@ -18674,9 +18598,7 @@ export interface WorkspacesUpdateMetadataRequest { /** * Opaque workspace context supplied by the session host. */ - context?: { - [k: string]: unknown | undefined; - }; + context?: JsonValue; /** * Optional workspace display name override. */ @@ -18736,7 +18658,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 db24fc23e..4bdfa1994 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -3,6 +3,9 @@ * Generated from: session-events.schema.json */ +/** A value that can be represented losslessly on the SDK JSON wire. */ +export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; + /** * Union of all session event variants emitted by the Copilot CLI runtime. */ @@ -669,6 +672,10 @@ 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 */ @@ -709,6 +716,10 @@ 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 */ @@ -3269,9 +3280,7 @@ export interface AttachmentExtensionContext { /** * Caller-supplied JSON payload */ - payload?: { - [k: string]: unknown | undefined; - }; + payload?: JsonValue; /** * Human-readable composer pill label */ @@ -3810,9 +3819,7 @@ 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?: { - [k: string]: unknown | undefined; - }; + providerMetadata?: JsonValue; /** * Identifier of the CitationSource this reference points to (CitationSource.id). */ @@ -3881,9 +3888,9 @@ 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 @@ -3892,9 +3899,7 @@ 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 */ @@ -4573,9 +4578,7 @@ export interface ToolUserRequestedData { /** * Arguments for the tool invocation */ - arguments?: { - [k: string]: unknown | undefined; - }; + arguments?: JsonValue; /** * Unique identifier for this tool call */ @@ -4622,9 +4625,7 @@ 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 */ @@ -4848,9 +4849,7 @@ export interface ToolExecutionCompleteData { * * @experimental */ - mcpMeta?: { - [k: string]: unknown | undefined; - }; + mcpMeta?: JsonValue; /** * Model identifier that generated this tool call */ @@ -4879,7 +4878,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 @@ -4932,15 +4931,11 @@ export interface ToolExecutionCompleteResult { * * @experimental */ - mcpMeta?: { - [k: string]: unknown | undefined; - }; + mcpMeta?: JsonValue; /** * Structured content (arbitrary JSON) returned verbatim by the MCP tool */ - structuredContent?: { - [k: string]: unknown | undefined; - }; + structuredContent?: JsonValue; uiResource?: ToolExecutionCompleteUIResource; } /** @@ -4959,7 +4954,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 @@ -4984,7 +4979,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 @@ -5014,7 +5009,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 @@ -5779,9 +5774,7 @@ export interface HookStartData { /** * 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 @@ -5829,9 +5822,7 @@ export interface HookEndData { /** * Output data produced by the hook */ - output?: { - [k: string]: unknown | undefined; - }; + output?: JsonValue; /** * Whether the hook completed successfully */ @@ -5952,7 +5943,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 @@ -6021,7 +6012,7 @@ export interface SystemMessageMetadata { * Template variables used when constructing the prompt */ variables?: { - [k: string]: unknown | undefined; + [k: string]: JsonValue | undefined; }; } /** @@ -6226,9 +6217,7 @@ export interface SystemNotificationFactoryCompleted { /** * Machine-readable terminal failure details, when present. */ - failure?: { - [k: string]: unknown | undefined; - }; + failure?: JsonValue; /** * Bounded prompt-safe preview of the completed result. */ @@ -6254,9 +6243,7 @@ export interface SystemNotificationUnclassified { /** * Opaque metadata supplied by the external host, when present. */ - metadata?: { - [k: string]: unknown | undefined; - }; + metadata?: JsonValue; /** * Type discriminator. Always "unclassified". */ @@ -6309,9 +6296,7 @@ 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?: { - [k: string]: unknown | undefined; - }; + riskAssessment?: JsonValue; } /** * Shell command permission request @@ -6494,9 +6479,7 @@ export interface PermissionRequestMcp { /** * Arguments to pass to the MCP tool */ - args?: { - [k: string]: unknown | undefined; - }; + args?: JsonValue; /** * Permission kind discriminator */ @@ -6597,9 +6580,7 @@ export interface PermissionRequestCustomTool { /** * Arguments to pass to the custom tool */ - args?: { - [k: string]: unknown | undefined; - }; + args?: JsonValue; /** * Permission kind discriminator */ @@ -6632,9 +6613,7 @@ export interface PermissionRequestHook { /** * Arguments of the tool call being gated */ - toolArgs?: { - [k: string]: unknown | undefined; - }; + toolArgs?: JsonValue; /** * Tool call ID that triggered this permission request */ @@ -6893,9 +6872,7 @@ 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. * @@ -7010,9 +6987,7 @@ 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. * @@ -7081,9 +7056,7 @@ export interface PermissionPromptRequestHook { /** * Arguments of the tool call being gated */ - toolArgs?: { - [k: string]: unknown | undefined; - }; + toolArgs?: JsonValue; /** * Tool call ID that triggered this permission request */ @@ -7663,7 +7636,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 @@ -7720,12 +7693,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 */ @@ -7763,9 +7730,7 @@ 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() */ @@ -8114,12 +8079,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 */ @@ -8163,9 +8122,7 @@ 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() */ @@ -8718,9 +8675,7 @@ 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?: { - [k: string]: unknown | undefined; - }; + settings?: JsonValue; source: ManagedSettingsResolvedSource; } /** @@ -9570,9 +9525,7 @@ export interface CanvasOpenedData { /** * Input supplied when the instance was opened */ - input?: { - [k: string]: unknown | undefined; - }; + input?: JsonValue; /** * Stable caller-supplied canvas instance identifier */ @@ -9667,9 +9620,7 @@ export interface CanvasRegistryChangedCanvas { /** * 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. @@ -9683,9 +9634,7 @@ export interface CanvasRegistryChangedCanvasAction { /** * JSON Schema for action input */ - inputSchema?: { - [k: string]: unknown | undefined; - }; + inputSchema?: JsonValue; /** * Action name */ @@ -9836,9 +9785,7 @@ export interface CanvasRecordedData { /** * Input supplied when the instance was opened */ - input?: { - [k: string]: unknown | undefined; - }; + input?: JsonValue; /** * Stable caller-supplied canvas instance identifier */ @@ -9974,7 +9921,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 @@ -9985,7 +9932,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 5ab53471a..622fd38b5 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -41,14 +41,15 @@ export { // consumers can import them directly from "@github/copilot-sdk" instead of // reaching into the package's internal dist layout. See issue #1156. // -// Five names from this file are also explicitly exported elsewhere in this +// Six names from this file are also explicitly exported elsewhere in this // module — `SessionEvent` (re-exported below from `./types.js`), // `PermissionRequest` (re-exported below from `./types.js`), // `PermissionRequestedData`/`PermissionRequestedEvent` (also re-exported below -// from `./types.js`), and `AssistantMessageEvent` (re-exported above from -// `./session.js`). Per the ECMAScript module spec, the explicit named re-exports +// from `./types.js`), `AssistantMessageEvent` (re-exported above from +// `./session.js`), and `JsonValue` (re-exported below from `./factory.js`). +// Per the ECMAScript module spec, the explicit named re-exports // shadow the names arriving via `export type *`, so the hand-authored public API -// surface for those five identifiers is preserved unchanged. +// surface for those six identifiers is preserved unchanged. export type * from "./generated/session-events.js"; export type { CommandContext, diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 5cc49fb75..42e30d6f3 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -7,6 +7,7 @@ * @module session */ +import { AsyncLocalStorage } from "node:async_hooks"; import type { MessageConnection } from "vscode-jsonrpc/node.js"; import { ConnectionError, ErrorCodes, ResponseError } from "vscode-jsonrpc/node.js"; import { createSessionRpc } from "./generated/rpc.js"; @@ -16,9 +17,6 @@ import type { CurrentToolMetadata, McpOauthPendingRequestResponse, FactoryLogLine, - FactoryRunRequest, - FactoryExecuteResult, - FactoryJournalPutRequest, FactoryRunResult as WireFactoryRunResult, } from "./generated/rpc.js"; import { type Canvas, CanvasError } from "./canvas.js"; @@ -66,11 +64,13 @@ import type { UserInputResponse, } from "./types.js"; import { + FACTORY_AGENT_OPTION_KEYS, getFactoryDefinition, FactoryResumeError, isFactoryRunTerminal, type FactoryResumeErrorCode, type FactoryRunResult, + type FactoryAgentOptions, type RunOptions, type SessionFactoryApi, type FactoryContext, @@ -84,11 +84,35 @@ function isFactoryResumeErrorCode(value: unknown): value is FactoryResumeErrorCo value === "not_found" || value === "non_resumable" || value === "already_active" || - value === "reapproval_declined" || - value === "no_approval_provider" + value === "factory_already_running" || + value === "factory_limits_invalid" || + value === "factory_session_disposed" || + value === "factory_storage_unavailable" || + value === "factory_storage_corrupt" ); } +function copyDefinedFactoryAgentOption( + source: FactoryAgentOptions, + target: FactoryAgentOptions, + key: TKey +): void { + const value = source[key]; + if (value !== undefined) { + target[key] = value; + } +} + +const factoryExecutionStore = new AsyncLocalStorage<{ active: boolean }>(); + +function throwIfFactoryExecutionIsActive(): void { + if (factoryExecutionStore.getStore()?.active) { + throw new Error( + "factory.run and factory.resume are not allowed while a factory body is running on this call path." + ); + } +} + /** * Convert a raw hook input received over the wire into its public-facing shape. * This deserializes the numeric Unix-ms `timestamp` field on BaseHookInput @@ -255,7 +279,10 @@ class FactoryProgressBuffer { const lines = this.pending.splice(0); await this.flushTail; if (this.flushFailed) { - throw this.flushError; + console.warn( + "Ignoring a background factory progress flush failure after the factory body settled", + this.flushError + ); } if (lines.length > 0) { try { @@ -288,24 +315,6 @@ 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. - * - * 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; -} - async function awaitFactoryOperation( operation: () => Promise, signal: AbortSignal @@ -442,6 +451,7 @@ export class CopilotSession { nameOrHandle: string | FactoryHandle, options?: RunOptions ): Promise => { + throwIfFactoryExecutionIsActive(); const name = typeof nameOrHandle === "string" ? nameOrHandle @@ -453,9 +463,7 @@ export class CopilotSession { } const envelope = await this.rpc.factory.run({ name, - args: (options?.args === undefined - ? {} - : options.args) as FactoryRunRequest["args"], + args: options?.args === undefined ? {} : options.args, options: { limits: options?.limits, }, @@ -464,6 +472,7 @@ export class CopilotSession { return this.settleFactoryRun(envelope); }) as SessionFactoryApi["run"], resume: (async (runId: string, options?: Parameters[1]) => { + throwIfFactoryExecutionIsActive(); let response; try { response = await this.rpc.factory.resume({ @@ -485,13 +494,13 @@ export class CopilotSession { } return this.settleFactoryRun(response.run); }) as SessionFactoryApi["resume"], - getRun: async (runId) => toPublicFactoryRunResult(await this.rpc.factory.getRun({ runId })), + getRun: async (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: async (runId) => toPublicFactoryRunResult(await this.rpc.factory.cancel({ runId })), + cancel: async (runId) => this.rpc.factory.cancel({ runId }), }; /** @@ -503,7 +512,7 @@ export class CopilotSession { */ private settleFactoryRun(envelope: WireFactoryRunResult): Promise { if (isFactoryRunTerminal(envelope.status)) { - return Promise.resolve(toPublicFactoryRunResult(envelope)); + return Promise.resolve(envelope); } return this.waitForFactoryRun(envelope.runId); } @@ -562,7 +571,7 @@ export class CopilotSession { rereadRequested = false; const envelope = await this.rpc.factory.getRun({ runId }); if (isFactoryRunTerminal(envelope.status)) { - finish(() => resolve(toPublicFactoryRunResult(envelope))); + finish(() => resolve(envelope)); return; } } while (rereadRequested && !settled); @@ -1379,7 +1388,7 @@ export class CopilotSession { try { const context: FactoryContext = { runId: params.runId, - args: params.args as JsonValue, + args: params.args, session: self, signal: controller.signal, phase: (title: string) => { @@ -1392,17 +1401,17 @@ export class CopilotSession { }, agent: async (prompt, options = {}) => { await progress.flush(); + const opts: FactoryAgentOptions = {}; + for (const key of FACTORY_AGENT_OPTION_KEYS) { + copyDefinedFactoryAgentOption(options, opts, key); + } 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, - }, + opts, }), controller.signal ); @@ -1452,8 +1461,7 @@ export class CopilotSession { runId: params.runId, executionToken: params.executionToken, key, - resultJson: - result as FactoryJournalPutRequest["resultJson"], + resultJson: result, }), controller.signal ); @@ -1465,12 +1473,19 @@ export class CopilotSession { throw new Error("nested factories are not supported"); }, }; - const result = await definition.run(context); + const execution = { active: true }; + const result = await factoryExecutionStore.run(execution, async () => { + try { + return await definition.run(context); + } finally { + execution.active = false; + } + }); if (result === undefined) { return {}; } assertFactoryResult(result); - return { result } as FactoryExecuteResult; + return { result }; } finally { try { await progress.close(); diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 4ff279189..3a5f7714b 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -19,6 +19,7 @@ import type { SessionEvent as GeneratedSessionEvent, } from "./generated/session-events.js"; import type { CopilotSession } from "./session.js"; +import type { JsonValue } from "./factory.js"; import type { GitHubTelemetryNotification, ModelBillingTokenPrices, @@ -451,7 +452,7 @@ export type ToolBinaryResult = { description?: string; }; -export type ToolTelemetry = Record | undefined>; +export type ToolTelemetry = Record | undefined>; export type ToolResultObject = { textResultForLlm: string; diff --git a/nodejs/test/e2e/factory.e2e.test.ts b/nodejs/test/e2e/factory.e2e.test.ts index 547ecbbd5..8c038d9de 100644 --- a/nodejs/test/e2e/factory.e2e.test.ts +++ b/nodejs/test/e2e/factory.e2e.test.ts @@ -1,10 +1,10 @@ import { execFileSync } from "node:child_process"; -import { existsSync } from "node:fs"; -import { copyFile, mkdir } from "node:fs/promises"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { copyFile, mkdir, rm } 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 { expect, it, vi } from "vitest"; +import { approveAll, FactoryResumeError } from "../../src/index.js"; import { createSdkTestContext, DEFAULT_GITHUB_TOKEN, @@ -23,55 +23,290 @@ const factoryTestContext = isInProcessTransport }, }); +async function setupFactoryExtension(workDir: string, onPermissionRequest = approveAll) { + if (!factoryTestContext) { + throw new Error("Factory E2E requires the stdio transport"); + } + + const { copilotClient, openAiEndpoint } = factoryTestContext; + const extensionDir = join(workDir, ".github", "extensions", "factory-smoke"); + const readyFile = join(extensionDir, "ready"); + await rm(join(workDir, ".github"), { recursive: true, force: true }); + await mkdir(extensionDir, { recursive: true }); + await copyFile( + join(__dirname, "fixtures", "factory-extension.mjs"), + join(extensionDir, "extension.mjs") + ); + execFileSync("git", ["init", "--quiet"], { cwd: workDir }); + + await openAiEndpoint.setCopilotUserByToken(DEFAULT_GITHUB_TOKEN, { + login: "factory-e2e-user", + copilot_plan: "individual_pro", + token_based_billing: true, + is_mcp_enabled: true, + endpoints: { + api: openAiEndpoint.url, + telemetry: "https://localhost:1/telemetry", + }, + analytics_tracking_id: "e2e-test-tracking-id", + }); + + const session = await copilotClient.createSession({ + requestExtensions: true, + extensionSdkPath: resolve(__dirname, "..", "..", "dist"), + onPermissionRequest, + 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 + ); + + return session; +} + 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; + const { workDir } = factoryTestContext; + await using session = await setupFactoryExtension(workDir); + + 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 }, + }); + } +); + +it.skipIf(isInProcessTransport)( + "forwards every declared subagent option to the runtime", + async () => { + if (!factoryTestContext) { + throw new Error("Factory E2E requires the stdio transport"); + } + const { workDir } = factoryTestContext; + await using session = await setupFactoryExtension(workDir); + + const result = await session.factory.run("forwards-subagent-options"); + + expect(result).toMatchObject({ + status: "completed", + result: { didThrow: false }, + }); + }, + // The factory abandons its subagent once the runtime has accepted the + // request, so the run settles only after the runtime drains that work. + 60_000 +); + +it.skipIf(isInProcessTransport)( + "throws FactoryResumeError with not_found for an unknown run", + async () => { + if (!factoryTestContext) { + throw new Error("Factory E2E requires the stdio transport"); + } + const { workDir } = factoryTestContext; + await using session = await setupFactoryExtension(workDir); + + const error = await session.factory + .resume("00000000-0000-0000-0000-000000000000") + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(FactoryResumeError); + expect((error as FactoryResumeError).code).toBe("not_found"); + } +); + +it.skipIf(isInProcessTransport)( + "throws FactoryResumeError with non_resumable for a completed run", + async () => { + if (!factoryTestContext) { + throw new Error("Factory E2E requires the stdio transport"); + } + const { workDir } = factoryTestContext; + await using session = await setupFactoryExtension(workDir); + + const run = await session.factory.run("argument-echo"); + const error = await session.factory.resume(run.runId).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(FactoryResumeError); + expect((error as FactoryResumeError).code).toBe("non_resumable"); + } +); + +it.skipIf(isInProcessTransport)( + "runs a factory when its session denies every permission request", + async () => { + if (!factoryTestContext) { + throw new Error("Factory E2E requires the stdio transport"); + } + const { workDir } = factoryTestContext; + const denyPermissions = vi.fn(() => ({ kind: "reject" as const })); + await using session = await setupFactoryExtension(workDir, denyPermissions); + + await expect(session.factory.run("argument-echo")).resolves.toMatchObject({ + status: "completed", + }); + expect(denyPermissions).not.toHaveBeenCalled(); + } +); + +it.skipIf(isInProcessTransport)( + "resumes a failed factory when its session denies every permission request", + async () => { + if (!factoryTestContext) { + throw new Error("Factory E2E requires the stdio transport"); + } + const { workDir } = factoryTestContext; + const denyPermissions = vi.fn(() => ({ kind: "reject" as const })); + await using session = await setupFactoryExtension(workDir, denyPermissions); - await openAiEndpoint.setCopilotUserByToken(DEFAULT_GITHUB_TOKEN, { - login: "factory-e2e-user", - copilot_plan: "individual_pro", - token_based_billing: true, + const failedRun = await session.factory.run("fails-once"); + expect(failedRun).toMatchObject({ + status: "error", }); + await expect(session.factory.resume(failedRun.runId)).resolves.toMatchObject({ + status: "completed", + result: "resumed", + }); + expect(denyPermissions).not.toHaveBeenCalled(); + } +); + +it.skipIf(isInProcessTransport)( + "refuses a factory started through the context session from a factory body", + async () => { + if (!factoryTestContext) { + throw new Error("Factory E2E requires the stdio transport"); + } + const { workDir } = factoryTestContext; + await using session = await setupFactoryExtension(workDir); + + const result = await session.factory.run("starts-from-context-session"); + + expect(result).toMatchObject({ + status: "completed", + result: expect.stringContaining("factory.run and factory.resume"), + }); + expect((result as { result: string }).result).toContain("factory body"); + } +); + +it.skipIf(isInProcessTransport)( + "refuses a factory started through the module session from a factory body", + async () => { + if (!factoryTestContext) { + throw new Error("Factory E2E requires the stdio transport"); + } + const { workDir } = factoryTestContext; + await using session = await setupFactoryExtension(workDir); + + const result = await session.factory.run("starts-from-module-session"); + + expect(result).toMatchObject({ + status: "completed", + result: expect.stringContaining("factory.run and factory.resume"), + }); + expect((result as { result: string }).result).toContain("factory body"); + } +); + +it.skipIf(isInProcessTransport)( + "allows a module-level extension watcher to start a factory while another body is parked", + async () => { + if (!factoryTestContext) { + throw new Error("Factory E2E requires the stdio transport"); + } + const { workDir } = factoryTestContext; 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") + await using session = await setupFactoryExtension(workDir); + + const parked = session.factory.run("parked"); + await retry( + "wait for the parked factory to enter its body", + async () => { + expect(existsSync(join(extensionDir, "entered"))).toBe(true); + }, + 100, + 100 ); - 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" }, - }), - }); + writeFileSync(join(extensionDir, "start-b"), "start"); + const bResultFile = join(extensionDir, "b-result"); await retry( - "wait for the factory extension to join the session", + "wait for the module-level watcher factory run to succeed", async () => { - expect(existsSync(readyFile)).toBe(true); + expect(existsSync(bResultFile)).toBe(true); + expect(JSON.parse(readFileSync(bResultFile, "utf8"))).toMatchObject({ + status: "success", + result: { + status: "completed", + result: { source: "module-watcher" }, + }, + }); }, - 300, + 100, 100 ); - const result = await session.factory.run("argument-echo", { - args: { source: "sdk-e2e", count: 11 }, + writeFileSync(join(extensionDir, "release"), "release"); + await expect(parked).resolves.toMatchObject({ + status: "completed", + result: "released", }); + }, + 60_000 +); + +it.skipIf(isInProcessTransport)( + "returns an array result from an extension-authored factory", + async () => { + if (!factoryTestContext) { + throw new Error("Factory E2E requires the stdio transport"); + } + const { workDir } = factoryTestContext; + await using session = await setupFactoryExtension(workDir); + + const result = await session.factory.run("array-result"); expect(result).toMatchObject({ status: "completed", - result: { source: "sdk-e2e", count: 11 }, + result: [1, "two", false], + }); + } +); + +it.skipIf(isInProcessTransport)( + "passes array factory arguments across the SDK process boundary", + async () => { + if (!factoryTestContext) { + throw new Error("Factory E2E requires the stdio transport"); + } + const { workDir } = factoryTestContext; + await using session = await setupFactoryExtension(workDir); + + const args = [1, "two", false]; + const result = await session.factory.run("argument-echo", { args }); + + expect(result).toMatchObject({ + status: "completed", + result: args, }); } ); diff --git a/nodejs/test/e2e/fixtures/factory-extension.mjs b/nodejs/test/e2e/fixtures/factory-extension.mjs index fab95a90f..5f6c19e2b 100644 --- a/nodejs/test/e2e/fixtures/factory-extension.mjs +++ b/nodejs/test/e2e/fixtures/factory-extension.mjs @@ -1,6 +1,18 @@ -import { writeFileSync } from "node:fs"; +import { existsSync, writeFileSync } from "node:fs"; import { defineFactory, joinSession } from "@github/copilot-sdk/extension"; +const marker = (name) => new URL(`./${name}`, import.meta.url); + +async function waitForMarker(name, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (!existsSync(marker(name))) { + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for ${name}`); + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + const argumentEcho = defineFactory({ meta: { name: "argument-echo", @@ -10,5 +22,141 @@ const argumentEcho = defineFactory({ run: async ({ args }) => args, }); -await joinSession({ factories: [argumentEcho] }); -writeFileSync(new URL("./ready", import.meta.url), "ready"); +const arrayResult = defineFactory({ + meta: { + name: "array-result", + description: "Return an array result.", + phases: [], + }, + run: async () => [1, "two", false], +}); + +const forwardsSubagentOptions = defineFactory({ + meta: { + name: "forwards-subagent-options", + description: "Send every declared subagent option to the runtime.", + phases: [], + }, + run: async ({ agent }) => { + // Only the runtime's acceptance of the payload is under test. A refused + // request rejects quickly, because the runtime parses the options before + // it starts a subagent. A subagent that is merely slow to reach a model + // proves the payload was accepted, so waiting for it adds nothing and + // hangs wherever no model is reachable. + const call = agent("Confirm that this request is accepted.", { + agent: "reviewer", + reasoningEffort: "high", + contextTier: "long_context", + }); + // A rejection that lands after the race still needs a handler. + call.catch(() => {}); + let settleTimer; + const stillPending = new Promise((resolve) => { + settleTimer = setTimeout(() => resolve(undefined), 3000); + settleTimer.unref?.(); + }); + try { + await Promise.race([call, stillPending]); + return { didThrow: false }; + } catch { + return { didThrow: true }; + } finally { + clearTimeout(settleTimer); + } + }, +}); + +const startsFromContextSession = defineFactory({ + meta: { + name: "starts-from-context-session", + description: "Try to start a factory through the context session.", + phases: [], + }, + run: async ({ session }) => { + try { + await session.factory.run("argument-echo"); + return "unexpectedly started a factory"; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }, +}); + +let session; + +const startsFromModuleSession = defineFactory({ + meta: { + name: "starts-from-module-session", + description: "Try to start a factory through the module session.", + phases: [], + }, + run: async () => { + try { + await session.factory.run("argument-echo"); + return "unexpectedly started a factory"; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }, +}); + +const parked = defineFactory({ + meta: { + name: "parked", + description: "Wait for a test-controlled release marker.", + phases: [], + }, + run: async () => { + writeFileSync(marker("entered"), "entered"); + await waitForMarker("release", 30_000); + return "released"; + }, +}); + +const failsOnce = defineFactory({ + meta: { + name: "fails-once", + description: "Fails its first attempt and succeeds when resumed.", + phases: [], + }, + run: async () => { + if (!existsSync(marker("fails-once-attempted"))) { + writeFileSync(marker("fails-once-attempted"), "attempted"); + throw new Error("first attempt failed"); + } + return "resumed"; + }, +}); + +session = await joinSession({ + factories: [ + argumentEcho, + arrayResult, + forwardsSubagentOptions, + startsFromContextSession, + startsFromModuleSession, + parked, + failsOnce, + ], +}); + +void waitForMarker("start-b", 30_000) + .then(async () => { + const result = await session.factory.run("argument-echo", { + args: { source: "module-watcher" }, + }); + writeFileSync(marker("b-result"), JSON.stringify({ status: "success", result })); + }) + .catch((error) => { + if (existsSync(marker("start-b"))) { + writeFileSync( + marker("b-result"), + JSON.stringify({ + status: "error", + error: error instanceof Error ? error.message : String(error), + }) + ); + } + }); + +writeFileSync(marker("ready"), "ready"); diff --git a/nodejs/test/factory.test.ts b/nodejs/test/factory.test.ts index 0282cc4a5..f550c3bc9 100644 --- a/nodejs/test/factory.test.ts +++ b/nodejs/test/factory.test.ts @@ -402,6 +402,65 @@ describe("factories", () => { expect(generatedRpc).toContain("timeoutSeconds?: number;"); }); + it("documents factory invocation and list paging behavior accurately", () => { + const guide = readFileSync(new URL("../docs/factories.md", import.meta.url), "utf8"); + const publicApi = readFileSync(new URL("../src/factory.ts", import.meta.url), "utf8"); + const listRunsPagingWording = "newest default page of this session's durable factory runs"; + const resumeCodes = [ + "not_found", + "non_resumable", + "already_active", + "factory_already_running", + "factory_limits_invalid", + "factory_session_disposed", + "factory_storage_unavailable", + "factory_storage_corrupt", + ]; + const normalizeJSDoc = (document: string) => + document.replace(/\r?\n\s*\* ?/g, " ").replace(/\s+/g, " "); + const normalizedGuide = normalizeJSDoc(guide); + const normalizedPublicApi = normalizeJSDoc(publicApi); + + for (const document of [guide, publicApi]) { + expect(document).not.toContain("reapproval_declined"); + expect(document).not.toContain("no_approval_provider"); + expect(document).not.toMatch(/declined fresh run[\s\S]*terminal `cancelled` envelope/i); + } + + for (const document of [normalizedGuide, normalizedPublicApi]) { + expect(document).toContain(listRunsPagingWording); + } + + expect(normalizedGuide).toContain( + "SDK-initiated `run` and `resume` do not request permission" + ); + expect(normalizedGuide).toContain( + "`run_factory` tool requests permission before the durable row exists" + ); + expect(normalizedGuide).toContain("declining it creates no run row"); + expect(normalizedGuide).toContain("its maximum number of active top-level runs"); + for (const code of resumeCodes) { + expect(guide).toContain(`\`${code}\``); + } + expect(guide).toContain( + "Options are exactly `label`, `schema`, `model`, `agent`, `reasoningEffort`, and `contextTier`" + ); + expect(normalizedGuide).toContain( + "session returned by `joinSession`. It refuses calls that start or resume a factory run" + ); + + expect(normalizedPublicApi).toContain("SDK-initiated runs do not request permission"); + expect(normalizedPublicApi).toContain("declining it creates no run row"); + expect(normalizedPublicApi).toContain( + "while the session is at its active top-level run limit" + ); + expect(normalizedPublicApi).toContain("SDK-initiated resumes do not request permission"); + expect(normalizedPublicApi).toContain("with a documented resume code rejects with"); + expect(normalizedPublicApi).toContain( + "session instance returned by `joinSession`. It refuses calls that start or resume a factory run" + ); + }); + it("serializes only factory metadata in the extension resume payload", async () => { const client = new CopilotClient(); await client.start(); @@ -569,6 +628,133 @@ describe("factories", () => { expect(sendRequest).not.toHaveBeenCalled(); }); + it("keeps factory reads and cancellation available inside a factory body", async () => { + const sendRequest = vi.fn(async (method: string) => { + switch (method) { + case "session.factory.getRun": + return { runId: "other-run", status: "completed" }; + case "session.factory.listRuns": + return { runs: [] }; + case "session.factory.cancel": + return {}; + default: + throw new Error(`Unexpected method: ${method}`); + } + }); + const session = new CopilotSession("session-factory-reads", { sendRequest } as never); + const factory = defineFactory({ + meta: { + name: "factory-reads", + description: "Read factory state from a factory body", + phases: [], + }, + run: async ({ session: contextSession }) => { + const [run, runs] = await Promise.all([ + contextSession.factory.getRun("other-run"), + contextSession.factory.listRuns(), + contextSession.factory.cancel("other-run"), + ]); + return { runId: run.runId, runCount: runs.length }; + }, + }); + session.registerFactories([factory]); + + await expect( + session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "factory-reads", + runId: "run-factory-reads", + executionToken: "execution-token", + args: {}, + }) + ).resolves.toEqual({ result: { runId: "other-run", runCount: 0 } }); + expect(sendRequest).toHaveBeenCalledWith("session.factory.getRun", { + sessionId: session.sessionId, + runId: "other-run", + }); + expect(sendRequest).toHaveBeenCalledWith("session.factory.listRuns", { + sessionId: session.sessionId, + }); + expect(sendRequest).toHaveBeenCalledWith("session.factory.cancel", { + sessionId: session.sessionId, + runId: "other-run", + }); + }); + + it("allows factory.run after a factory body returns", async () => { + const sendRequest = vi.fn(async (method: string) => { + if (method === "session.factory.run") { + return { runId: "run-after-body", status: "completed", result: "started" }; + } + throw new Error(`Unexpected method: ${method}`); + }); + const session = new CopilotSession("session-after-body", { sendRequest } as never); + const factory = defineFactory({ + meta: { + name: "returns", + description: "Return before a separate factory run", + phases: [], + }, + run: async () => "finished", + }); + session.registerFactories([factory]); + + await expect( + session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "returns", + runId: "run-returns", + executionToken: "execution-token", + args: {}, + }) + ).resolves.toEqual({ result: "finished" }); + await expect(session.factory.run("after-body")).resolves.toMatchObject({ + status: "completed", + result: "started", + }); + }); + + it("allows a factory-body timer to start a factory after the body settles", async () => { + const delayedRun = Promise.withResolvers(); + const sendRequest = vi.fn(async (method: string) => { + if (method === "session.factory.run") { + return { runId: "run-from-timer", status: "completed", result: "started" }; + } + throw new Error(`Unexpected method: ${method}`); + }); + const session = new CopilotSession("session-timer", { sendRequest } as never); + const factory = defineFactory({ + meta: { + name: "timer", + description: "Start a factory from an unawaited timer", + phases: [], + }, + run: async () => { + setTimeout(() => { + void session.factory + .run("from-timer") + .then(delayedRun.resolve, delayedRun.reject); + }, 0); + return "finished"; + }, + }); + session.registerFactories([factory]); + + await expect( + session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "timer", + runId: "run-timer", + executionToken: "execution-token", + args: {}, + }) + ).resolves.toEqual({ result: "finished" }); + await expect(delayedRun.promise).resolves.toMatchObject({ + status: "completed", + result: "started", + }); + }); + 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); @@ -653,6 +839,95 @@ describe("factories", () => { }); }); + it("forwards every declared factory.agent option", 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-options", { sendRequest } as never); + const factory = defineFactory({ + meta: { + name: "agent-options", + description: "Agent option forwarding test", + phases: [], + }, + run: async ({ agent }) => + agent("Reply with pong", { + label: "Pong helper", + model: "gpt-test", + schema: { type: "string" }, + agent: "reviewer", + reasoningEffort: "high", + contextTier: "long_context", + }), + }); + session.registerFactories([factory]); + + await expect( + session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "agent-options", + runId: "run-agent-options", + executionToken: "execution-token", + args: {}, + }) + ).resolves.toEqual({ result: "pong" }); + expect(sendRequest).toHaveBeenCalledWith("session.factory.agent", { + sessionId: session.sessionId, + factoryRunId: "run-agent-options", + executionToken: "execution-token", + prompt: "Reply with pong", + opts: { + label: "Pong helper", + model: "gpt-test", + schema: { type: "string" }, + agent: "reviewer", + reasoningEffort: "high", + contextTier: "long_context", + }, + }); + }); + + it("sends empty factory.agent options when none are supplied", 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-empty-agent-options", { + sendRequest, + } as never); + const factory = defineFactory({ + meta: { + name: "empty-agent-options", + description: "Empty agent option forwarding test", + phases: [], + }, + run: async ({ agent }) => agent("Reply with pong"), + }); + session.registerFactories([factory]); + + await expect( + session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "empty-agent-options", + runId: "run-empty-agent-options", + executionToken: "execution-token", + args: {}, + }) + ).resolves.toEqual({ result: "pong" }); + expect(sendRequest).toHaveBeenCalledWith("session.factory.agent", { + sessionId: session.sessionId, + factoryRunId: "run-empty-agent-options", + executionToken: "execution-token", + prompt: "Reply with pong", + opts: {}, + }); + }); + 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") { @@ -1431,6 +1706,56 @@ describe("factories", () => { ); }); + it("keeps a completed execution successful when a background progress flush fails", async () => { + vi.useFakeTimers(); + const release = Promise.withResolvers(); + const sendRequest = vi.fn(async (method: string) => { + if (method === "session.factory.log") { + throw new Error("background transport failure"); + } + return {}; + }); + const warning = vi.spyOn(console, "warn").mockImplementation(() => {}); + const session = new CopilotSession("session-background-flush-failure", { + sendRequest, + } as never); + const factory = defineFactory({ + meta: { + name: "background-flush-failure", + description: "Background flush failure regression test", + phases: [], + }, + run: async ({ log }) => { + log("background line"); + await release.promise; + return "done"; + }, + }); + session.registerFactories([factory]); + + try { + const execution = session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "background-flush-failure", + runId: "run-background-flush-failure", + executionToken: "execution-token", + args: {}, + }); + await vi.advanceTimersByTimeAsync(10_000); + await Promise.resolve(); + + release.resolve(); + + await expect(execution).resolves.toEqual({ result: "done" }); + expect(warning).toHaveBeenCalledWith( + "Ignoring a background factory progress flush failure after the factory body settled", + expect.objectContaining({ message: "background transport failure" }) + ); + } finally { + vi.useRealTimers(); + } + }); + it("keeps a mid-run progress flush failure fatal", async () => { const sendRequest = vi.fn(async (method: string) => { if (method === "session.factory.log") { @@ -1736,8 +2061,11 @@ describe("factories", () => { "not_found", "non_resumable", "already_active", - "reapproval_declined", - "no_approval_provider", + "factory_already_running", + "factory_limits_invalid", + "factory_session_disposed", + "factory_storage_unavailable", + "factory_storage_corrupt", ] as const)( "throws FactoryResumeError with code %s for pre-execution failures", async (code) => { @@ -1755,6 +2083,21 @@ describe("factories", () => { } ); + it("leaves an unreachable permission_denied response as a raw ResponseError", async () => { + const session = new CopilotSession("session-resume-permission-denied", { + sendRequest: vi.fn(async () => { + throw new ResponseError(-32602, "resume failed: permission_denied", { + code: "permission_denied", + }); + }), + } as never); + + const error = await session.factory.resume("run-error").catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(ResponseError); + expect(error).not.toBeInstanceOf(FactoryResumeError); + expect((error as ResponseError<{ code: string }>).data.code).toBe("permission_denied"); + }); + it("returns resumed execution failures as envelopes", async () => { const envelope = { runId: "run-execution-error", diff --git a/nodejs/test/session-event-types.test.ts b/nodejs/test/session-event-types.test.ts index 5a8f2ca52..213670216 100644 --- a/nodejs/test/session-event-types.test.ts +++ b/nodejs/test/session-event-types.test.ts @@ -16,6 +16,8 @@ import { describe, expect, it } from "vitest"; import { approveAll } from "../src/index.js"; +import { FACTORY_AGENT_OPTION_KEYS } from "../src/factory.js"; +import type { FactoryAgentOptions as WireFactoryAgentOptions } from "../src/generated/rpc.js"; import type { // The aggregate union; must still resolve via the package root. SessionEvent, @@ -59,6 +61,8 @@ import type { WorkingDirectoryContextHostType, FactoryContext, FactoryDefinition, + FactoryAgentOptions, + FactoryRunResult, JsonValue, } from "../src/index.js"; @@ -97,6 +101,21 @@ type _DefaultFactoryResultIsJsonValueOrVoid = _AssertEqual< JsonValue | void >; const _defaultFactoryResultCheck: _DefaultFactoryResultIsJsonValueOrVoid = true; +type _FactoryRunResultIsJsonValueOrUndefined = _AssertEqual< + FactoryRunResult["result"], + JsonValue | undefined +>; +const _factoryRunResultCheck: _FactoryRunResultIsJsonValueOrUndefined = true; +type _FactoryAgentOptionKeysMatchPublicInterface = _AssertEqual< + (typeof FACTORY_AGENT_OPTION_KEYS)[number], + keyof FactoryAgentOptions +>; +const _factoryAgentOptionKeysCheck: _FactoryAgentOptionKeysMatchPublicInterface = true; +type _PublicFactoryAgentOptionsMatchWire = _AssertEqual< + keyof FactoryAgentOptions, + keyof WireFactoryAgentOptions +>; +const _publicFactoryAgentOptionsCheck: _PublicFactoryAgentOptionsMatchWire = 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. diff --git a/nodejs/test/typescript-codegen.test.ts b/nodejs/test/typescript-codegen.test.ts index e3f3d2857..0a63a5293 100644 --- a/nodejs/test/typescript-codegen.test.ts +++ b/nodejs/test/typescript-codegen.test.ts @@ -48,6 +48,128 @@ describe("typescript schema codegen", () => { ); expect(code).toContain('inlineMode: /** Use a direct value. */ "direct" | "indirect";'); }); + + it("maps bare opaque properties to their marker aliases", async () => { + const code = await compile( + normalizeSchemaForTypeScript({ + title: "OpaqueProperty", + type: "object", + properties: { + json: { "x-opaque-json": true }, + inProcess: { "x-opaque-in-process": true }, + }, + required: ["json", "inProcess"], + }), + "OpaqueProperty", + { bannerComment: "", style: { semi: true, singleQuote: false } } + ); + + expect(code).toContain("json: JsonValue;"); + expect(code).toContain("inProcess: OpaqueInProcessValue;"); + }); + + it("maps a bare opaque JSON additional property to JsonValue", async () => { + const code = await compile( + normalizeSchemaForTypeScript({ + title: "OpaqueMap", + type: "object", + additionalProperties: { "x-opaque-json": true }, + }), + "OpaqueMap", + { bannerComment: "", style: { semi: true, singleQuote: false } } + ); + + expect(code).toContain("[k: string]: JsonValue;"); + }); + + it("maps a bare opaque JSON array item to JsonValue", async () => { + const code = await compile( + normalizeSchemaForTypeScript({ + title: "OpaqueArray", + type: "object", + properties: { values: { type: "array", items: { "x-opaque-json": true } } }, + required: ["values"], + }), + "OpaqueArray", + { bannerComment: "", style: { semi: true, singleQuote: false } } + ); + + expect(code).toContain("values: JsonValue[];"); + }); + + it("maps a bare opaque JSON definition to a named alias", async () => { + const code = await compile( + normalizeSchemaForTypeScript({ + title: "OpaqueDefinitionRoot", + type: "object", + properties: { value: { $ref: "#/definitions/OpaqueDefinition" } }, + definitions: { OpaqueDefinition: { "x-opaque-json": true } }, + }), + "OpaqueDefinitionRoot", + { bannerComment: "", style: { semi: true, singleQuote: false } } + ); + + expect(code).toContain("export type OpaqueDefinition = JsonValue;"); + }); + + it("keeps an opaque JSON node with anyOf as a union", async () => { + const code = await compile( + normalizeSchemaForTypeScript({ + title: "ConstrainedUnion", + "x-opaque-json": true, + anyOf: [{ type: "string" }, { type: "number" }], + }), + "ConstrainedUnion", + { bannerComment: "", style: { semi: true, singleQuote: false } } + ); + + expect(code).toContain("export type ConstrainedUnion = string | number;"); + expect(code).not.toContain("JsonValue"); + }); + + it("keeps an opaque JSON node with object constraints as an object", async () => { + const code = await compile( + normalizeSchemaForTypeScript({ + title: "ConstrainedObject", + type: "object", + "x-opaque-json": true, + properties: { name: { type: "string" } }, + required: ["name"], + }), + "ConstrainedObject", + { bannerComment: "", style: { semi: true, singleQuote: false } } + ); + + expect(code).toContain("export interface ConstrainedObject {"); + expect(code).toContain("name: string;"); + expect(code).not.toContain("JsonValue"); + }); + + it("removes both opaque markers from every normalized schema node", () => { + const normalized = normalizeSchemaForTypeScript({ + type: "object", + "x-opaque-json": true, + properties: { + json: { "x-opaque-json": true }, + inProcess: { "x-opaque-in-process": true }, + }, + additionalProperties: { "x-opaque-in-process": true }, + }) as Record; + + const assertMarkersRemoved = (value: unknown): void => { + if (Array.isArray(value)) { + value.forEach(assertMarkersRemoved); + } else if (value && typeof value === "object") { + for (const [key, child] of Object.entries(value as Record)) { + expect(key).not.toBe("x-opaque-json"); + expect(key).not.toBe("x-opaque-in-process"); + assertMarkersRemoved(child); + } + } + }; + + assertMarkersRemoved(normalized); + }); }); describe("filterPublicSessionEventVariants", () => { diff --git a/scripts/codegen/typescript.ts b/scripts/codegen/typescript.ts index 30cefd3e9..0c6866ed6 100644 --- a/scripts/codegen/typescript.ts +++ b/scripts/codegen/typescript.ts @@ -40,7 +40,9 @@ import { isSchemaInternal, appendPropertyMarkerTagsToDescriptions, getEnumValueDescriptions, - stripOpaqueJsonMarker, + isBareSchemaNode, + isOpaqueInProcess, + isOpaqueJson, loadSchemaJson, fixBrandCasing, type ApiSchema, @@ -52,6 +54,35 @@ const TS_EXPERIMENTAL_JSDOC = "/** @experimental */"; const EXTERNAL_SCHEMA_TS_IMPORT: Record = { "session-events.schema.json": "./session-events.js", }; +type OpaqueTypeAlias = "JsonValue" | "OpaqueInProcessValue"; + +function opaqueTypeAliasBlock(aliases: ReadonlySet): string { + const declarations: string[] = []; + if (aliases.has("JsonValue")) { + declarations.push( + `/** A value that can be represented losslessly on the SDK JSON wire. */ +export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };` + ); + } + if (aliases.has("OpaqueInProcessValue")) { + declarations.push( + `/** + * A value that lives only in this process and never crosses the JSON-RPC + * boundary, such as a callback or a host object handle. + * @internal + */ +export type OpaqueInProcessValue = unknown;` + ); + } + return declarations.join("\n\n"); +} + +function restoreOpaqueTypeAliasFormatting(code: string): string { + return code.replace( + "export type JsonValue = null | boolean | number | string | JsonValue[] | {[key: string]: JsonValue};", + "export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };" + ); +} function tsExperimentalJSDoc(indent = ""): string { return `${indent}${TS_EXPERIMENTAL_JSDOC}`; @@ -327,7 +358,10 @@ function collectRpcMethods(node: Record): RpcMethod[] { return results; } -export function normalizeSchemaForTypeScript(schema: JSONSchema7): JSONSchema7 { +export function normalizeSchemaForTypeScript( + schema: JSONSchema7, + opaqueTypeAliases?: Set +): JSONSchema7 { const root = structuredClone(schema) as JSONSchema7 & { definitions?: Record; $defs?: Record; @@ -361,12 +395,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. - stripOpaqueJsonMarker(rewritten); + if (isBareSchemaNode(rewritten as JSONSchema7)) { + if (isOpaqueJson(rewritten as JSONSchema7)) { + rewritten.tsType = "JsonValue"; + opaqueTypeAliases?.add("JsonValue"); + } else if (isOpaqueInProcess(rewritten as JSONSchema7)) { + rewritten.tsType = "OpaqueInProcessValue"; + opaqueTypeAliases?.add("OpaqueInProcessValue"); + } + } + delete rewritten["x-opaque-json"]; + delete rewritten["x-opaque-in-process"]; const enumValueDescriptions = getEnumValueDescriptions(rewritten as JSONSchema7); if (enumValueDescriptions && Array.isArray(rewritten.enum) && rewritten.enum.every((entry) => typeof entry === "string")) { @@ -493,15 +532,23 @@ async function generateSessionEvents(schemaPath?: string): Promise { ); appendPropertyMarkerTagsToDescriptions(schemaForCompile); - const ts = await compile(normalizeSchemaForTypeScript(schemaForCompile), "SessionEvent", { - bannerComment: `/** + const opaqueTypeAliases = new Set(); + const ts = restoreOpaqueTypeAliasFormatting( + await compile(normalizeSchemaForTypeScript(schemaForCompile, opaqueTypeAliases), "SessionEvent", { + bannerComment: [ + `/** * AUTO-GENERATED FILE - DO NOT EDIT * Generated from: session-events.schema.json */`, - style: { semi: true, singleQuote: false, trailingComma: "all" }, - additionalProperties: false, - strictIndexSignatures: true, - }); + opaqueTypeAliasBlock(opaqueTypeAliases), + ] + .filter(Boolean) + .join("\n\n"), + style: { semi: true, singleQuote: false, trailingComma: "all" }, + additionalProperties: false, + strictIndexSignatures: true, + }) + ); let annotatedTs = annotateTypeScriptTypes(ts, experimentalDefinitionNames(definitionCollections), TS_EXPERIMENTAL_JSDOC); // Add @internal JSDoc annotations for session-event types marked @@ -659,6 +706,7 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; if (externalSchemaRefs.size > 0) { lines.push(""); } + const aliasInsertIndex = lines.length; const allMethods = [...collectRpcMethods(schema.server || {}), ...collectRpcMethods(schema.session || {})]; const clientSessionMethods = collectRpcMethods(schema.clientSession || {}); @@ -761,12 +809,17 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; const schemaForCompile = combinedSchema; appendPropertyMarkerTagsToDescriptions(schemaForCompile); - const compiled = await compile(normalizeSchemaForTypeScript(schemaForCompile), "_RpcSchemaRoot", { + const opaqueTypeAliases = new Set(); + const compiled = await compile(normalizeSchemaForTypeScript(schemaForCompile, opaqueTypeAliases), "_RpcSchemaRoot", { bannerComment: "", additionalProperties: false, strictIndexSignatures: true, unreachableDefinitions: true, }); + const aliases = opaqueTypeAliasBlock(opaqueTypeAliases); + if (aliases) { + lines.splice(aliasInsertIndex, 0, aliases, ""); + } // Strip the placeholder root type and keep only the definition-generated types const strippedTs = compiled diff --git a/scripts/codegen/utils.ts b/scripts/codegen/utils.ts index 42e78b9a0..1804990ae 100644 --- a/scripts/codegen/utils.ts +++ b/scripts/codegen/utils.ts @@ -943,6 +943,34 @@ export function isOpaqueJson(schema: JSONSchema7 | null | undefined): boolean { return typeof schema === "object" && schema !== null && (schema as Record)["x-opaque-json"] === true; } +/** Returns true when a JSON Schema node is marked `x-opaque-in-process: true`. */ +export function isOpaqueInProcess(schema: JSONSchema7 | null | undefined): boolean { + return typeof schema === "object" && schema !== null && (schema as Record)["x-opaque-in-process"] === true; +} + +/** + * Returns true when a schema node has no structural constraints that describe a + * more precise TypeScript type than an opaque marker. + */ +export function isBareSchemaNode(schema: JSONSchema7 | null | undefined): boolean { + if (typeof schema !== "object" || schema === null) return false; + const node = schema as Record; + return ![ + "type", + "anyOf", + "oneOf", + "allOf", + "$ref", + "properties", + "items", + "enum", + "const", + "additionalProperties", + "not", + "patternProperties", + ].some((key) => key in node); +} + /** * Removes the `x-opaque-json` marker from a schema node in place. Useful for * codegens (e.g. TypeScript) that don't distinguish opaque JSON from any other