From e1381a8df49a0a6f7273effe073ebfec90ea9f5c Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Mon, 10 Aug 2026 16:13:25 -0700 Subject: [PATCH 01/14] [SDK/Factories] Make The Agent Factories Surface Match The Wire Contract Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ece6b29-8b10-47aa-ab17-b64c47f5fdcd From 6e0805da7d637bf38c79ca25b7d0db3987d16b81 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Mon, 10 Aug 2026 16:23:40 -0700 Subject: [PATCH 02/14] [SDK/Codegen] Map Both Opaque Schema Markers In The TypeScript Generator A bare x-opaque-json node now renders as JsonValue and a bare x-opaque-in-process node as OpaqueInProcessValue, instead of both collapsing to an object index signature. Nodes that also carry a real constraint keep it, so declarations like ExternalToolResult and McpServerConfig retain their unions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ece6b29-8b10-47aa-ab17-b64c47f5fdcd --- nodejs/src/generated/rpc.ts | 268 +++++++++---------------- nodejs/src/generated/session-events.ts | 149 +++++--------- nodejs/src/index.ts | 9 +- nodejs/src/types.ts | 3 +- nodejs/test/typescript-codegen.test.ts | 122 +++++++++++ scripts/codegen/typescript.ts | 83 ++++++-- scripts/codegen/utils.ts | 28 +++ 7 files changed, 368 insertions(+), 294 deletions(-) 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/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/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 From 5343f35f3a914c741d491f3ea972bdc12212838b Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Mon, 10 Aug 2026 16:32:23 -0700 Subject: [PATCH 03/14] [SDK/Factories] Drop The FactoryRunResult Override And The Factory Casts The regenerated wire types now type the factory result and argument fields as JsonValue, so the hand-written FactoryRunResult override, the toPublicFactoryRunResult boundary helper, and four casts are all unnecessary. A compile-time assertion pins the result type so the override cannot creep back. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ece6b29-8b10-47aa-ab17-b64c47f5fdcd --- nodejs/src/factory.ts | 28 +--- nodejs/src/session.ts | 40 ++---- nodejs/test/e2e/factory.e2e.test.ts | 122 +++++++++++++----- .../test/e2e/fixtures/factory-extension.mjs | 11 +- nodejs/test/session-event-types.test.ts | 6 + 5 files changed, 114 insertions(+), 93 deletions(-) diff --git a/nodejs/src/factory.ts b/nodejs/src/factory.ts index 53c0aeca8..ae746e9c5 100644 --- a/nodejs/src/factory.ts +++ b/nodejs/src/factory.ts @@ -6,38 +6,14 @@ import type { FactoryGetRunProgressRequest, FactoryProgressPage, FactoryRunDetail, - FactoryRunResult as WireFactoryRunResult, + FactoryRunResult, FactoryRunStatus, FactoryRunSummary, } from "./generated/rpc.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, diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 5cc49fb75..59a3c66c7 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -16,9 +16,6 @@ import type { CurrentToolMetadata, McpOauthPendingRequestResponse, FactoryLogLine, - FactoryRunRequest, - FactoryExecuteResult, - FactoryJournalPutRequest, FactoryRunResult as WireFactoryRunResult, } from "./generated/rpc.js"; import { type Canvas, CanvasError } from "./canvas.js"; @@ -288,24 +285,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 @@ -453,9 +432,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, }, @@ -485,13 +462,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 +480,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 +539,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 +1356,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) => { @@ -1452,8 +1429,7 @@ export class CopilotSession { runId: params.runId, executionToken: params.executionToken, key, - resultJson: - result as FactoryJournalPutRequest["resultJson"], + resultJson: result, }), controller.signal ); @@ -1470,7 +1446,7 @@ export class CopilotSession { return {}; } assertFactoryResult(result); - return { result } as FactoryExecuteResult; + return { result }; } finally { try { await progress.close(); diff --git a/nodejs/test/e2e/factory.e2e.test.ts b/nodejs/test/e2e/factory.e2e.test.ts index 547ecbbd5..ed44667c7 100644 --- a/nodejs/test/e2e/factory.e2e.test.ts +++ b/nodejs/test/e2e/factory.e2e.test.ts @@ -1,6 +1,6 @@ import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; -import { copyFile, mkdir } from "node:fs/promises"; +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"; @@ -23,55 +23,109 @@ const factoryTestContext = isInProcessTransport }, }); +async function setupFactoryExtension(workDir: string) { + 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: approveAll, + onElicitationRequest: async () => ({ + action: "accept", + content: { action: "approve" }, + }), + }); + + await retry( + "wait for the factory extension to join the session", + async () => { + expect(existsSync(readyFile)).toBe(true); + }, + 300, + 100 + ); + + 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); - await openAiEndpoint.setCopilotUserByToken(DEFAULT_GITHUB_TOKEN, { - login: "factory-e2e-user", - copilot_plan: "individual_pro", - token_based_billing: true, + const result = await session.factory.run("argument-echo", { + args: { source: "sdk-e2e", count: 11 }, }); - const extensionDir = join(workDir, ".github", "extensions", "factory-smoke"); - const readyFile = join(extensionDir, "ready"); - await mkdir(extensionDir, { recursive: true }); - await copyFile( - join(__dirname, "fixtures", "factory-extension.mjs"), - join(extensionDir, "extension.mjs") - ); - execFileSync("git", ["init", "--quiet"], { cwd: workDir }); - - await using session = await copilotClient.createSession({ - requestExtensions: true, - extensionSdkPath: resolve(__dirname, "..", "..", "dist"), - onPermissionRequest: approveAll, - onElicitationRequest: async () => ({ - action: "accept", - content: { action: "approve" }, - }), + expect(result).toMatchObject({ + status: "completed", + result: { source: "sdk-e2e", count: 11 }, }); + } +); + +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); - await retry( - "wait for the factory extension to join the session", - async () => { - expect(existsSync(readyFile)).toBe(true); - }, - 300, - 100 - ); + const result = await session.factory.run("array-result"); - const result = await session.factory.run("argument-echo", { - args: { source: "sdk-e2e", count: 11 }, + expect(result).toMatchObject({ + status: "completed", + 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: { source: "sdk-e2e", count: 11 }, + result: args, }); } ); diff --git a/nodejs/test/e2e/fixtures/factory-extension.mjs b/nodejs/test/e2e/fixtures/factory-extension.mjs index fab95a90f..67aebd32f 100644 --- a/nodejs/test/e2e/fixtures/factory-extension.mjs +++ b/nodejs/test/e2e/fixtures/factory-extension.mjs @@ -10,5 +10,14 @@ const argumentEcho = defineFactory({ run: async ({ args }) => args, }); -await joinSession({ factories: [argumentEcho] }); +const arrayResult = defineFactory({ + meta: { + name: "array-result", + description: "Return an array result.", + phases: [], + }, + run: async () => [1, "two", false], +}); + +await joinSession({ factories: [argumentEcho, arrayResult] }); writeFileSync(new URL("./ready", import.meta.url), "ready"); diff --git a/nodejs/test/session-event-types.test.ts b/nodejs/test/session-event-types.test.ts index 5a8f2ca52..36783a404 100644 --- a/nodejs/test/session-event-types.test.ts +++ b/nodejs/test/session-event-types.test.ts @@ -59,6 +59,7 @@ import type { WorkingDirectoryContextHostType, FactoryContext, FactoryDefinition, + FactoryRunResult, JsonValue, } from "../src/index.js"; @@ -97,6 +98,11 @@ type _DefaultFactoryResultIsJsonValueOrVoid = _AssertEqual< JsonValue | void >; const _defaultFactoryResultCheck: _DefaultFactoryResultIsJsonValueOrVoid = true; +type _FactoryRunResultIsJsonValueOrUndefined = _AssertEqual< + FactoryRunResult["result"], + JsonValue | undefined +>; +const _factoryRunResultCheck: _FactoryRunResultIsJsonValueOrUndefined = 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. From e8685a19a5c90cfbc5e3158780f1b3d0804fbe96 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Mon, 10 Aug 2026 16:40:44 -0700 Subject: [PATCH 04/14] [SDK/Factories] Refuse A Factory Run Started From Inside A Factory Body A factory body could start a second top-level run through any session reference it could reach, escaping the limits the user approved. An AsyncLocalStorage guard now refuses factory.run and factory.resume on the body's call path, before the RPC is dispatched, so no durable run row is created. The guard is per-call-path, so an unrelated concurrent run started elsewhere in the extension still succeeds. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ece6b29-8b10-47aa-ab17-b64c47f5fdcd --- nodejs/src/session.ts | 22 ++- nodejs/test/e2e/factory.e2e.test.ts | 87 +++++++++++- .../test/e2e/fixtures/factory-extension.mjs | 93 ++++++++++++- nodejs/test/factory.test.ts | 127 ++++++++++++++++++ 4 files changed, 324 insertions(+), 5 deletions(-) diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 59a3c66c7..b762d524d 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"; @@ -86,6 +87,16 @@ function isFactoryResumeErrorCode(value: unknown): value is FactoryResumeErrorCo ); } +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 @@ -421,6 +432,7 @@ export class CopilotSession { nameOrHandle: string | FactoryHandle, options?: RunOptions ): Promise => { + throwIfFactoryExecutionIsActive(); const name = typeof nameOrHandle === "string" ? nameOrHandle @@ -441,6 +453,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({ @@ -1441,7 +1454,14 @@ 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 {}; } diff --git a/nodejs/test/e2e/factory.e2e.test.ts b/nodejs/test/e2e/factory.e2e.test.ts index ed44667c7..cc35e523d 100644 --- a/nodejs/test/e2e/factory.e2e.test.ts +++ b/nodejs/test/e2e/factory.e2e.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { existsSync } from "node:fs"; +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"; @@ -93,6 +93,91 @@ it.skipIf(isInProcessTransport)( } ); +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"); + 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 + ); + + writeFileSync(join(extensionDir, "start-b"), "start"); + const bResultFile = join(extensionDir, "b-result"); + await retry( + "wait for the module-level watcher factory run to succeed", + async () => { + expect(existsSync(bResultFile)).toBe(true); + expect(JSON.parse(readFileSync(bResultFile, "utf8"))).toMatchObject({ + status: "success", + result: { + status: "completed", + result: { source: "module-watcher" }, + }, + }); + }, + 100, + 100 + ); + + 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 () => { diff --git a/nodejs/test/e2e/fixtures/factory-extension.mjs b/nodejs/test/e2e/fixtures/factory-extension.mjs index 67aebd32f..182d5a24f 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", @@ -19,5 +31,80 @@ const arrayResult = defineFactory({ run: async () => [1, "two", false], }); -await joinSession({ factories: [argumentEcho, arrayResult] }); -writeFileSync(new URL("./ready", import.meta.url), "ready"); +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"; + }, +}); + +session = await joinSession({ + factories: [ + argumentEcho, + arrayResult, + startsFromContextSession, + startsFromModuleSession, + parked, + ], +}); + +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..273b72be0 100644 --- a/nodejs/test/factory.test.ts +++ b/nodejs/test/factory.test.ts @@ -569,6 +569,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); From a730a67c535cfef4eb497401d93e4525ceb34e51 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Mon, 10 Aug 2026 16:45:38 -0700 Subject: [PATCH 05/14] [SDK/Factories] Correct The Factory Resume Error Code Union The union exported two codes no runtime path raises and omitted five it does, so a caller could branch on a dead code and receive a raw RpcResponseError for a real one. It now names exactly the codes execute_resume raises before a resumed run starts. permission_denied is deliberately excluded: an SDK-initiated resume dispatches with RunOrigin::default(), so the approval branch never runs and the code is unreachable from this path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ece6b29-8b10-47aa-ab17-b64c47f5fdcd --- nodejs/src/factory.ts | 7 +- nodejs/src/session.ts | 7 +- nodejs/test/e2e/factory.e2e.test.ts | 83 ++++++++++++++++++- .../test/e2e/fixtures/factory-extension.mjs | 16 ++++ nodejs/test/factory.test.ts | 22 ++++- 5 files changed, 125 insertions(+), 10 deletions(-) diff --git a/nodejs/src/factory.ts b/nodejs/src/factory.ts index ae746e9c5..2bc385037 100644 --- a/nodejs/src/factory.ts +++ b/nodejs/src/factory.ts @@ -251,8 +251,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. diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index b762d524d..a139e9dc5 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -82,8 +82,11 @@ 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" ); } diff --git a/nodejs/test/e2e/factory.e2e.test.ts b/nodejs/test/e2e/factory.e2e.test.ts index cc35e523d..37604e4db 100644 --- a/nodejs/test/e2e/factory.e2e.test.ts +++ b/nodejs/test/e2e/factory.e2e.test.ts @@ -3,8 +3,8 @@ 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,7 +23,7 @@ const factoryTestContext = isInProcessTransport }, }); -async function setupFactoryExtension(workDir: string) { +async function setupFactoryExtension(workDir: string, onPermissionRequest = approveAll) { if (!factoryTestContext) { throw new Error("Factory E2E requires the stdio transport"); } @@ -54,7 +54,7 @@ async function setupFactoryExtension(workDir: string) { const session = await copilotClient.createSession({ requestExtensions: true, extensionSdkPath: resolve(__dirname, "..", "..", "dist"), - onPermissionRequest: approveAll, + onPermissionRequest, onElicitationRequest: async () => ({ action: "accept", content: { action: "approve" }, @@ -93,6 +93,81 @@ it.skipIf(isInProcessTransport)( } ); +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); + + 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 () => { diff --git a/nodejs/test/e2e/fixtures/factory-extension.mjs b/nodejs/test/e2e/fixtures/factory-extension.mjs index 182d5a24f..3d18d1930 100644 --- a/nodejs/test/e2e/fixtures/factory-extension.mjs +++ b/nodejs/test/e2e/fixtures/factory-extension.mjs @@ -78,6 +78,21 @@ const parked = defineFactory({ }, }); +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, @@ -85,6 +100,7 @@ session = await joinSession({ startsFromContextSession, startsFromModuleSession, parked, + failsOnce, ], }); diff --git a/nodejs/test/factory.test.ts b/nodejs/test/factory.test.ts index 273b72be0..a308360ef 100644 --- a/nodejs/test/factory.test.ts +++ b/nodejs/test/factory.test.ts @@ -1863,8 +1863,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) => { @@ -1882,6 +1885,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", From 73422335002bcb72a2078c250c16bba8da7f14d7 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Mon, 10 Aug 2026 16:51:49 -0700 Subject: [PATCH 06/14] [SDK/Factories] Forward Every Declared Subagent Option From ctx.agent The hand-written FactoryAgentOptions declared only label, schema and model, and the agent implementation rebuilt the request from those three, so agent, reasoningEffort and contextTier were dropped before the request was sent. The options are now declared once as a key tuple and copied from it, and two compile-time assertions pin that tuple to both the public and the wire interface, so a future wire option fails the build instead of being silently dropped. Undeclared keys are still filtered out, because the wire schema forbids them. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ece6b29-8b10-47aa-ab17-b64c47f5fdcd --- nodejs/src/factory.ts | 13 +++ nodejs/src/session.ts | 23 +++-- nodejs/test/e2e/factory.e2e.test.ts | 18 ++++ .../test/e2e/fixtures/factory-extension.mjs | 21 +++++ nodejs/test/factory.test.ts | 89 +++++++++++++++++++ nodejs/test/session-event-types.test.ts | 13 +++ 6 files changed, 172 insertions(+), 5 deletions(-) diff --git a/nodejs/src/factory.ts b/nodejs/src/factory.ts index 2bc385037..3ec336888 100644 --- a/nodejs/src/factory.ts +++ b/nodejs/src/factory.ts @@ -10,6 +10,7 @@ import type { 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"; @@ -91,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. * diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index a139e9dc5..b60c14244 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -64,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, @@ -90,6 +92,17 @@ function isFactoryResumeErrorCode(value: unknown): value is FactoryResumeErrorCo ); } +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 { @@ -1385,17 +1398,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 ); diff --git a/nodejs/test/e2e/factory.e2e.test.ts b/nodejs/test/e2e/factory.e2e.test.ts index 37604e4db..fe02e8634 100644 --- a/nodejs/test/e2e/factory.e2e.test.ts +++ b/nodejs/test/e2e/factory.e2e.test.ts @@ -93,6 +93,24 @@ it.skipIf(isInProcessTransport)( } ); +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 }, + }); + } +); + it.skipIf(isInProcessTransport)( "throws FactoryResumeError with not_found for an unknown run", async () => { diff --git a/nodejs/test/e2e/fixtures/factory-extension.mjs b/nodejs/test/e2e/fixtures/factory-extension.mjs index 3d18d1930..4368a16cc 100644 --- a/nodejs/test/e2e/fixtures/factory-extension.mjs +++ b/nodejs/test/e2e/fixtures/factory-extension.mjs @@ -31,6 +31,26 @@ const arrayResult = defineFactory({ 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 }) => { + try { + await agent("Confirm that this request is accepted.", { + agent: "reviewer", + reasoningEffort: "high", + contextTier: "long_context", + }); + return { didThrow: false }; + } catch { + return { didThrow: true }; + } + }, +}); + const startsFromContextSession = defineFactory({ meta: { name: "starts-from-context-session", @@ -97,6 +117,7 @@ session = await joinSession({ factories: [ argumentEcho, arrayResult, + forwardsSubagentOptions, startsFromContextSession, startsFromModuleSession, parked, diff --git a/nodejs/test/factory.test.ts b/nodejs/test/factory.test.ts index a308360ef..07833ad44 100644 --- a/nodejs/test/factory.test.ts +++ b/nodejs/test/factory.test.ts @@ -780,6 +780,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") { diff --git a/nodejs/test/session-event-types.test.ts b/nodejs/test/session-event-types.test.ts index 36783a404..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,7 @@ import type { WorkingDirectoryContextHostType, FactoryContext, FactoryDefinition, + FactoryAgentOptions, FactoryRunResult, JsonValue, } from "../src/index.js"; @@ -103,6 +106,16 @@ type _FactoryRunResultIsJsonValueOrUndefined = _AssertEqual< 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. From fd799efbc0984d8b0f8a1150f09a1560aef5daa3 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Mon, 10 Aug 2026 16:56:01 -0700 Subject: [PATCH 07/14] [SDK/Factories] Stop A Latched Progress Flush Error From Downgrading A Run A background progress flush that failed earlier latched its error, and close() rethrew it from the factory execute finally block, so a factory body that succeeded settled as an error. The latched error is now best effort and warns, matching the treatment the final send already had. A mid-body flush failure stays fatal, because a running body that cannot record progress must not continue. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ece6b29-8b10-47aa-ab17-b64c47f5fdcd --- nodejs/src/session.ts | 5 +++- nodejs/test/factory.test.ts | 50 +++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index b60c14244..42e30d6f3 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -279,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 { diff --git a/nodejs/test/factory.test.ts b/nodejs/test/factory.test.ts index 07833ad44..7d08baca4 100644 --- a/nodejs/test/factory.test.ts +++ b/nodejs/test/factory.test.ts @@ -1647,6 +1647,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") { From 5e8602f1ee20de48b7216a66f6d1d5f3e6338511 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Mon, 10 Aug 2026 17:04:32 -0700 Subject: [PATCH 08/14] [SDK/Factories] Correct The Factories Guide And Published API Comments The guide and four JSDoc comments described behavior that does not exist: a declined SDK-initiated run resolving as cancelled, a single-active-run limit, two error codes no runtime path raises, an unpaginated listRuns, and a three-option ctx.agent. They now match the shipped surface, including that the SDK forwards agent, reasoningEffort and contextTier while the current runtime does not yet honor them. File-content assertions guard both files, which nothing else covers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ece6b29-8b10-47aa-ab17-b64c47f5fdcd --- nodejs/docs/factories.md | 8 ++--- nodejs/src/factory.ts | 26 +++++++++++----- nodejs/test/factory.test.ts | 60 +++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 12 deletions(-) diff --git a/nodejs/docs/factories.md b/nodejs/docs/factories.md index 0c1f0f09a..19d228a04 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`. The SDK forwards `agent`, `reasoningEffort`, and `contextTier`, but the current runtime does not yet honor them. 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 full session returned by `joinSession`. It remains the full session, but `factory.run` and `factory.resume` are refused while the factory body runs on the same call path. - `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 four top-level runs are already active. 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 (the SDK sends `{}`, so the runtime defaults to 200 runs and caps the page at 500). - `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 3ec336888..e1f1e9282 100644 --- a/nodejs/src/factory.ts +++ b/nodejs/src/factory.ts @@ -174,7 +174,12 @@ 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 same full session instance returned by `joinSession`. + * + * While the factory body runs, `factory.run` and `factory.resume` are + * refused on the same call path. + */ session: CopilotSession; /** Cooperative cancellation signal for the current factory run. */ signal: AbortSignal; @@ -282,9 +287,11 @@ 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 fifth top-level run while four are active) still reject. */ run(name: string, options?: RunOptions): Promise; run( @@ -294,9 +301,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. */ @@ -316,7 +323,10 @@ 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 (the SDK sends `{}`, so the runtime defaults + * to 200 runs and caps the page at 500). + */ listRuns(): Promise; /** Read durable phases, direct agents, and the latest progress tail for a run. */ getRunDetail(runId: string): Promise; diff --git a/nodejs/test/factory.test.ts b/nodejs/test/factory.test.ts index 7d08baca4..d1c7430e5 100644 --- a/nodejs/test/factory.test.ts +++ b/nodejs/test/factory.test.ts @@ -402,6 +402,66 @@ 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 (the SDK sends `{}`, so the runtime defaults to 200 runs and caps the page at 500)"; + 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("only when four top-level runs are already active"); + for (const code of resumeCodes) { + expect(guide).toContain(`\`${code}\``); + } + expect(guide).toContain( + "Options are exactly `label`, `schema`, `model`, `agent`, `reasoningEffort`, and `contextTier`" + ); + expect(guide).toContain( + "The SDK forwards `agent`, `reasoningEffort`, and `contextTier`" + ); + expect(guide).toContain("the current runtime does not yet honor them"); + expect(normalizedGuide).toContain("full session returned by `joinSession`"); + expect(normalizedGuide).toContain("factory body runs on the same call path"); + + expect(normalizedPublicApi).toContain("SDK-initiated runs do not request permission"); + expect(normalizedPublicApi).toContain("declining it creates no run row"); + expect(normalizedPublicApi).toContain("fifth top-level run while four are active"); + expect(normalizedPublicApi).toContain("SDK-initiated resumes do not request permission"); + expect(normalizedPublicApi).toContain("with a documented resume code rejects with"); + expect(normalizedPublicApi).toContain("same full session instance returned by `joinSession`"); + expect(normalizedPublicApi).toContain( + "factory.run` and `factory.resume` are refused on the same call path" + ); + }); + it("serializes only factory metadata in the extension resume payload", async () => { const client = new CopilotClient(); await client.start(); From cdb924c6fda0a57719e767e02bc3060263dec3eb Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Mon, 10 Aug 2026 17:10:18 -0700 Subject: [PATCH 09/14] Add changelog entry for the Agent Factories wire-contract corrections Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ece6b29-8b10-47aa-ab17-b64c47f5fdcd --- CHANGELOG.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9f22a3df..634e7f54d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,27 @@ var session = await client.CreateSessionAsync(new SessionConfig }); ``` +### Fix: Agent Factories types and behavior now match the wire contract + +The `@experimental` Agent Factories surface described several things the runtime does not do, and the TypeScript generator was the root cause. The schema marks an opaque value that travels as JSON with `x-opaque-json`, and one that never serializes with `x-opaque-in-process`. The generator read neither marker, so both kinds of value rendered as an object index signature. + +`FactoryRunResult.result` and factory arguments are now `JsonValue`, so an array, a string, a number, or `null` fits the type the runtime already sent. `ctx.agent()` gains `agent`, `reasoningEffort`, and `contextTier`, which the SDK previously dropped before sending. The SDK forwards them, and the current runtime accepts but does not yet honor them. + +`FactoryResumeErrorCode` now names the eight codes the runtime raises before a resumed run starts. `reapproval_declined` and `no_approval_provider` are removed, because no runtime path raises them. `factory_already_running`, `factory_limits_invalid`, `factory_session_disposed`, `factory_storage_unavailable`, and `factory_storage_corrupt` are added. + +A factory body can no longer start a second top-level run. `factory.run` and `factory.resume` are refused while a factory body runs on the same call path, through any session reference the body reaches. A run started elsewhere in the extension is unaffected. A background progress-flush error no longer turns a completed run into an errored run. + +```ts +const run = await session.factory.run("collect-findings"); +if (run.status === "completed" && Array.isArray(run.result)) { + for (const finding of run.result) { + console.log(finding); + } +} +``` + +Correcting the two markers also retypes declarations outside the factory surface. `CanvasJsonSchema`, `CanvasActionInvokeResult`, `ElicitationCompletedContent`, and `CustomNotificationPayload` change from interfaces to type aliases, and `ElicitationCompletedContent` becomes optional. `ToolTelemetry` narrows its inner record to `Record`, which is what the wire accepts. + ## [v1.0.7](https://github.com/github/copilot-sdk/releases/tag/v1.0.7) (2026-07-16) ### Feature: in-process (FFI) transport From c69fcf1f351499fe8e6736211c548c5f55c0fab2 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Mon, 10 Aug 2026 17:17:48 -0700 Subject: [PATCH 10/14] Drop the transient runtime-support caveat from the factories docs The claim that the runtime does not yet honor agent, reasoningEffort and contextTier is a point-in-time fact about another repository. It rots as soon as the runtime lands support, so the SDK docs no longer carry it. Also wraps three over-length test assertions that the prettier check flagged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ece6b29-8b10-47aa-ab17-b64c47f5fdcd --- CHANGELOG.md | 2 +- nodejs/docs/factories.md | 2 +- nodejs/test/factory.test.ts | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 634e7f54d..f89098d83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,7 +46,7 @@ var session = await client.CreateSessionAsync(new SessionConfig The `@experimental` Agent Factories surface described several things the runtime does not do, and the TypeScript generator was the root cause. The schema marks an opaque value that travels as JSON with `x-opaque-json`, and one that never serializes with `x-opaque-in-process`. The generator read neither marker, so both kinds of value rendered as an object index signature. -`FactoryRunResult.result` and factory arguments are now `JsonValue`, so an array, a string, a number, or `null` fits the type the runtime already sent. `ctx.agent()` gains `agent`, `reasoningEffort`, and `contextTier`, which the SDK previously dropped before sending. The SDK forwards them, and the current runtime accepts but does not yet honor them. +`FactoryRunResult.result` and factory arguments are now `JsonValue`, so an array, a string, a number, or `null` fits the type the runtime already sent. `ctx.agent()` gains `agent`, `reasoningEffort`, and `contextTier`, which the SDK previously dropped before sending. `FactoryResumeErrorCode` now names the eight codes the runtime raises before a resumed run starts. `reapproval_declined` and `no_approval_provider` are removed, because no runtime path raises them. `factory_already_running`, `factory_limits_invalid`, `factory_session_disposed`, `factory_storage_unavailable`, and `factory_storage_corrupt` are added. diff --git a/nodejs/docs/factories.md b/nodejs/docs/factories.md index 19d228a04..30d82676b 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`, `model`, `agent`, `reasoningEffort`, and `contextTier`. The SDK forwards `agent`, `reasoningEffort`, and `contextTier`, but the current runtime does not yet honor them. 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. diff --git a/nodejs/test/factory.test.ts b/nodejs/test/factory.test.ts index d1c7430e5..7fc363465 100644 --- a/nodejs/test/factory.test.ts +++ b/nodejs/test/factory.test.ts @@ -435,7 +435,9 @@ describe("factories", () => { 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( + "`run_factory` tool requests permission before the durable row exists" + ); expect(normalizedGuide).toContain("declining it creates no run row"); expect(normalizedGuide).toContain("only when four top-level runs are already active"); for (const code of resumeCodes) { @@ -444,10 +446,6 @@ describe("factories", () => { expect(guide).toContain( "Options are exactly `label`, `schema`, `model`, `agent`, `reasoningEffort`, and `contextTier`" ); - expect(guide).toContain( - "The SDK forwards `agent`, `reasoningEffort`, and `contextTier`" - ); - expect(guide).toContain("the current runtime does not yet honor them"); expect(normalizedGuide).toContain("full session returned by `joinSession`"); expect(normalizedGuide).toContain("factory body runs on the same call path"); @@ -456,7 +454,9 @@ describe("factories", () => { expect(normalizedPublicApi).toContain("fifth top-level run while four are active"); expect(normalizedPublicApi).toContain("SDK-initiated resumes do not request permission"); expect(normalizedPublicApi).toContain("with a documented resume code rejects with"); - expect(normalizedPublicApi).toContain("same full session instance returned by `joinSession`"); + expect(normalizedPublicApi).toContain( + "same full session instance returned by `joinSession`" + ); expect(normalizedPublicApi).toContain( "factory.run` and `factory.resume` are refused on the same call path" ); From abe3e19aa86bc09204fadf36c84a577b6b0f4985 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Mon, 10 Aug 2026 17:20:53 -0700 Subject: [PATCH 11/14] Simplify factory docs so they do not encode transient facts - Describe ctx.session by what it omits, and point at the extensions_manage guide - Drop the hardcoded active-run limit, which will become a setting - Drop the listRuns paging parenthetical Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ece6b29-8b10-47aa-ab17-b64c47f5fdcd --- nodejs/docs/factories.md | 6 +++--- nodejs/src/factory.ts | 12 +++++------- nodejs/test/factory.test.ts | 19 +++++++++---------- 3 files changed, 17 insertions(+), 20 deletions(-) diff --git a/nodejs/docs/factories.md b/nodejs/docs/factories.md index 30d82676b..26a61cca6 100644 --- a/nodejs/docs/factories.md +++ b/nodejs/docs/factories.md @@ -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`. It remains the full session, but `factory.run` and `factory.resume` are refused while the factory body runs on the same call path. +- `ctx.session`: The session returned by `joinSession`, without the APIs that start and resume factory runs. 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`. 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 four top-level runs are already active. 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`. +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 the newest default page (the SDK sends `{}`, so the runtime defaults to 200 runs and caps the page at 500). +- `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 e1f1e9282..4f0a2badc 100644 --- a/nodejs/src/factory.ts +++ b/nodejs/src/factory.ts @@ -175,10 +175,8 @@ export interface FactoryContext { /** Caller-supplied input, forwarded verbatim. */ args: TArgs; /** - * The same full session instance returned by `joinSession`. - * - * While the factory body runs, `factory.run` and `factory.resume` are - * refused on the same call path. + * The session instance returned by `joinSession`, without the APIs that + * start and resume factory runs. */ session: CopilotSession; /** Cooperative cancellation signal for the current factory run. */ @@ -291,7 +289,8 @@ export interface SessionFactoryApi { * 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 fifth top-level run while four are active) still reject. + * to start a run while the session is at its active top-level run limit) + * still reject. */ run(name: string, options?: RunOptions): Promise; run( @@ -324,8 +323,7 @@ export interface SessionFactoryApi { */ waitForRun(runId: string, options?: { signal?: AbortSignal }): Promise; /** - * List the newest default page (the SDK sends `{}`, so the runtime defaults - * to 200 runs and caps the page at 500). + * 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. */ diff --git a/nodejs/test/factory.test.ts b/nodejs/test/factory.test.ts index 7fc363465..b040e3f94 100644 --- a/nodejs/test/factory.test.ts +++ b/nodejs/test/factory.test.ts @@ -405,8 +405,7 @@ describe("factories", () => { 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 (the SDK sends `{}`, so the runtime defaults to 200 runs and caps the page at 500)"; + const listRunsPagingWording = "newest default page of this session's durable factory runs"; const resumeCodes = [ "not_found", "non_resumable", @@ -439,26 +438,26 @@ describe("factories", () => { "`run_factory` tool requests permission before the durable row exists" ); expect(normalizedGuide).toContain("declining it creates no run row"); - expect(normalizedGuide).toContain("only when four top-level runs are already active"); + 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("full session returned by `joinSession`"); - expect(normalizedGuide).toContain("factory body runs on the same call path"); + expect(normalizedGuide).toContain( + "session returned by `joinSession`, without the APIs that start and resume factory runs" + ); expect(normalizedPublicApi).toContain("SDK-initiated runs do not request permission"); expect(normalizedPublicApi).toContain("declining it creates no run row"); - expect(normalizedPublicApi).toContain("fifth top-level run while four are active"); - expect(normalizedPublicApi).toContain("SDK-initiated resumes do not request permission"); - expect(normalizedPublicApi).toContain("with a documented resume code rejects with"); expect(normalizedPublicApi).toContain( - "same full session instance returned by `joinSession`" + "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( - "factory.run` and `factory.resume` are refused on the same call path" + "session instance returned by `joinSession`, without the APIs that start and resume factory runs" ); }); From dde6cf6d91dc082de01e4a8963bb25cfa1a80a93 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Mon, 10 Aug 2026 17:30:02 -0700 Subject: [PATCH 12/14] Stop the subagent-option E2E from waiting on a model response The factory awaited its subagent to completion, so the test hung wherever no cached model response exists and timed out at 30s on CI. Only the runtime's acceptance of the option payload is under test, and a refused request rejects before a subagent starts. The factory now races the call against a short timer and returns as soon as the request is accepted. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ece6b29-8b10-47aa-ab17-b64c47f5fdcd --- nodejs/test/e2e/factory.e2e.test.ts | 5 +++- .../test/e2e/fixtures/factory-extension.mjs | 25 +++++++++++++++---- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/nodejs/test/e2e/factory.e2e.test.ts b/nodejs/test/e2e/factory.e2e.test.ts index fe02e8634..8c038d9de 100644 --- a/nodejs/test/e2e/factory.e2e.test.ts +++ b/nodejs/test/e2e/factory.e2e.test.ts @@ -108,7 +108,10 @@ it.skipIf(isInProcessTransport)( 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)( diff --git a/nodejs/test/e2e/fixtures/factory-extension.mjs b/nodejs/test/e2e/fixtures/factory-extension.mjs index 4368a16cc..5f6c19e2b 100644 --- a/nodejs/test/e2e/fixtures/factory-extension.mjs +++ b/nodejs/test/e2e/fixtures/factory-extension.mjs @@ -38,15 +38,30 @@ const forwardsSubagentOptions = defineFactory({ 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 agent("Confirm that this request is accepted.", { - agent: "reviewer", - reasoningEffort: "high", - contextTier: "long_context", - }); + await Promise.race([call, stillPending]); return { didThrow: false }; } catch { return { didThrow: true }; + } finally { + clearTimeout(settleTimer); } }, }); From 343aaddf25fa7bd5c71614e8f503c5e2bb5cf21e Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Mon, 10 Aug 2026 17:46:56 -0700 Subject: [PATCH 13/14] Describe ctx.session by how it behaves, not by what it lacks The context session is a full CopilotSession, so factory.run and factory.resume are present and callable. Saying the APIs are absent contradicted the exported type. The guide and the published comment now say the session refuses those calls. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ece6b29-8b10-47aa-ab17-b64c47f5fdcd --- nodejs/docs/factories.md | 2 +- nodejs/src/factory.ts | 4 ++-- nodejs/test/factory.test.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/nodejs/docs/factories.md b/nodejs/docs/factories.md index 26a61cca6..a22767905 100644 --- a/nodejs/docs/factories.md +++ b/nodejs/docs/factories.md @@ -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 session returned by `joinSession`, without the APIs that start and resume factory runs. Call `extensions_manage` with `operation: "guide"` to read more about the session APIs. +- `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. diff --git a/nodejs/src/factory.ts b/nodejs/src/factory.ts index 4f0a2badc..8ad1c7acb 100644 --- a/nodejs/src/factory.ts +++ b/nodejs/src/factory.ts @@ -175,8 +175,8 @@ export interface FactoryContext { /** Caller-supplied input, forwarded verbatim. */ args: TArgs; /** - * The session instance returned by `joinSession`, without the APIs that - * start and resume factory runs. + * 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. */ diff --git a/nodejs/test/factory.test.ts b/nodejs/test/factory.test.ts index b040e3f94..f550c3bc9 100644 --- a/nodejs/test/factory.test.ts +++ b/nodejs/test/factory.test.ts @@ -446,7 +446,7 @@ describe("factories", () => { "Options are exactly `label`, `schema`, `model`, `agent`, `reasoningEffort`, and `contextTier`" ); expect(normalizedGuide).toContain( - "session returned by `joinSession`, without the APIs that start and resume factory runs" + "session returned by `joinSession`. It refuses calls that start or resume a factory run" ); expect(normalizedPublicApi).toContain("SDK-initiated runs do not request permission"); @@ -457,7 +457,7 @@ describe("factories", () => { 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`, without the APIs that start and resume factory runs" + "session instance returned by `joinSession`. It refuses calls that start or resume a factory run" ); }); From e85ad903a89a33dcb2cd82077ff15b6e96426814 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Mon, 10 Aug 2026 17:49:35 -0700 Subject: [PATCH 14/14] Drop the hand-written changelog entry The changelog is generated at release time, so an entry added by hand in a feature PR does not fit the file's convention. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ece6b29-8b10-47aa-ab17-b64c47f5fdcd --- CHANGELOG.md | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f89098d83..e9f22a3df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,27 +42,6 @@ var session = await client.CreateSessionAsync(new SessionConfig }); ``` -### Fix: Agent Factories types and behavior now match the wire contract - -The `@experimental` Agent Factories surface described several things the runtime does not do, and the TypeScript generator was the root cause. The schema marks an opaque value that travels as JSON with `x-opaque-json`, and one that never serializes with `x-opaque-in-process`. The generator read neither marker, so both kinds of value rendered as an object index signature. - -`FactoryRunResult.result` and factory arguments are now `JsonValue`, so an array, a string, a number, or `null` fits the type the runtime already sent. `ctx.agent()` gains `agent`, `reasoningEffort`, and `contextTier`, which the SDK previously dropped before sending. - -`FactoryResumeErrorCode` now names the eight codes the runtime raises before a resumed run starts. `reapproval_declined` and `no_approval_provider` are removed, because no runtime path raises them. `factory_already_running`, `factory_limits_invalid`, `factory_session_disposed`, `factory_storage_unavailable`, and `factory_storage_corrupt` are added. - -A factory body can no longer start a second top-level run. `factory.run` and `factory.resume` are refused while a factory body runs on the same call path, through any session reference the body reaches. A run started elsewhere in the extension is unaffected. A background progress-flush error no longer turns a completed run into an errored run. - -```ts -const run = await session.factory.run("collect-findings"); -if (run.status === "completed" && Array.isArray(run.result)) { - for (const finding of run.result) { - console.log(finding); - } -} -``` - -Correcting the two markers also retypes declarations outside the factory surface. `CanvasJsonSchema`, `CanvasActionInvokeResult`, `ElicitationCompletedContent`, and `CustomNotificationPayload` change from interfaces to type aliases, and `ElicitationCompletedContent` becomes optional. `ToolTelemetry` narrows its inner record to `Record`, which is what the wire accepts. - ## [v1.0.7](https://github.com/github/copilot-sdk/releases/tag/v1.0.7) (2026-07-16) ### Feature: in-process (FFI) transport