From b9d143b375ce74795d57842e50eb4114cdc40711 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Wed, 15 Jul 2026 11:58:44 -0700 Subject: [PATCH 01/22] Phase 1: Contract skeleton and feature flag Regenerate SDK wire types (nodejs/src/generated/rpc.ts) from the updated runtime schema for the new workflow.* methods and DTOs, and mirror WorkflowMeta/WorkflowLimits in the SDK public types (nodejs/src/types.ts) with exports via index.ts/extension.ts. No behavior wired (contract skeleton only). Verification: cd nodejs && npm run build (Node 22) exit 0. Deviations: none. --- nodejs/src/extension.ts | 3 +- nodejs/src/generated/rpc.ts | 485 ++++++++++++++++++++++++++++++++++++ nodejs/src/index.ts | 2 + nodejs/src/types.ts | 22 ++ 4 files changed, 510 insertions(+), 2 deletions(-) diff --git a/nodejs/src/extension.ts b/nodejs/src/extension.ts index 72bde93bde..6cec2674f7 100644 --- a/nodejs/src/extension.ts +++ b/nodejs/src/extension.ts @@ -6,7 +6,6 @@ import { CopilotClient } from "./client.js"; import type { CopilotSession } from "./session.js"; import { defaultJoinSessionPermissionHandler, - type ExtensionInfo, type PermissionHandler, type ResumeSessionConfig, } from "./types.js"; @@ -29,7 +28,7 @@ export type JoinSessionConfig = Omit< onPermissionRequest?: PermissionHandler; }; -export type { ExtensionInfo }; +export type { ExtensionInfo, WorkflowLimits, WorkflowMeta } from "./types.js"; /** * Joins the current foreground session. diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 255a769d55..e8c2e852c2 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -540,6 +540,8 @@ export type HookType = | "postToolUseFailure" /** Runs after the user submits a prompt. */ | "userPromptSubmitted" + /** Runs after the runtime transforms the submitted prompt for the model, before it is added to session history. */ + | "userPromptTransformed" /** Runs when a session starts. */ | "sessionStart" /** Runs when a session ends. */ @@ -2113,6 +2115,38 @@ export type UISessionLimitsExhaustedResponseAction = | "unset" /** Leave the limit unchanged and cancel the blocked model request. */ | "cancel"; +/** + * Kind of workflow progress line. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "WorkflowLogLineKind". + */ +/** @experimental */ +export type WorkflowLogLineKind = + /** A narrator log line. */ + | "log" + /** A named workflow phase marker. */ + | "phase"; +/** + * Current or terminal state of a workflow run. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "WorkflowRunStatus". + */ +/** @experimental */ +export type WorkflowRunStatus = + /** The run was minted and is awaiting approval. */ + | "pending" + /** The run is executing. */ + | "running" + /** The run completed successfully. */ + | "completed" + /** The run stopped after reaching a resource ceiling. */ + | "halted" + /** The run was cancelled before completion. */ + | "cancelled" + /** The workflow body failed. */ + | "error"; /** * Type of change represented by this file diff. * @@ -11044,6 +11078,23 @@ export interface RemoteSessionRepository { */ branch?: string; } +/** + * Options controlling workflow invocation. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "RunOptions". + */ +/** @experimental */ +export interface RunOptions { + /** + * Whether to return once the approved run starts instead of awaiting its terminal result. + */ + background?: boolean; + /** + * Run identifier whose journal and progress should seed this resumed run. + */ + resumeFromRunId?: string; +} /** * Resolved sandbox configuration. * @@ -15559,6 +15610,330 @@ export interface VisibilitySetResult { */ shareUrl?: string; } +/** + * Parameters for cooperatively aborting a workflow body. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "WorkflowAbortRequest". + */ +/** @experimental */ +export interface WorkflowAbortRequest { + /** + * Target session identifier + */ + sessionId: string; + /** + * Workflow run identifier. + */ + runId: string; +} +/** + * Acknowledgement that a workflow request was accepted. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "WorkflowAckResult". + */ +/** @experimental */ +export interface WorkflowAckResult {} +/** + * Options for one workflow-scoped subagent call. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "WorkflowAgentOptions". + */ +/** @experimental */ +export interface WorkflowAgentOptions { + /** + * Optional label distinguishing otherwise identical memoized agent calls. + */ + label?: string; + /** + * Optional JSON Schema for structured agent output. + */ + schema?: { + [k: string]: unknown | undefined; + }; + /** + * Optional model identifier for the subagent. + */ + model?: string; +} +/** + * Parameters for one workflow-scoped subagent call. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "WorkflowAgentRequest". + */ +/** @experimental */ +export interface WorkflowAgentRequest { + /** + * Workflow run identifier that owns the subagent. + */ + workflowRunId: string; + /** + * Prompt to send to the subagent. + */ + prompt: string; + opts: WorkflowAgentOptions; +} +/** + * Result of one workflow-scoped subagent call. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "WorkflowAgentResult". + */ +/** @experimental */ +export interface WorkflowAgentResult { + /** + * Agent result, omitted when the agent produced no result. + */ + result?: { + [k: string]: unknown | undefined; + }; +} +/** + * Parameters for cancelling a workflow run. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "WorkflowCancelRequest". + */ +/** @experimental */ +export interface WorkflowCancelRequest { + /** + * Workflow run identifier. + */ + runId: string; +} +/** + * Parameters sent to the owning extension to execute a workflow closure. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "WorkflowExecuteRequest". + */ +/** @experimental */ +export interface WorkflowExecuteRequest { + /** + * Target session identifier + */ + sessionId: string; + /** + * Registered workflow name. + */ + name: string; + /** + * Workflow run identifier. + */ + runId: string; + /** + * Workflow input value. + */ + args: { + [k: string]: unknown | undefined; + }; + /** + * Parent workflow run identifier for nested execution. + */ + parentRunId?: string; +} +/** + * Result returned by an extension workflow closure. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "WorkflowExecuteResult". + */ +/** @experimental */ +export interface WorkflowExecuteResult { + /** + * Workflow result value. + */ + result: { + [k: string]: unknown | undefined; + }; +} +/** + * Parameters for retrieving a workflow run. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "WorkflowGetRunRequest". + */ +/** @experimental */ +export interface WorkflowGetRunRequest { + /** + * Workflow run identifier. + */ + runId: string; +} +/** + * Parameters for reading a workflow journal entry. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "WorkflowJournalGetRequest". + */ +/** @experimental */ +export interface WorkflowJournalGetRequest { + /** + * Workflow run identifier. + */ + runId: string; + /** + * Namespaced journal key. + */ + key: string; +} +/** + * Result of reading a workflow journal entry. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "WorkflowJournalGetResult". + */ +/** @experimental */ +export interface WorkflowJournalGetResult { + /** + * Whether the journal contained the requested key. + */ + hit: boolean; + /** + * Cached JSON result. The hit field distinguishes a cached JSON null from a miss. + */ + resultJson?: { + [k: string]: unknown | undefined; + }; +} +/** + * Parameters for storing a workflow journal entry. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "WorkflowJournalPutRequest". + */ +/** @experimental */ +export interface WorkflowJournalPutRequest { + /** + * Workflow run identifier. + */ + runId: string; + /** + * Namespaced journal key. + */ + key: string; + /** + * JSON result to memoize. + */ + resultJson: { + [k: string]: unknown | undefined; + }; +} +/** + * One ordered workflow progress line. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "WorkflowLogLine". + */ +/** @experimental */ +export interface WorkflowLogLine { + /** + * Monotonic sequence number within the workflow run. + */ + seq: number; + kind: WorkflowLogLineKind; + /** + * Progress text. + */ + text: string; +} +/** + * Parameters for recording workflow progress. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "WorkflowLogRequest". + */ +/** @experimental */ +export interface WorkflowLogRequest { + /** + * Workflow run identifier. + */ + runId: string; + /** + * Ordered progress lines to append. + */ + lines: WorkflowLogLine[]; +} +/** + * Parameters for invoking a nested workflow. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "WorkflowRunNestedRequest". + */ +/** @experimental */ +export interface WorkflowRunNestedRequest { + /** + * Parent workflow run identifier. + */ + parentRunId: string; + /** + * Registered child workflow name. + */ + name: string; + /** + * Child workflow input value. + */ + args: { + [k: string]: unknown | undefined; + }; +} +/** + * Parameters for invoking a registered workflow. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "WorkflowRunRequest". + */ +/** @experimental */ +export interface WorkflowRunRequest { + /** + * Registered workflow name. + */ + name: string; + /** + * Workflow input value. + */ + args: { + [k: string]: unknown | undefined; + }; + options?: RunOptions; +} +/** + * Complete current or terminal workflow run envelope. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "WorkflowRunResult". + */ +/** @experimental */ +export interface WorkflowRunResult { + /** + * Workflow run identifier. + */ + runId: string; + status: WorkflowRunStatus; + /** + * Completed workflow result. + */ + result?: { + [k: string]: unknown | undefined; + }; + /** + * Error message for an errored run. + */ + error?: string; + /** + * Reason for a halted or cancelled run. + */ + reason?: string; + /** + * Partial journal and progress snapshot for a halted or cancelled run. + */ + snapshot?: { + [k: string]: unknown | undefined; + }; +} /** * A single changed file and its unified diff. * @@ -16669,6 +17044,84 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin }, }, /** @experimental */ + workflow: { + /** + * Runs a registered workflow by name at the top level. + * + * @param params Parameters for invoking a registered workflow. + * + * @returns Complete current or terminal workflow run envelope. + */ + run: async (params: WorkflowRunRequest): Promise => + connection.sendRequest("session.workflow.run", { sessionId, ...params }), + /** + * Runs a registered workflow as a child of an existing workflow run. + * + * @param params Parameters for invoking a nested workflow. + * + * @returns Complete current or terminal workflow run envelope. + */ + runNested: async (params: WorkflowRunNestedRequest): Promise => + connection.sendRequest("session.workflow.runNested", { sessionId, ...params }), + /** + * Gets the current or settled envelope for a workflow run. + * + * @param params Parameters for retrieving a workflow run. + * + * @returns Complete current or terminal workflow run envelope. + */ + getRun: async (params: WorkflowGetRunRequest): Promise => + connection.sendRequest("session.workflow.getRun", { sessionId, ...params }), + /** + * Requests cancellation of a workflow run and returns its run envelope. + * + * @param params Parameters for cancelling a workflow run. + * + * @returns Complete current or terminal workflow run envelope. + */ + cancel: async (params: WorkflowCancelRequest): Promise => + connection.sendRequest("session.workflow.cancel", { sessionId, ...params }), + /** + * Records a batch of ordered workflow progress lines. + * + * @param params Parameters for recording workflow progress. + * + * @returns Acknowledgement that a workflow request was accepted. + */ + log: async (params: WorkflowLogRequest): Promise => + connection.sendRequest("session.workflow.log", { sessionId, ...params }), + /** + * Runs one workflow-scoped subagent and returns its result. + * + * @param params Parameters for one workflow-scoped subagent call. + * + * @returns Result of one workflow-scoped subagent call. + */ + agent: async (params: WorkflowAgentRequest): Promise => + connection.sendRequest("session.workflow.agent", { sessionId, ...params }), + /** @experimental */ + journal: { + /** + * Reads a memoized workflow journal entry. + * + * @param params Parameters for reading a workflow journal entry. + * + * @returns Result of reading a workflow journal entry. + */ + get: async (params: WorkflowJournalGetRequest): Promise => + connection.sendRequest("session.workflow.journal.get", { sessionId, ...params }), + /** + * Stores a memoized workflow journal entry. + * + * @param params Parameters for storing a workflow journal entry. + * + * @returns Acknowledgement that a workflow request was accepted. + */ + put: async (params: WorkflowJournalPutRequest): Promise => + connection.sendRequest("session.workflow.journal.put", { sessionId, ...params }), + }, + }, + /** @experimental */ model: { /** * Gets the currently selected model for the session. @@ -18156,6 +18609,27 @@ export interface ProviderTokenHandler { getToken(params: ProviderTokenAcquireRequest): Promise; } +/** Handler for `workflow` client session API methods. */ +/** @experimental */ +export interface WorkflowHandler { + /** + * Asks the owning extension connection to execute a registered workflow closure. + * + * @param params Parameters sent to the owning extension to execute a workflow closure. + * + * @returns Result returned by an extension workflow closure. + */ + execute(params: WorkflowExecuteRequest): Promise; + /** + * Asks the owning extension connection to abort a running workflow cooperatively. + * + * @param params Parameters for cooperatively aborting a workflow body. + * + * @returns Acknowledgement that a workflow request was accepted. + */ + abort(params: WorkflowAbortRequest): Promise; +} + /** Handler for `sessionFs` client session API methods. */ /** @experimental */ export interface SessionFsHandler { @@ -18287,6 +18761,7 @@ export interface CanvasHandler { /** All client session API handler groups. */ export interface ClientSessionApiHandlers { providerToken?: ProviderTokenHandler; + workflow?: WorkflowHandler; sessionFs?: SessionFsHandler; canvas?: CanvasHandler; } @@ -18306,6 +18781,16 @@ export function registerClientSessionApiHandlers( if (!handler) throw new Error(`No providerToken handler registered for session: ${params.sessionId}`); return handler.getToken(params); }); + connection.onRequest("workflow.execute", async (params: WorkflowExecuteRequest) => { + const handler = getHandlers(params.sessionId).workflow; + if (!handler) throw new Error(`No workflow handler registered for session: ${params.sessionId}`); + return handler.execute(params); + }); + connection.onRequest("workflow.abort", async (params: WorkflowAbortRequest) => { + const handler = getHandlers(params.sessionId).workflow; + if (!handler) throw new Error(`No workflow handler registered for session: ${params.sessionId}`); + return handler.abort(params); + }); connection.onRequest("sessionFs.readFile", async (params: SessionFsReadFileRequest) => { const handler = getHandlers(params.sessionId).sessionFs; if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 9d3bdcd7f0..5ae3f4f1f5 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -86,6 +86,8 @@ export type { LargeToolOutputConfig, MemoryConfiguration, UiInputOptions, + WorkflowLimits, + WorkflowMeta, MCPStdioServerConfig, MCPHTTPServerConfig, MCPServerConfig, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index fc0fa51d00..0f0a9de303 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1836,6 +1836,28 @@ export interface CanvasProviderIdentity { name?: string; } +/** Static resource ceilings declared by a workflow before it runs. */ +export interface WorkflowLimits { + /** Maximum number of workflow subagents that may run concurrently. Must be positive when present. */ + maxConcurrentSubagents?: number; + /** Maximum total number of workflow subagents that may be spawned. Must be positive when present. */ + maxTotalSubagents?: number; + /** Wall-clock timeout for the workflow run, in milliseconds. Must be positive when present. */ + timeout?: number; +} + +/** Registration metadata for an extension-authored workflow. */ +export interface WorkflowMeta { + /** Stable workflow name used for invocation. */ + name: string; + /** Human-readable workflow description. */ + description: string; + /** Display metadata for the progress phases the workflow may report. */ + phases: Array<{ title: string; detail?: string }>; + /** Optional resource ceilings presented to the user before execution. */ + limits?: WorkflowLimits; +} + /** * Provider-scoped options for the Copilot API (CAPI). * From d2114890b4bca469e2731f05f3282ea1e70d0382 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Wed, 15 Jul 2026 12:25:55 -0700 Subject: [PATCH 02/22] Phase 2: SDK authoring surface (defineWorkflow + registration) Add the SDK-side Dynamic Workflows authoring surface: defineWorkflow (opaque frozen WorkflowHandle, module-local name->{meta,run} map, optional limits that reject non-positive values), workflows?: WorkflowHandle[] on the extension JoinSessionConfig only (serializing just WorkflowMeta on the wire, never the run closure), the workflow.execute reverse handler (dispatch-by-name with a stub ctx of args+log-noop+return, structured error on unknown name) and workflow.abort handler (per-run AbortController keyed by runId), both registered before session.resume, and the session.workflow.run friendly wrapper (name/handle overloads; foreground unwraps completed result and throws exported WorkflowRunError on non-completed; background returns the envelope). Exports from index.ts and extension.ts. SDK-only; runtime untouched; no generated files edited. Verification (Node 22, exit 0): npm run build; npm test -- test/workflow.test.ts (12 passed); typecheck; targeted ESLint. Deviations: none. --- nodejs/src/client.ts | 20 +++ nodejs/src/extension.ts | 35 ++++- nodejs/src/index.ts | 12 ++ nodejs/src/session.ts | 97 ++++++++++++++ nodejs/src/workflow.ts | 176 +++++++++++++++++++++++++ nodejs/test/workflow.test.ts | 247 +++++++++++++++++++++++++++++++++++ 6 files changed, 581 insertions(+), 6 deletions(-) create mode 100644 nodejs/src/workflow.ts create mode 100644 nodejs/test/workflow.test.ts diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 61f4a99416..6e0d818993 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -85,6 +85,7 @@ import type { TypedSessionLifecycleHandler, } from "./types.js"; import { defaultJoinSessionPermissionHandler } from "./types.js"; +import type { WorkflowHandle } from "./workflow.js"; /** * Minimum protocol version this SDK can communicate with. @@ -1663,6 +1664,23 @@ export class CopilotClient { * ``` */ async resumeSession(sessionId: string, config: ResumeSessionConfig): Promise { + return this.resumeSessionInternal(sessionId, config); + } + + /** @internal */ + async resumeSessionForExtension( + sessionId: string, + config: ResumeSessionConfig, + workflows?: WorkflowHandle[] + ): Promise { + return this.resumeSessionInternal(sessionId, config, workflows); + } + + private async resumeSessionInternal( + sessionId: string, + config: ResumeSessionConfig, + workflows?: WorkflowHandle[] + ): Promise { if (!this.connection) { await this.start(); } @@ -1679,6 +1697,7 @@ export class CopilotClient { session.registerTools(config.tools); session.registerCanvases(config.canvases); session.registerCommands(config.commands); + session.registerWorkflows(workflows); const { wireProvider: bearerWireProvider, wireProviders: bearerWireProviders, @@ -1750,6 +1769,7 @@ export class CopilotClient { })), toolSearch: config.toolSearch, canvases: config.canvases?.map((canvas) => canvas.declaration), + workflows: workflows?.map((workflow) => workflow.meta), requestCanvasRenderer: config.requestCanvasRenderer, requestExtensions: config.requestExtensions, extensionSdkPath: config.extensionSdkPath, diff --git a/nodejs/src/extension.ts b/nodejs/src/extension.ts index 6cec2674f7..0d55379f5c 100644 --- a/nodejs/src/extension.ts +++ b/nodejs/src/extension.ts @@ -9,6 +9,7 @@ import { type PermissionHandler, type ResumeSessionConfig, } from "./types.js"; +import type { WorkflowHandle } from "./workflow.js"; export { Canvas, @@ -26,9 +27,23 @@ export type JoinSessionConfig = Omit< "onPermissionRequest" | "extensionSdkPath" > & { onPermissionRequest?: PermissionHandler; + workflows?: WorkflowHandle[]; }; export type { ExtensionInfo, WorkflowLimits, WorkflowMeta } from "./types.js"; +export { + defineWorkflow, + WorkflowRunError, + type RunOptions, + type SessionWorkflowApi, + type WorkflowAgentOptions, + type WorkflowContext, + type WorkflowDefinition, + type WorkflowHandle, + type WorkflowJsonSchema, + type WorkflowPipelineStage, + type WorkflowStepOptions, +} from "./workflow.js"; /** * Joins the current foreground session. @@ -57,14 +72,22 @@ export async function joinSession(config: JoinSessionConfig = {}): Promise = new Map(); private bearerTokenProviders: Map = new Map(); private commandHandlers: Map = new Map(); + private workflows = new Map>(); + private workflowAbortControllers = new Map(); private permissionHandler?: PermissionHandler; private mcpAuthHandler?: McpAuthHandler; private userInputHandler?: UserInputHandler; @@ -155,6 +166,36 @@ export class CopilotSession { /** @internal Client session API handlers, populated by CopilotClient during create/resume. */ clientSessionApis: ClientSessionApiHandlers = {}; + readonly workflow: SessionWorkflowApi = { + run: (async ( + nameOrHandle: string | WorkflowHandle, + options?: RunOptions + ): Promise => { + const name = + typeof nameOrHandle === "string" + ? nameOrHandle + : getWorkflowDefinition(nameOrHandle).meta.name; + const envelope = await this.rpc.workflow.run({ + name, + args: (options?.args === undefined ? {} : options.args) as Parameters< + typeof this.rpc.workflow.run + >[0]["args"], + options: { + background: options?.background, + resumeFromRunId: options?.resumeFromRunId, + }, + }); + + if (options?.background) { + return envelope; + } + if (envelope.status !== "completed") { + throw new WorkflowRunError(envelope); + } + return envelope.result; + }) as SessionWorkflowApi["run"], + }; + /** * Creates a new CopilotSession instance. * @@ -358,6 +399,11 @@ export class CopilotSession { this.autoModeSwitchHandler = undefined; this.commandHandlers.clear(); this.canvases.clear(); + this.workflows.clear(); + for (const controller of this.workflowAbortControllers.values()) { + controller.abort(); + } + this.workflowAbortControllers.clear(); this.transformCallbacks?.clear(); } @@ -872,6 +918,57 @@ export class CopilotSession { }; } + /** + * Registers workflow closures and reverse-RPC handlers for this session. + * + * @param workflows - Workflow handles declared by the joining extension. + * @internal Called by the SDK when an extension joins a session. + */ + registerWorkflows(workflows?: WorkflowHandle[]): void { + this.workflows.clear(); + if (!workflows || workflows.length === 0) { + delete this.clientSessionApis.workflow; + return; + } + + for (const handle of workflows) { + const definition = getWorkflowDefinition(handle); + this.workflows.set(definition.meta.name, definition); + } + + const self = this; + this.clientSessionApis.workflow = { + async execute(params) { + const definition = self.workflows.get(params.name); + if (!definition) { + const message = `No workflow registered with name "${params.name}"`; + throw new ResponseError(ErrorCodes.InvalidParams, message, { + code: "workflow_not_found", + name: params.name, + }); + } + + const controller = new AbortController(); + self.workflowAbortControllers.set(params.runId, controller); + try { + const result = await definition.run({ + args: params.args, + log: (_message: string) => {}, + } as WorkflowContext); + return { result } as WorkflowExecuteResult; + } finally { + if (self.workflowAbortControllers.get(params.runId) === controller) { + self.workflowAbortControllers.delete(params.runId); + } + } + }, + async abort(params) { + self.workflowAbortControllers.get(params.runId)?.abort(); + return {}; + }, + }; + } + /** * Registers per-provider {@link BearerTokenProvider} callbacks for BYOK providers * configured with managed-identity / on-demand bearer-token auth. diff --git a/nodejs/src/workflow.ts b/nodejs/src/workflow.ts new file mode 100644 index 0000000000..d68cfdc92b --- /dev/null +++ b/nodejs/src/workflow.ts @@ -0,0 +1,176 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import type { WorkflowRunResult } from "./generated/rpc.js"; +import type { CopilotSession } from "./session.js"; +import type { WorkflowMeta } from "./types.js"; + +declare const workflowHandleBrand: unique symbol; + +/** JSON Schema accepted for structured workflow agent output. */ +export type WorkflowJsonSchema = Record; + +/** Options for one workflow-scoped subagent call. */ +export interface WorkflowAgentOptions { + label?: string; + schema?: WorkflowJsonSchema; + model?: string; +} + +/** Options for a durable workflow step. */ +export interface WorkflowStepOptions { + /** Skip the journal and always invoke the producer. */ + volatile?: boolean; +} + +/** One stage in a per-item workflow pipeline. */ +export type WorkflowPipelineStage = ( + previous: TInput, + item: unknown, + index: number +) => Promise | TResult; + +/** Context passed to an extension-authored workflow body. */ +export interface WorkflowContext { + /** Spawn and await one workflow-scoped subagent. */ + agent(prompt: string, options?: WorkflowAgentOptions): Promise; + /** Memoize an arbitrary producer under a stable author-supplied key. */ + step( + key: string, + producer: () => Promise | TResult, + options?: WorkflowStepOptions + ): Promise; + /** Run thunks concurrently, returning null for a thunk that throws. */ + parallel(thunks: Array<() => Promise>): Promise>; + /** Run each item through every stage without barriers between stages. */ + pipeline(items: unknown[], ...stages: WorkflowPipelineStage[]): Promise; + /** Start a named workflow progress phase. */ + phase(title: string): void; + /** Emit a workflow progress line. */ + log(message: string): void; + /** Invoke another registered workflow as a child run. */ + workflow(name: string, args?: unknown): Promise; + /** Caller-supplied input, forwarded verbatim. */ + args: TArgs; + /** The same full session instance returned by `joinSession`. */ + session: CopilotSession; + /** Cooperative cancellation signal for the current workflow run. */ + signal: AbortSignal; +} + +/** Definition accepted by {@link defineWorkflow}. */ +export interface WorkflowDefinition { + meta: WorkflowMeta; + run(context: WorkflowContext): Promise; +} + +/** Opaque reusable reference to a defined workflow. */ +export interface WorkflowHandle { + readonly meta: WorkflowMeta; + readonly [workflowHandleBrand]: { + readonly args: TArgs; + readonly result: TResult; + }; +} + +/** Options for invoking a workflow. */ +export interface RunOptions { + /** Input surfaced as `context.args`. */ + args?: TArgs; + /** Return once the approved run starts instead of awaiting completion. */ + background?: boolean; + /** Prior run whose journal and progress should seed this run. */ + resumeFromRunId?: string; +} + +/** Friendly workflow API exposed on a session. */ +export interface SessionWorkflowApi { + run( + workflow: WorkflowHandle, + options: RunOptions & { background: true } + ): Promise; + run( + workflow: WorkflowHandle, + options?: RunOptions & { background?: false } + ): Promise; + run( + workflow: WorkflowHandle, + options?: RunOptions + ): Promise; + run(name: string, options: RunOptions & { background: true }): Promise; + run( + name: string, + options?: RunOptions & { background?: false } + ): Promise; + run( + name: string, + options?: RunOptions + ): Promise; +} + +/** Error thrown when a foreground workflow run does not complete successfully. */ +export class WorkflowRunError extends Error { + constructor(public readonly envelope: WorkflowRunResult) { + super( + envelope.error ?? + envelope.reason ?? + `Workflow run "${envelope.runId}" ended with status "${envelope.status}"` + ); + this.name = "WorkflowRunError"; + } +} + +interface StoredWorkflow { + meta: WorkflowMeta; + run(context: WorkflowContext): Promise; +} + +const workflowDefinitions = new Map(); +const workflowHandles = new WeakMap(); + +function validateLimits(meta: WorkflowMeta): void { + const limits = meta.limits; + if (!limits) { + return; + } + + for (const field of ["maxConcurrentSubagents", "maxTotalSubagents"] as const) { + const value = limits[field]; + if (value !== undefined && (!Number.isInteger(value) || value <= 0)) { + throw new Error(`Workflow limit "${field}" must be a positive integer`); + } + } + + if (limits.timeout !== undefined && (!Number.isFinite(limits.timeout) || limits.timeout <= 0)) { + throw new Error('Workflow limit "timeout" must be a positive number of milliseconds'); + } +} + +/** + * Defines an extension-authored workflow and returns an opaque registration handle. + */ +export function defineWorkflow( + definition: WorkflowDefinition +): WorkflowHandle { + validateLimits(definition.meta); + + const stored: StoredWorkflow = { + meta: definition.meta, + run: definition.run as StoredWorkflow["run"], + }; + const handle = Object.freeze({ meta: definition.meta }) as WorkflowHandle; + + workflowDefinitions.set(definition.meta.name, stored); + workflowHandles.set(handle, stored); + return handle; +} + +/** @internal */ +export function getWorkflowDefinition(handle: WorkflowHandle): StoredWorkflow { + const definition = workflowHandles.get(handle); + if (!definition) { + throw new Error("Invalid workflow handle"); + } + return definition; +} diff --git a/nodejs/test/workflow.test.ts b/nodejs/test/workflow.test.ts new file mode 100644 index 0000000000..0fe5388ca8 --- /dev/null +++ b/nodejs/test/workflow.test.ts @@ -0,0 +1,247 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest"; +import { ResponseError } from "vscode-jsonrpc/node.js"; +import { CopilotClient } from "../src/client.js"; +import { joinSession } from "../src/extension.js"; +import { CopilotSession } from "../src/session.js"; +import { defineWorkflow, WorkflowRunError, type WorkflowDefinition } from "../src/workflow.js"; + +async function stopClient(client: CopilotClient): Promise { + await client.stop(); +} + +describe("workflows", () => { + const originalSessionId = process.env.SESSION_ID; + + afterEach(() => { + if (originalSessionId === undefined) { + delete process.env.SESSION_ID; + } else { + process.env.SESSION_ID = originalSessionId; + } + vi.restoreAllMocks(); + }); + + it("defines a stable handle and accepts omitted limits", async () => { + const meta = { + name: "no-limits", + description: "A workflow without resource limits", + phases: [], + }; + const run = vi.fn(async ({ args }: { args: unknown }) => args); + const handle = defineWorkflow({ meta, run }); + + expect(handle.meta).toBe(meta); + expect(Object.isFrozen(handle)).toBe(true); + + const session = new CopilotSession("session-1", {} as never); + session.registerWorkflows([handle]); + const result = await session.clientSessionApis.workflow!.execute({ + sessionId: session.sessionId, + name: meta.name, + runId: "run-1", + args: { value: 42 }, + }); + + expect(run).toHaveBeenCalledOnce(); + expect(result).toEqual({ result: { value: 42 } }); + }); + + it.each([ + ["maxConcurrentSubagents", 0], + ["maxConcurrentSubagents", 1.5], + ["maxTotalSubagents", -1], + ["maxTotalSubagents", Number.POSITIVE_INFINITY], + ["timeout", 0], + ["timeout", Number.NaN], + ] as const)("rejects invalid %s limit %s", (field, value) => { + const definition = { + meta: { + name: `invalid-${field}-${String(value)}`, + description: "Invalid workflow", + phases: [], + limits: { [field]: value }, + }, + run: async () => null, + } as WorkflowDefinition; + + expect(() => defineWorkflow(definition)).toThrow(/must be a positive/); + }); + + it("serializes only workflow metadata in the extension resume payload", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => stopClient(client)); + + const run = vi.fn(async () => ({ ok: true })); + const workflow = defineWorkflow({ + meta: { + name: "registered", + description: "Registration test", + phases: [{ title: "Run" }], + limits: { maxTotalSubagents: 2 }, + }, + run, + }); + const sendRequest = vi + .spyOn( + (client as never as { connection: { sendRequest: Function } }).connection, + "sendRequest" + ) + .mockImplementation(async (method: string, params: Record) => { + if (method === "session.resume") { + const sessions = (client as never as { sessions: Map }) + .sessions; + expect( + sessions.get(params.sessionId as string)?.clientSessionApis.workflow + ).toBeDefined(); + return { sessionId: params.sessionId }; + } + throw new Error(`Unexpected method: ${method}`); + }); + + await client.resumeSessionForExtension( + "session-registration", + { onPermissionRequest: () => ({ kind: "approved" }) }, + [workflow] + ); + + const payload = sendRequest.mock.calls.find( + ([method]) => method === "session.resume" + )![1] as { + workflows: unknown[]; + }; + expect(payload.workflows).toEqual([workflow.meta]); + expect(payload.workflows[0]).not.toHaveProperty("run"); + expect(JSON.stringify(payload.workflows)).not.toContain("async"); + }); + + it("passes workflows only through the extension join path", async () => { + process.env.SESSION_ID = "session-extension"; + const workflow = defineWorkflow({ + meta: { + name: "extension-only", + description: "Extension-only registration", + phases: [], + }, + run: async () => ({ ok: true }), + }); + const resumeSessionForExtension = vi + .spyOn(CopilotClient.prototype, "resumeSessionForExtension") + .mockResolvedValue({} as CopilotSession); + + await joinSession({ workflows: [workflow] }); + + expect(resumeSessionForExtension).toHaveBeenCalledWith( + "session-extension", + expect.objectContaining({ suppressResumeEvent: true }), + [workflow] + ); + }); + + it("dispatches workflow.execute by name and returns a structured unknown-name error", async () => { + const run = vi.fn(async ({ args, log }) => { + log("ignored in phase 2"); + return { echoed: args }; + }); + const workflow = defineWorkflow({ + meta: { + name: "echo", + description: "Echo arguments", + phases: [], + }, + run, + }); + const session = new CopilotSession("session-execute", {} as never); + session.registerWorkflows([workflow]); + + await expect( + session.clientSessionApis.workflow!.execute({ + sessionId: session.sessionId, + name: "echo", + runId: "run-echo", + args: { message: "hello" }, + }) + ).resolves.toEqual({ result: { echoed: { message: "hello" } } }); + + const error = await session.clientSessionApis + .workflow!.execute({ + sessionId: session.sessionId, + name: "missing", + runId: "run-missing", + args: {}, + }) + .catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(ResponseError); + expect((error as ResponseError<{ code: string; name: string }>).data).toEqual({ + code: "workflow_not_found", + name: "missing", + }); + }); + + it("runs workflows by name or handle and unwraps only foreground results", async () => { + const workflow = defineWorkflow({ + meta: { + name: "friendly-run", + description: "Friendly run wrapper", + phases: [], + }, + run: async () => ({ unused: true }), + }); + const sendRequest = vi.fn( + async ( + _method: string, + params: { name: string; options?: { background?: boolean } } + ) => + params.options?.background + ? { runId: "run-background", status: "running" } + : { + runId: "run-foreground", + status: "completed", + result: { name: params.name }, + } + ); + const session = new CopilotSession("session-run", { sendRequest } as never); + + await expect(session.workflow.run("by-name", { args: { value: 1 } })).resolves.toEqual({ + name: "by-name", + }); + await expect(session.workflow.run(workflow)).resolves.toEqual({ name: "friendly-run" }); + await expect(session.workflow.run("background", { background: true })).resolves.toEqual({ + runId: "run-background", + status: "running", + }); + + expect(sendRequest).toHaveBeenNthCalledWith(1, "session.workflow.run", { + sessionId: session.sessionId, + name: "by-name", + args: { value: 1 }, + options: { background: undefined, resumeFromRunId: undefined }, + }); + expect(sendRequest).toHaveBeenNthCalledWith(2, "session.workflow.run", { + sessionId: session.sessionId, + name: "friendly-run", + args: {}, + options: { background: undefined, resumeFromRunId: undefined }, + }); + }); + + it("throws WorkflowRunError with the full foreground envelope", async () => { + const envelope = { + runId: "run-error", + status: "error" as const, + error: "workflow failed", + snapshot: { completed: 1 }, + }; + const session = new CopilotSession("session-error", { + sendRequest: vi.fn(async () => envelope), + } as never); + + const error = await session.workflow.run("failing").catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(WorkflowRunError); + expect((error as WorkflowRunError).envelope).toBe(envelope); + }); +}); From 20fc9568f7cb5bf4fe8f38c7b5478b44c13c9620 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Wed, 15 Jul 2026 14:05:18 -0700 Subject: [PATCH 03/22] Phase 4: Host API context, part 1 (args, log, phase, return) Build the real run() WorkflowContext in the SDK workflow.execute handler (replacing the Phase 2 stub): populate args; set ctx.session to the identical CopilotSession instance joinSession returned (strict === identity, no wrapper); expose ctx.signal as the per-run AbortController's AbortSignal; and implement log()/phase() as synchronous calls that buffer each line with a monotonic seq and flush incrementally over workflow.log (at await boundaries, a short unref'd timer, and a guaranteed finally-flush so no buffered narration is lost on a throwing/aborted body). SDK side of Phase 4; runtime workflow.log handler is the companion commit. Verification (Node 22, exit 0): npm run build; npm test -- test/workflow.test.ts (16 passed); typecheck; lint. Deviations: none. --- nodejs/src/session.ts | 97 ++++++++++++++++++-- nodejs/test/workflow.test.ts | 167 ++++++++++++++++++++++++++++++++++- 2 files changed, 257 insertions(+), 7 deletions(-) diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 52c6ac9b81..54e00ee65b 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -16,6 +16,7 @@ import type { CurrentToolMetadata, McpOauthPendingRequestResponse, WorkflowExecuteResult, + WorkflowLogLine, } from "./generated/rpc.js"; import { type Canvas, CanvasError } from "./canvas.js"; import type { OpenCanvasInstance } from "./generated/rpc.js"; @@ -103,6 +104,72 @@ function isOpenCanvasInstance(value: unknown): value is OpenCanvasInstance { ); } +const WORKFLOW_LOG_FLUSH_DELAY_MS = 10; + +class WorkflowProgressBuffer { + private nextSeq = 0; + private pending: WorkflowLogLine[] = []; + private flushTimer?: ReturnType; + private flushTail: Promise = Promise.resolve(); + private flushError: unknown; + private flushFailed = false; + private closed = false; + + constructor(private readonly send: (lines: WorkflowLogLine[]) => Promise) {} + + enqueue(kind: WorkflowLogLine["kind"], text: string): void { + if (this.closed) { + throw new Error("Cannot log after the workflow run has settled"); + } + this.pending.push({ seq: this.nextSeq++, kind, text }); + this.scheduleFlush(); + } + + async flush(): Promise { + this.clearFlushTimer(); + const lines = this.pending.splice(0); + if (lines.length > 0) { + this.flushTail = this.flushTail.then(async () => { + try { + await this.send(lines); + } catch (error) { + if (!this.flushFailed) { + this.flushFailed = true; + this.flushError = error; + } + } + }); + } + await this.flushTail; + if (this.flushFailed) { + throw this.flushError; + } + } + + async close(): Promise { + this.closed = true; + await this.flush(); + } + + private scheduleFlush(): void { + if (this.flushTimer !== undefined) { + return; + } + this.flushTimer = setTimeout(() => { + this.flushTimer = undefined; + void this.flush().catch(() => {}); + }, WORKFLOW_LOG_FLUSH_DELAY_MS); + this.flushTimer.unref?.(); + } + + private clearFlushTimer(): void { + if (this.flushTimer !== undefined) { + clearTimeout(this.flushTimer); + this.flushTimer = undefined; + } + } +} + /** Assistant message event - the final response from the assistant. */ export type AssistantMessageEvent = Extract; @@ -950,15 +1017,35 @@ export class CopilotSession { const controller = new AbortController(); self.workflowAbortControllers.set(params.runId, controller); + const progress = new WorkflowProgressBuffer(async (lines) => { + await self.rpc.workflow.log({ runId: params.runId, lines }); + }); try { - const result = await definition.run({ + const unsupported = async (method: string): Promise => { + await progress.flush(); + throw new Error(`${method} is not yet supported`); + }; + const context: WorkflowContext = { args: params.args, - log: (_message: string) => {}, - } as WorkflowContext); + session: self, + signal: controller.signal, + phase: (title: string) => progress.enqueue("phase", title), + log: (message: string) => progress.enqueue("log", message), + agent: async () => unsupported("workflow.agent"), + step: async () => unsupported("workflow.step"), + parallel: async () => unsupported("workflow.parallel"), + pipeline: async () => unsupported("workflow.pipeline"), + workflow: async () => unsupported("workflow.runNested"), + }; + const result = await definition.run(context); return { result } as WorkflowExecuteResult; } finally { - if (self.workflowAbortControllers.get(params.runId) === controller) { - self.workflowAbortControllers.delete(params.runId); + try { + await progress.close(); + } finally { + if (self.workflowAbortControllers.get(params.runId) === controller) { + self.workflowAbortControllers.delete(params.runId); + } } } }, diff --git a/nodejs/test/workflow.test.ts b/nodejs/test/workflow.test.ts index 0fe5388ca8..06a055b650 100644 --- a/nodejs/test/workflow.test.ts +++ b/nodejs/test/workflow.test.ts @@ -142,9 +142,170 @@ describe("workflows", () => { ); }); + it("builds the workflow context with args, progress, signal, and the joined session identity", async () => { + process.env.SESSION_ID = "session-context"; + const sendRequest = vi.fn(async (method: string) => { + if (method === "session.workflow.log") { + return {}; + } + throw new Error(`Unexpected method: ${method}`); + }); + const joinedSession = new CopilotSession("session-context", { sendRequest } as never); + const contextSeen = Promise.withResolvers<{ + args: unknown; + session: CopilotSession; + signal: AbortSignal; + }>(); + const workflow = defineWorkflow({ + meta: { + name: "context", + description: "Context test", + phases: [], + }, + run: async (context) => { + contextSeen.resolve(context); + context.phase("A"); + context.log("hi"); + return { ok: true }; + }, + }); + vi.spyOn(CopilotClient.prototype, "resumeSessionForExtension").mockImplementation( + async (_sessionId, _config, workflows) => { + joinedSession.registerWorkflows(workflows); + return joinedSession; + } + ); + + const joinSessionResult = await joinSession({ workflows: [workflow] }); + const executeResult = await joinSessionResult.clientSessionApis.workflow!.execute({ + sessionId: joinSessionResult.sessionId, + name: "context", + runId: "run-context", + args: { value: 42 }, + }); + const context = await contextSeen.promise; + + expect(context.args).toEqual({ value: 42 }); + expect(context.session).toBe(joinSessionResult); + expect(context.signal).toBeInstanceOf(AbortSignal); + expect(executeResult).toEqual({ result: { ok: true } }); + expect(sendRequest).toHaveBeenCalledWith("session.workflow.log", { + sessionId: joinSessionResult.sessionId, + runId: "run-context", + lines: [ + { seq: 0, kind: "phase", text: "A" }, + { seq: 1, kind: "log", text: "hi" }, + ], + }); + }); + + it("flushes progress incrementally while a workflow body is awaiting", async () => { + const sendRequest = vi.fn(async () => ({})); + const session = new CopilotSession("session-live-progress", { sendRequest } as never); + const body = Promise.withResolvers(); + const workflow = defineWorkflow({ + meta: { + name: "live-progress", + description: "Incremental progress test", + phases: [], + }, + run: async ({ log }) => { + log("before await"); + await body.promise; + return "done"; + }, + }); + session.registerWorkflows([workflow]); + + const execution = session.clientSessionApis.workflow!.execute({ + sessionId: session.sessionId, + name: "live-progress", + runId: "run-live-progress", + args: {}, + }); + await vi.waitFor(() => { + expect(sendRequest).toHaveBeenCalledWith("session.workflow.log", { + sessionId: session.sessionId, + runId: "run-live-progress", + lines: [{ seq: 0, kind: "log", text: "before await" }], + }); + }); + + body.resolve(); + await expect(execution).resolves.toEqual({ result: "done" }); + }); + + it("flushes buffered progress in finally when the workflow body throws", async () => { + const sendRequest = vi.fn(async () => ({})); + const session = new CopilotSession("session-throw-progress", { sendRequest } as never); + const workflow = defineWorkflow({ + meta: { + name: "throw-progress", + description: "Throwing progress test", + phases: [], + }, + run: async ({ log }) => { + log("before throw"); + throw new Error("body failed"); + }, + }); + session.registerWorkflows([workflow]); + + await expect( + session.clientSessionApis.workflow!.execute({ + sessionId: session.sessionId, + name: "throw-progress", + runId: "run-throw-progress", + args: {}, + }) + ).rejects.toThrow("body failed"); + expect(sendRequest).toHaveBeenCalledWith("session.workflow.log", { + sessionId: session.sessionId, + runId: "run-throw-progress", + lines: [{ seq: 0, kind: "log", text: "before throw" }], + }); + }); + + it("surfaces the per-run abort signal on the workflow context", async () => { + const session = new CopilotSession("session-abort-signal", {} as never); + const signalSeen = Promise.withResolvers(); + const workflow = defineWorkflow({ + meta: { + name: "abort-signal", + description: "Abort signal test", + phases: [], + }, + run: async ({ signal }) => { + signalSeen.resolve(signal); + await new Promise((resolve) => + signal.addEventListener("abort", () => resolve(), { once: true }) + ); + return signal.aborted; + }, + }); + session.registerWorkflows([workflow]); + + const execution = session.clientSessionApis.workflow!.execute({ + sessionId: session.sessionId, + name: "abort-signal", + runId: "run-abort-signal", + args: {}, + }); + const signal = await signalSeen.promise; + expect(signal.aborted).toBe(false); + + await session.clientSessionApis.workflow!.abort({ + sessionId: session.sessionId, + runId: "run-abort-signal", + }); + + expect(signal.aborted).toBe(true); + await expect(execution).resolves.toEqual({ result: true }); + }); + it("dispatches workflow.execute by name and returns a structured unknown-name error", async () => { const run = vi.fn(async ({ args, log }) => { - log("ignored in phase 2"); + log("executing"); return { echoed: args }; }); const workflow = defineWorkflow({ @@ -155,7 +316,9 @@ describe("workflows", () => { }, run, }); - const session = new CopilotSession("session-execute", {} as never); + const session = new CopilotSession("session-execute", { + sendRequest: vi.fn(async () => ({})), + } as never); session.registerWorkflows([workflow]); await expect( From 69854e2d457b7dfe8b9ca2f47402fc2df369a884 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Wed, 15 Jul 2026 15:51:41 -0700 Subject: [PATCH 04/22] Phase 5: The one-agent executor (`agent()` via in-session subagents) Implement ctx.agent in the SDK run() context: it calls workflow.agent carrying the closed-over workflowRunId and the model, returns the subagent result text, and trims opts to label/schema/model only (effort/isolation/agentType/tool-narrowing are not v1). Schema handling arrives in Phase 6. Verification (Node 22, exit 0): npm run build; npm test -- test/workflow.test.ts (17 passed, asserts effort is dropped). Deviations: none. --- nodejs/src/session.ts | 14 +++++++++- nodejs/test/workflow.test.ts | 51 +++++++++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 54e00ee65b..62e4c82f98 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -1031,7 +1031,19 @@ export class CopilotSession { signal: controller.signal, phase: (title: string) => progress.enqueue("phase", title), log: (message: string) => progress.enqueue("log", message), - agent: async () => unsupported("workflow.agent"), + agent: async (prompt, options = {}) => { + await progress.flush(); + const response = await self.rpc.workflow.agent({ + workflowRunId: params.runId, + prompt, + opts: { + label: options.label, + schema: options.schema, + model: options.model, + }, + }); + return response.result ?? null; + }, step: async () => unsupported("workflow.step"), parallel: async () => unsupported("workflow.parallel"), pipeline: async () => unsupported("workflow.pipeline"), diff --git a/nodejs/test/workflow.test.ts b/nodejs/test/workflow.test.ts index 06a055b650..3f4fafe46f 100644 --- a/nodejs/test/workflow.test.ts +++ b/nodejs/test/workflow.test.ts @@ -7,7 +7,12 @@ import { ResponseError } from "vscode-jsonrpc/node.js"; import { CopilotClient } from "../src/client.js"; import { joinSession } from "../src/extension.js"; import { CopilotSession } from "../src/session.js"; -import { defineWorkflow, WorkflowRunError, type WorkflowDefinition } from "../src/workflow.js"; +import { + defineWorkflow, + WorkflowRunError, + type WorkflowAgentOptions, + type WorkflowDefinition, +} from "../src/workflow.js"; async function stopClient(client: CopilotClient): Promise { await client.stop(); @@ -235,6 +240,50 @@ describe("workflows", () => { await expect(execution).resolves.toEqual({ result: "done" }); }); + it("calls workflow.agent with the current run id and returns its text", async () => { + const sendRequest = vi.fn(async (method: string) => { + if (method === "session.workflow.agent") { + return { result: "pong" }; + } + throw new Error(`Unexpected method: ${method}`); + }); + const session = new CopilotSession("session-agent", { sendRequest } as never); + const workflow = defineWorkflow({ + meta: { + name: "agent", + description: "Agent context test", + phases: [], + }, + run: async ({ agent }) => + agent("Reply with pong", { + label: "Pong helper", + model: "gpt-test", + schema: { type: "string" }, + effort: "high", + } as WorkflowAgentOptions), + }); + session.registerWorkflows([workflow]); + + await expect( + session.clientSessionApis.workflow!.execute({ + sessionId: session.sessionId, + name: "agent", + runId: "run-agent", + args: {}, + }) + ).resolves.toEqual({ result: "pong" }); + expect(sendRequest).toHaveBeenCalledWith("session.workflow.agent", { + sessionId: session.sessionId, + workflowRunId: "run-agent", + prompt: "Reply with pong", + opts: { + label: "Pong helper", + model: "gpt-test", + schema: { type: "string" }, + }, + }); + }); + it("flushes buffered progress in finally when the workflow body throws", async () => { const sendRequest = vi.fn(async () => ({})); const session = new CopilotSession("session-throw-progress", { sendRequest } as never); From d7c622b7996b7cc55ca45df47b4ceffc8b2a9987 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Wed, 15 Jul 2026 16:43:29 -0700 Subject: [PATCH 05/22] Phase 6: Structured output (`agent({ schema })`) and retry Document the supported schema subset on the WorkflowContext agent() schema option (examon validator subset, not arbitrary JSON Schema) so authors do not expect unsupported keywords. Companion to the runtime Rust parse/validate. Verification (Node 22, exit 0): npm run build; npm run typecheck; npm test -- test/workflow.test.ts. Deviations: none. --- nodejs/src/workflow.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/nodejs/src/workflow.ts b/nodejs/src/workflow.ts index d68cfdc92b..0475a6b43a 100644 --- a/nodejs/src/workflow.ts +++ b/nodejs/src/workflow.ts @@ -8,7 +8,12 @@ import type { WorkflowMeta } from "./types.js"; declare const workflowHandleBrand: unique symbol; -/** JSON Schema accepted for structured workflow agent output. */ +/** + * Conservative JSON shape language accepted for structured workflow agent output. + * + * Supports `type`, `required`, `enum`, `const`, recursive `properties`/`items`, + * and `anyOf`/`oneOf`/`allOf`. Other JSON Schema keywords are ignored. + */ export type WorkflowJsonSchema = Record; /** Options for one workflow-scoped subagent call. */ From be558273f1c71bad6b6b554d10bb2f6ef193b395 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Wed, 15 Jul 2026 18:27:42 -0700 Subject: [PATCH 06/22] Phase 7: Combinators (`parallel`, `pipeline`) and the per-run limiter Implement ctx.parallel(thunks) as a barrier fan-out (awaits all; throwing thunk -> null) and ctx.pipeline(items, ...stages) as per-item staged flow with no barrier (throwing stage nulls that item and skips its remaining stages). Both are pure SDK control flow holding no slot; concurrency is enforced by the runtime per-run limiter (each leaf ctx.agent is a workflow.agent RPC that queues until admitted). Enforce the non-configurable 4096 per-fan-out item cap, and validate element types up front so passing already-invoked promises surfaces a clear pass-functions-not-promises diagnostic instead of silently resolving to all-nulls. Verification (Node 22, exit 0): npm run build; npm test -- test/workflow.test.ts (22 passed); typecheck. Deviations: none. --- nodejs/src/session.ts | 61 +++++++++- nodejs/src/workflow.ts | 4 +- nodejs/test/workflow.test.ts | 214 +++++++++++++++++++++++++++++++++++ 3 files changed, 276 insertions(+), 3 deletions(-) diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 62e4c82f98..4a4f4482d2 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -105,6 +105,63 @@ function isOpenCanvasInstance(value: unknown): value is OpenCanvasInstance { } const WORKFLOW_LOG_FLUSH_DELAY_MS = 10; +const MAX_WORKFLOW_FANOUT_ITEMS = 4096; + +function assertWorkflowFanoutSize(kind: "parallel" | "pipeline", size: number): void { + if (size > MAX_WORKFLOW_FANOUT_ITEMS) { + throw new Error( + `${kind}() accepts at most ${MAX_WORKFLOW_FANOUT_ITEMS} items; got ${size}.` + ); + } +} + +async function runWorkflowParallel( + thunks: Array<() => Promise | TResult> +): Promise> { + if (!Array.isArray(thunks)) { + throw new Error( + "parallel() expects an array of functions, not promises. Wrap each call: () => agent(...)" + ); + } + assertWorkflowFanoutSize("parallel", thunks.length); + if (thunks.some((thunk) => typeof thunk !== "function")) { + throw new Error( + "parallel() expects an array of functions, not promises. Wrap each call: () => agent(...)" + ); + } + return Promise.all( + thunks.map((thunk) => + Promise.resolve() + .then(() => thunk()) + .catch(() => null) + ) + ); +} + +async function runWorkflowPipeline( + items: unknown[], + ...stages: Array< + (previous: unknown, item: unknown, index: number) => Promise | unknown + > +): Promise { + if (!Array.isArray(items)) { + throw new Error("pipeline(items, ...stages): items must be an array"); + } + assertWorkflowFanoutSize("pipeline", items.length); + return Promise.all( + items.map(async (item, index) => { + let previous = item; + for (const stage of stages) { + try { + previous = await stage(previous, item, index); + } catch { + return null; + } + } + return previous; + }) + ); +} class WorkflowProgressBuffer { private nextSeq = 0; @@ -1045,8 +1102,8 @@ export class CopilotSession { return response.result ?? null; }, step: async () => unsupported("workflow.step"), - parallel: async () => unsupported("workflow.parallel"), - pipeline: async () => unsupported("workflow.pipeline"), + parallel: runWorkflowParallel, + pipeline: runWorkflowPipeline, workflow: async () => unsupported("workflow.runNested"), }; const result = await definition.run(context); diff --git a/nodejs/src/workflow.ts b/nodejs/src/workflow.ts index 0475a6b43a..3680f91fcd 100644 --- a/nodejs/src/workflow.ts +++ b/nodejs/src/workflow.ts @@ -47,7 +47,9 @@ export interface WorkflowContext { options?: WorkflowStepOptions ): Promise; /** Run thunks concurrently, returning null for a thunk that throws. */ - parallel(thunks: Array<() => Promise>): Promise>; + parallel( + thunks: Array<() => Promise | TResult> + ): Promise>; /** Run each item through every stage without barriers between stages. */ pipeline(items: unknown[], ...stages: WorkflowPipelineStage[]): Promise; /** Start a named workflow progress phase. */ diff --git a/nodejs/test/workflow.test.ts b/nodejs/test/workflow.test.ts index 3f4fafe46f..60c04c223a 100644 --- a/nodejs/test/workflow.test.ts +++ b/nodejs/test/workflow.test.ts @@ -284,6 +284,220 @@ describe("workflows", () => { }); }); + it("runs parallel as a barrier and maps a throwing thunk to null", async () => { + const first = Promise.withResolvers(); + const second = Promise.withResolvers(); + const started: string[] = []; + const session = new CopilotSession("session-parallel", {} as never); + const workflow = defineWorkflow({ + meta: { + name: "parallel", + description: "Parallel combinator test", + phases: [], + }, + run: async ({ parallel }) => + parallel([ + async () => { + started.push("first"); + return first.promise; + }, + async () => { + started.push("second"); + return second.promise; + }, + async () => { + started.push("throwing"); + throw new Error("expected"); + }, + ]), + }); + session.registerWorkflows([workflow]); + + let settled = false; + const execution = session.clientSessionApis + .workflow!.execute({ + sessionId: session.sessionId, + name: "parallel", + runId: "run-parallel", + args: {}, + }) + .finally(() => { + settled = true; + }); + await vi.waitFor(() => expect(started).toEqual(["first", "second", "throwing"])); + + second.resolve("second"); + await Promise.resolve(); + expect(settled).toBe(false); + + first.resolve("first"); + await expect(execution).resolves.toEqual({ result: ["first", "second", null] }); + }); + + it("rejects already-invoked promises passed to parallel with a clear diagnostic", async () => { + const session = new CopilotSession("session-parallel-promises", {} as never); + const workflow = defineWorkflow({ + meta: { + name: "parallel-promises", + description: "Parallel misuse diagnostic", + phases: [], + }, + run: async ({ parallel }) => + parallel([Promise.resolve("already running")] as unknown as Array< + () => Promise + >), + }); + session.registerWorkflows([workflow]); + + await expect( + session.clientSessionApis.workflow!.execute({ + sessionId: session.sessionId, + name: "parallel-promises", + runId: "run-parallel-promises", + args: {}, + }) + ).rejects.toThrow( + "parallel() expects an array of functions, not promises. Wrap each call: () => agent(...)" + ); + }); + + it("flows pipeline items independently and drops only the item whose stage throws", async () => { + const releaseFirstItem = Promise.withResolvers(); + const secondStageStarted = Promise.withResolvers(); + const finalStageItems: string[] = []; + const session = new CopilotSession("session-pipeline", {} as never); + const workflow = defineWorkflow({ + meta: { + name: "pipeline", + description: "Pipeline combinator test", + phases: [], + }, + run: async ({ pipeline }) => + pipeline( + ["slow", "fast", "throw"], + async (_previous, item) => { + if (item === "slow") { + await releaseFirstItem.promise; + } + if (item === "throw") { + throw new Error("expected"); + } + return `${item}-stage-1`; + }, + async (previous, item) => { + if (item === "fast") { + secondStageStarted.resolve(); + } + finalStageItems.push(item as string); + return `${previous}-stage-2`; + } + ), + }); + session.registerWorkflows([workflow]); + + const execution = session.clientSessionApis.workflow!.execute({ + sessionId: session.sessionId, + name: "pipeline", + runId: "run-pipeline", + args: {}, + }); + await secondStageStarted.promise; + expect(finalStageItems).toEqual(["fast"]); + + releaseFirstItem.resolve(); + await expect(execution).resolves.toEqual({ + result: ["slow-stage-1-stage-2", "fast-stage-1-stage-2", null], + }); + expect(finalStageItems).toEqual(["fast", "slow"]); + }); + + it("enforces the 4096-item cap for parallel and pipeline", async () => { + const session = new CopilotSession("session-fanout-cap", {} as never); + const workflow = defineWorkflow({ + meta: { + name: "fanout-cap", + description: "Fan-out cap test", + phases: [], + }, + run: async ({ parallel, pipeline }) => { + const tooManyItems = Array.from({ length: 4097 }, () => null); + const parallelError = await parallel( + tooManyItems.map(() => async () => null) + ).catch((error: unknown) => error); + const pipelineError = await pipeline(tooManyItems).catch((error: unknown) => error); + return { + parallel: (parallelError as Error).message, + pipeline: (pipelineError as Error).message, + }; + }, + }); + session.registerWorkflows([workflow]); + + await expect( + session.clientSessionApis.workflow!.execute({ + sessionId: session.sessionId, + name: "fanout-cap", + runId: "run-fanout-cap", + args: {}, + }) + ).resolves.toEqual({ + result: { + parallel: "parallel() accepts at most 4096 items; got 4097.", + pipeline: "pipeline() accepts at most 4096 items; got 4097.", + }, + }); + }); + + it("does not deadlock nested combinators when only leaf agents use a one-slot limiter", async () => { + let active = 0; + let maxActive = 0; + let tail = Promise.resolve(); + const sendRequest = vi.fn( + async (method: string, params: { prompt: string }): Promise<{ result: string }> => { + if (method !== "session.workflow.agent") { + throw new Error(`Unexpected method: ${method}`); + } + const previous = tail; + const done = Promise.withResolvers(); + tail = done.promise; + await previous; + active++; + maxActive = Math.max(maxActive, active); + await Promise.resolve(); + active--; + done.resolve(); + return { result: params.prompt }; + } + ); + const session = new CopilotSession("session-nested-combinators", { + sendRequest, + } as never); + const workflow = defineWorkflow({ + meta: { + name: "nested-combinators", + description: "Nested combinator deadlock regression", + phases: [], + }, + run: async ({ agent, parallel, pipeline }) => + parallel([ + () => parallel([() => agent("a"), () => agent("b")]), + () => pipeline(["c"], (_previous, item) => agent(item as string)), + ]), + }); + session.registerWorkflows([workflow]); + + await expect( + session.clientSessionApis.workflow!.execute({ + sessionId: session.sessionId, + name: "nested-combinators", + runId: "run-nested-combinators", + args: {}, + }) + ).resolves.toEqual({ result: [["a", "b"], ["c"]] }); + expect(maxActive).toBe(1); + expect(sendRequest).toHaveBeenCalledTimes(3); + }); + it("flushes buffered progress in finally when the workflow body throws", async () => { const sendRequest = vi.fn(async () => ({})); const session = new CopilotSession("session-throw-progress", { sendRequest } as never); From beabf08df2e9a7f2da6e05be3bc75ebfe69223cb Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Wed, 15 Jul 2026 20:39:58 -0700 Subject: [PATCH 07/22] Phase 8: Durable `step()` and execution state in SQL SDK-side durable step(): ctx.step(key, producer) does get-then-put across the RPC gap (workflow.journal.get -> run producer on miss -> workflow.journal.put), with a cached JSON null served as a hit and producer failures left un-journaled (retried on resume). Adds getRun() forwarding and resume integration wiring. Pairs with the runtime SQL journal/run store. Verified (workflows-scoped, exit 0): npm run build; test/workflow.test.ts (24 passed, incl. step-once + cached-null + failures-not-cached + getRun forwarding). Deviations: none. --- nodejs/src/session.ts | 38 +++++++++++++++++- nodejs/src/workflow.ts | 2 + nodejs/test/workflow.test.ts | 76 ++++++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 1 deletion(-) diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 4a4f4482d2..ccdab1a06a 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -69,6 +69,7 @@ import { type SessionWorkflowApi, type WorkflowContext, type WorkflowHandle, + type WorkflowStepOptions, } from "./workflow.js"; /** @@ -318,6 +319,7 @@ export class CopilotSession { } return envelope.result; }) as SessionWorkflowApi["run"], + getRun: (runId) => this.rpc.workflow.getRun({ runId }), }; /** @@ -1101,7 +1103,41 @@ export class CopilotSession { }); return response.result ?? null; }, - step: async () => unsupported("workflow.step"), + step: async ( + key: string, + producer: () => Promise | TResult, + options: WorkflowStepOptions = {} + ): Promise => { + await progress.flush(); + if (options.volatile) { + return producer(); + } + const cached = await self.rpc.workflow.journal.get({ + runId: params.runId, + key, + }); + if (cached.hit) { + return cached.resultJson as TResult; + } + + // Producers are best-effort at-least-once across crashes or + // concurrent callers, so authors must make side effects idempotent. + const result = await producer(); + const resultJson = JSON.stringify(result); + if (resultJson === undefined) { + throw new Error( + `step("${key}") returned a value that is not JSON-serializable` + ); + } + await self.rpc.workflow.journal.put({ + runId: params.runId, + key, + resultJson: result as Parameters< + typeof self.rpc.workflow.journal.put + >[0]["resultJson"], + }); + return result; + }, parallel: runWorkflowParallel, pipeline: runWorkflowPipeline, workflow: async () => unsupported("workflow.runNested"), diff --git a/nodejs/src/workflow.ts b/nodejs/src/workflow.ts index 3680f91fcd..0c60d09b4a 100644 --- a/nodejs/src/workflow.ts +++ b/nodejs/src/workflow.ts @@ -114,6 +114,8 @@ export interface SessionWorkflowApi { name: string, options?: RunOptions ): Promise; + /** Read the latest durable envelope for a workflow run. */ + getRun(runId: string): Promise; } /** Error thrown when a foreground workflow run does not complete successfully. */ diff --git a/nodejs/test/workflow.test.ts b/nodejs/test/workflow.test.ts index 60c04c223a..961e997b03 100644 --- a/nodejs/test/workflow.test.ts +++ b/nodejs/test/workflow.test.ts @@ -284,6 +284,82 @@ describe("workflows", () => { }); }); + it("runs a durable step once, serves cached null, and does not cache failures", async () => { + const journal = new Map(); + const sendRequest = vi.fn( + async (method: string, params: { key?: string; resultJson?: unknown }) => { + if (method === "session.workflow.journal.get") { + return journal.has(params.key!) + ? { hit: true, resultJson: journal.get(params.key!) } + : { hit: false }; + } + if (method === "session.workflow.journal.put") { + journal.set(params.key!, params.resultJson); + return {}; + } + throw new Error(`Unexpected method: ${method}`); + } + ); + const session = new CopilotSession("session-step", { sendRequest } as never); + let cachedProducerCalls = 0; + let failingProducerCalls = 0; + const workflow = defineWorkflow({ + meta: { + name: "step", + description: "Durable step context test", + phases: [], + }, + run: async ({ step }) => { + const first = await step("cached-null", async () => { + cachedProducerCalls++; + return null; + }); + const second = await step("cached-null", async () => { + cachedProducerCalls++; + return "wrong"; + }); + const failed = await step("retry", async () => { + failingProducerCalls++; + throw new Error("transient"); + }).catch(() => "failed"); + const retried = await step("retry", async () => { + failingProducerCalls++; + return "recovered"; + }); + return { first, second, failed, retried }; + }, + }); + session.registerWorkflows([workflow]); + + await expect( + session.clientSessionApis.workflow!.execute({ + sessionId: session.sessionId, + name: "step", + runId: "run-step", + args: {}, + }) + ).resolves.toEqual({ + result: { first: null, second: null, failed: "failed", retried: "recovered" }, + }); + expect(cachedProducerCalls).toBe(1); + expect(failingProducerCalls).toBe(2); + expect( + sendRequest.mock.calls.filter(([method]) => method === "session.workflow.journal.put") + ).toHaveLength(2); + }); + + it("exposes workflow getRun and forwards the run id", async () => { + const envelope = { runId: "run-read", status: "error", error: "failed" }; + const sendRequest = vi.fn(async () => envelope); + const session = new CopilotSession("session-read", { sendRequest } as never); + + await expect(session.workflow.getRun("run-read")).resolves.toEqual(envelope); + expect(sendRequest).toHaveBeenCalledWith("session.workflow.getRun", { + sessionId: session.sessionId, + runId: "run-read", + }); + }); + it("runs parallel as a barrier and maps a throwing thunk to null", async () => { const first = Promise.withResolvers(); const second = Promise.withResolvers(); From 00513d693cf9ec5895b6940189eae8633684e186 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Wed, 15 Jul 2026 22:56:27 -0700 Subject: [PATCH 08/22] Phase 9: Limits enforcement and the pre-run approval gate SDK-side cancellation support for the Phase-9 limits/timeout/cancel machinery: workflow.abort trips the per-run AbortController so in-flight ctx awaits reject with an AbortError and a well-behaved body unwinds cooperatively; session.workflow.cancel forwards the runId. Pairs with the runtime enforcement, approval/feedback gate, and halt->continue flow. Verified (workflows-scoped, exit 0): npm run build; test/workflow.test.ts (both new Phase-9 tests pass: cancel forwards runId; workflow.abort rejects the in-flight await with AbortError). The lone unrelated failure is the documented win32-arm64 platform-package env wrinkle. Deviations: none. --- nodejs/src/session.ts | 92 +++++++++++++++++++++++++++--------- nodejs/src/workflow.ts | 2 + nodejs/test/workflow.test.ts | 48 +++++++++++++++++++ 3 files changed, 119 insertions(+), 23 deletions(-) diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index ccdab1a06a..638e56a241 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -179,6 +179,7 @@ class WorkflowProgressBuffer { if (this.closed) { throw new Error("Cannot log after the workflow run has settled"); } + this.pending.push({ seq: this.nextSeq++, kind, text }); this.scheduleFlush(); } @@ -228,6 +229,33 @@ class WorkflowProgressBuffer { } } +async function awaitWorkflowOperation( + operation: Promise, + signal: AbortSignal +): Promise { + throwIfWorkflowAborted(signal); + let rejectAbort: ((reason?: unknown) => void) | undefined; + const onAbort = () => + rejectAbort?.(signal.reason ?? new DOMException("Workflow run was aborted", "AbortError")); + try { + return await Promise.race([ + operation, + new Promise((_resolve, reject) => { + rejectAbort = reject; + signal.addEventListener("abort", onAbort, { once: true }); + }), + ]); + } finally { + signal.removeEventListener("abort", onAbort); + } +} + +function throwIfWorkflowAborted(signal: AbortSignal): void { + if (signal.aborted) { + throw signal.reason ?? new DOMException("Workflow run was aborted", "AbortError"); + } +} + /** Assistant message event - the final response from the assistant. */ export type AssistantMessageEvent = Extract; @@ -320,6 +348,7 @@ export class CopilotSession { return envelope.result; }) as SessionWorkflowApi["run"], getRun: (runId) => this.rpc.workflow.getRun({ runId }), + cancel: (runId) => this.rpc.workflow.cancel({ runId }), }; /** @@ -1088,19 +1117,28 @@ export class CopilotSession { args: params.args, session: self, signal: controller.signal, - phase: (title: string) => progress.enqueue("phase", title), - log: (message: string) => progress.enqueue("log", message), + phase: (title: string) => { + throwIfWorkflowAborted(controller.signal); + progress.enqueue("phase", title); + }, + log: (message: string) => { + throwIfWorkflowAborted(controller.signal); + progress.enqueue("log", message); + }, agent: async (prompt, options = {}) => { await progress.flush(); - const response = await self.rpc.workflow.agent({ - workflowRunId: params.runId, - prompt, - opts: { - label: options.label, - schema: options.schema, - model: options.model, - }, - }); + const response = await awaitWorkflowOperation( + self.rpc.workflow.agent({ + workflowRunId: params.runId, + prompt, + opts: { + label: options.label, + schema: options.schema, + model: options.model, + }, + }), + controller.signal + ); return response.result ?? null; }, step: async ( @@ -1112,10 +1150,13 @@ export class CopilotSession { if (options.volatile) { return producer(); } - const cached = await self.rpc.workflow.journal.get({ - runId: params.runId, - key, - }); + const cached = await awaitWorkflowOperation( + self.rpc.workflow.journal.get({ + runId: params.runId, + key, + }), + controller.signal + ); if (cached.hit) { return cached.resultJson as TResult; } @@ -1129,13 +1170,16 @@ export class CopilotSession { `step("${key}") returned a value that is not JSON-serializable` ); } - await self.rpc.workflow.journal.put({ - runId: params.runId, - key, - resultJson: result as Parameters< - typeof self.rpc.workflow.journal.put - >[0]["resultJson"], - }); + await awaitWorkflowOperation( + self.rpc.workflow.journal.put({ + runId: params.runId, + key, + resultJson: result as Parameters< + typeof self.rpc.workflow.journal.put + >[0]["resultJson"], + }), + controller.signal + ); return result; }, parallel: runWorkflowParallel, @@ -1155,7 +1199,9 @@ export class CopilotSession { } }, async abort(params) { - self.workflowAbortControllers.get(params.runId)?.abort(); + self.workflowAbortControllers + .get(params.runId) + ?.abort(new DOMException("Workflow run was aborted", "AbortError")); return {}; }, }; diff --git a/nodejs/src/workflow.ts b/nodejs/src/workflow.ts index 0c60d09b4a..9d8b2a4feb 100644 --- a/nodejs/src/workflow.ts +++ b/nodejs/src/workflow.ts @@ -116,6 +116,8 @@ export interface SessionWorkflowApi { ): Promise; /** Read the latest durable envelope for a workflow run. */ getRun(runId: string): Promise; + /** Cancel a workflow run and return its terminal envelope. */ + cancel(runId: string): Promise; } /** Error thrown when a foreground workflow run does not complete successfully. */ diff --git a/nodejs/test/workflow.test.ts b/nodejs/test/workflow.test.ts index 961e997b03..451ecc8ae9 100644 --- a/nodejs/test/workflow.test.ts +++ b/nodejs/test/workflow.test.ts @@ -360,6 +360,18 @@ describe("workflows", () => { }); }); + it("exposes workflow cancel and forwards the run id", async () => { + const envelope = { runId: "run-cancel", status: "cancelled", reason: "cancelled" }; + const sendRequest = vi.fn(async () => envelope); + const session = new CopilotSession("session-cancel", { sendRequest } as never); + + await expect(session.workflow.cancel("run-cancel")).resolves.toEqual(envelope); + expect(sendRequest).toHaveBeenCalledWith("session.workflow.cancel", { + sessionId: session.sessionId, + runId: "run-cancel", + }); + }); + it("runs parallel as a barrier and maps a throwing thunk to null", async () => { const first = Promise.withResolvers(); const second = Promise.withResolvers(); @@ -642,6 +654,42 @@ describe("workflows", () => { await expect(execution).resolves.toEqual({ result: true }); }); + it("rejects an in-flight runtime-backed await when workflow.abort trips the signal", async () => { + const agentResponse = Promise.withResolvers<{ result: string }>(); + const sendRequest = vi.fn(async (method: string) => { + if (method === "session.workflow.agent") { + return agentResponse.promise; + } + return {}; + }); + const session = new CopilotSession("session-abort-await", { sendRequest } as never); + const workflow = defineWorkflow({ + meta: { + name: "abort-await", + description: "Abort an in-flight workflow await", + phases: [], + }, + run: async ({ agent }) => agent("wait forever"), + }); + session.registerWorkflows([workflow]); + + const execution = session.clientSessionApis.workflow!.execute({ + sessionId: session.sessionId, + name: "abort-await", + runId: "run-abort-await", + args: {}, + }); + await vi.waitFor(() => expect(sendRequest).toHaveBeenCalledWith("session.workflow.agent", expect.anything())); + + await session.clientSessionApis.workflow!.abort({ + sessionId: session.sessionId, + runId: "run-abort-await", + }); + + await expect(execution).rejects.toMatchObject({ name: "AbortError" }); + agentResponse.resolve({ result: "late" }); + }); + it("dispatches workflow.execute by name and returns a structured unknown-name error", async () => { const run = vi.fn(async ({ args, log }) => { log("executing"); From d5b73aa8fd39d17a2ad58cc8d4227428c1757d2d Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Wed, 15 Jul 2026 23:27:26 -0700 Subject: [PATCH 09/22] Phase 10: Full session and Node power (the trust-tier payoff) Strengthens the SDK workflow-context unit coverage to lock the full-session trust-tier guarantee: asserts ctx.session is identical to the joinSession result (context.session === joinSessionResult and context.session.rpc === joinSessionResult.rpc, proving no facade/pruning) and that session RPC is reachable from the context (invokes context.session.rpc.tasks.list()). Test-only; pairs with the runtime E2E. Verified (workflows-scoped, exit 0): npm run build; test/workflow.test.ts 26/26. Deviations: none. --- nodejs/test/workflow.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/nodejs/test/workflow.test.ts b/nodejs/test/workflow.test.ts index 451ecc8ae9..409522a6f0 100644 --- a/nodejs/test/workflow.test.ts +++ b/nodejs/test/workflow.test.ts @@ -147,12 +147,15 @@ describe("workflows", () => { ); }); - it("builds the workflow context with args, progress, signal, and the joined session identity", async () => { + it("builds the workflow context with the unrestricted joined session identity", async () => { process.env.SESSION_ID = "session-context"; const sendRequest = vi.fn(async (method: string) => { if (method === "session.workflow.log") { return {}; } + if (method === "session.tasks.list") { + return { tasks: [] }; + } throw new Error(`Unexpected method: ${method}`); }); const joinedSession = new CopilotSession("session-context", { sendRequest } as never); @@ -171,7 +174,8 @@ describe("workflows", () => { contextSeen.resolve(context); context.phase("A"); context.log("hi"); - return { ok: true }; + const tasks = await context.session.rpc.tasks.list(); + return { ok: true, taskCount: tasks.tasks.length }; }, }); vi.spyOn(CopilotClient.prototype, "resumeSessionForExtension").mockImplementation( @@ -192,8 +196,12 @@ describe("workflows", () => { expect(context.args).toEqual({ value: 42 }); expect(context.session).toBe(joinSessionResult); + expect(context.session.rpc).toBe(joinSessionResult.rpc); expect(context.signal).toBeInstanceOf(AbortSignal); - expect(executeResult).toEqual({ result: { ok: true } }); + expect(executeResult).toEqual({ result: { ok: true, taskCount: 0 } }); + expect(sendRequest).toHaveBeenCalledWith("session.tasks.list", { + sessionId: joinSessionResult.sessionId, + }); expect(sendRequest).toHaveBeenCalledWith("session.workflow.log", { sessionId: joinSessionResult.sessionId, runId: "run-context", From c6d9921d0f3ae16edd40e658286adefb09a34d79 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Thu, 16 Jul 2026 00:35:33 -0700 Subject: [PATCH 10/22] Phase 11: Invocation ergonomics: the `run_workflow` tool, commands, name identity Makes name-based resolution the primary path in session.workflow.run (the workflow-handle overload becomes local sugar), matching how tools/commands/subagents resolve by name, and removes a now-dead module-global workflowDefinitions map (workflow.execute resolves via the session-scoped registry instead). Adds unit coverage that name resolution selects the right registered workflow and that run-by-name forwards {name, args}. Duplicate-name rejection continues to rely on the Phase-3 registration policy. Verified (workflows-scoped, exit 0): npm run build; test/workflow.test.ts (26 passed). Deviations: none. --- nodejs/src/workflow.ts | 20 +++++++++----------- nodejs/test/workflow.test.ts | 33 +++++++++++++++++++++++---------- 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/nodejs/src/workflow.ts b/nodejs/src/workflow.ts index 9d8b2a4feb..52a2bf2982 100644 --- a/nodejs/src/workflow.ts +++ b/nodejs/src/workflow.ts @@ -93,6 +93,15 @@ export interface RunOptions { /** Friendly workflow API exposed on a session. */ export interface SessionWorkflowApi { + run(name: string, options: RunOptions & { background: true }): Promise; + run( + name: string, + options?: RunOptions & { background?: false } + ): Promise; + run( + name: string, + options?: RunOptions + ): Promise; run( workflow: WorkflowHandle, options: RunOptions & { background: true } @@ -105,15 +114,6 @@ export interface SessionWorkflowApi { workflow: WorkflowHandle, options?: RunOptions ): Promise; - run(name: string, options: RunOptions & { background: true }): Promise; - run( - name: string, - options?: RunOptions & { background?: false } - ): Promise; - run( - name: string, - options?: RunOptions - ): Promise; /** Read the latest durable envelope for a workflow run. */ getRun(runId: string): Promise; /** Cancel a workflow run and return its terminal envelope. */ @@ -137,7 +137,6 @@ interface StoredWorkflow { run(context: WorkflowContext): Promise; } -const workflowDefinitions = new Map(); const workflowHandles = new WeakMap(); function validateLimits(meta: WorkflowMeta): void { @@ -172,7 +171,6 @@ export function defineWorkflow( }; const handle = Object.freeze({ meta: definition.meta }) as WorkflowHandle; - workflowDefinitions.set(definition.meta.name, stored); workflowHandles.set(handle, stored); return handle; } diff --git a/nodejs/test/workflow.test.ts b/nodejs/test/workflow.test.ts index 409522a6f0..bf5ebd61dc 100644 --- a/nodejs/test/workflow.test.ts +++ b/nodejs/test/workflow.test.ts @@ -698,32 +698,45 @@ describe("workflows", () => { agentResponse.resolve({ result: "late" }); }); - it("dispatches workflow.execute by name and returns a structured unknown-name error", async () => { - const run = vi.fn(async ({ args, log }) => { + it("dispatches workflow.execute to the registered workflow selected by name", async () => { + const firstRun = vi.fn(async () => ({ selected: "first" })); + const secondRun = vi.fn(async ({ args, log }) => { log("executing"); - return { echoed: args }; + return { selected: "second", echoed: args }; }); - const workflow = defineWorkflow({ + const firstWorkflow = defineWorkflow({ meta: { - name: "echo", - description: "Echo arguments", + name: "first", + description: "First workflow", phases: [], }, - run, + run: firstRun, + }); + const secondWorkflow = defineWorkflow({ + meta: { + name: "second", + description: "Second workflow", + phases: [], + }, + run: secondRun, }); const session = new CopilotSession("session-execute", { sendRequest: vi.fn(async () => ({})), } as never); - session.registerWorkflows([workflow]); + session.registerWorkflows([firstWorkflow, secondWorkflow]); await expect( session.clientSessionApis.workflow!.execute({ sessionId: session.sessionId, - name: "echo", + name: "second", runId: "run-echo", args: { message: "hello" }, }) - ).resolves.toEqual({ result: { echoed: { message: "hello" } } }); + ).resolves.toEqual({ + result: { selected: "second", echoed: { message: "hello" } }, + }); + expect(firstRun).not.toHaveBeenCalled(); + expect(secondRun).toHaveBeenCalledOnce(); const error = await session.clientSessionApis .workflow!.execute({ From 7d51f982d2fb5c29873930e45a425da9dbb9a5c8 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Thu, 16 Jul 2026 00:55:55 -0700 Subject: [PATCH 11/22] Phase 12: Forbid workflow nesting Nested workflows are not supported: ctx.workflow(name, args) now throws a clear "nested workflows are not supported" error synchronously at the call site and forwards NO workflow.runNested request (no RPC leaves the SDK). The parentRunId/rootRunId contract scaffolding is untouched. Pairs with the runtime workflow.runNested structured rejection. (Phase reshaped from "Nested workflows" to "Forbid workflow nesting" per a human-authorized plan amendment.) Verified (workflows-scoped, exit 0): npm run build; test/workflow.test.ts 27/27 (incl. ctx.workflow-throws-without-forwarding test asserting sendRequest is not called). Deviations: none. --- nodejs/src/session.ts | 8 +++----- nodejs/src/workflow.ts | 2 +- nodejs/test/workflow.test.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 638e56a241..89f5f0de87 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -1109,10 +1109,6 @@ export class CopilotSession { await self.rpc.workflow.log({ runId: params.runId, lines }); }); try { - const unsupported = async (method: string): Promise => { - await progress.flush(); - throw new Error(`${method} is not yet supported`); - }; const context: WorkflowContext = { args: params.args, session: self, @@ -1184,7 +1180,9 @@ export class CopilotSession { }, parallel: runWorkflowParallel, pipeline: runWorkflowPipeline, - workflow: async () => unsupported("workflow.runNested"), + workflow: async () => { + throw new Error("nested workflows are not supported"); + }, }; const result = await definition.run(context); return { result } as WorkflowExecuteResult; diff --git a/nodejs/src/workflow.ts b/nodejs/src/workflow.ts index 52a2bf2982..0306a0d4e1 100644 --- a/nodejs/src/workflow.ts +++ b/nodejs/src/workflow.ts @@ -56,7 +56,7 @@ export interface WorkflowContext { phase(title: string): void; /** Emit a workflow progress line. */ log(message: string): void; - /** Invoke another registered workflow as a child run. */ + /** Reject because nested workflows are not supported. */ workflow(name: string, args?: unknown): Promise; /** Caller-supplied input, forwarded verbatim. */ args: TArgs; diff --git a/nodejs/test/workflow.test.ts b/nodejs/test/workflow.test.ts index bf5ebd61dc..a602d2eef3 100644 --- a/nodejs/test/workflow.test.ts +++ b/nodejs/test/workflow.test.ts @@ -212,6 +212,32 @@ describe("workflows", () => { }); }); + it("rejects nested workflows without forwarding a runNested request", async () => { + const sendRequest = vi.fn(async () => { + throw new Error("Unexpected forward request"); + }); + const session = new CopilotSession("session-no-nesting", { sendRequest } as never); + const workflow = defineWorkflow({ + meta: { + name: "no-nesting", + description: "Nested workflow rejection test", + phases: [], + }, + run: async (context) => context.workflow("nested", { value: 42 }), + }); + session.registerWorkflows([workflow]); + + await expect( + session.clientSessionApis.workflow!.execute({ + sessionId: session.sessionId, + name: "no-nesting", + runId: "run-no-nesting", + args: {}, + }) + ).rejects.toThrow("nested workflows are not supported"); + expect(sendRequest).not.toHaveBeenCalled(); + }); + it("flushes progress incrementally while a workflow body is awaiting", async () => { const sendRequest = vi.fn(async () => ({})); const session = new CopilotSession("session-live-progress", { sendRequest } as never); From fc0626e3e033e0280e155a498c438f12650f2abb Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Thu, 16 Jul 2026 13:12:26 -0700 Subject: [PATCH 12/22] Mark Dynamic Workflows SDK APIs As Experimental Add @experimental JSDoc tags to the hand-authored Dynamic Workflows surface, matching the Canvas prior art. Covers the exported symbols in workflow.ts (defineWorkflow, WorkflowContext, WorkflowHandle, SessionWorkflowApi, WorkflowRunError, and the related option and type aliases), the WorkflowLimits and WorkflowMeta types in types.ts, the readonly workflow property on the session, and the workflows option on JoinSessionConfig. The generated wire types in generated/rpc.ts already carry @experimental because every workflow RPC method is marked stability: "experimental" in the runtime contract, so no generated files change here. This commit only annotates the hand-written ergonomic surface so consumers are warned the API may change or be removed in a future SDK or CLI release. --- nodejs/src/extension.ts | 6 ++++ nodejs/src/session.ts | 6 ++++ nodejs/src/types.ts | 14 +++++++-- nodejs/src/workflow.ts | 69 +++++++++++++++++++++++++++++++++++------ 4 files changed, 84 insertions(+), 11 deletions(-) diff --git a/nodejs/src/extension.ts b/nodejs/src/extension.ts index 0d55379f5c..7223139642 100644 --- a/nodejs/src/extension.ts +++ b/nodejs/src/extension.ts @@ -27,6 +27,12 @@ export type JoinSessionConfig = Omit< "onPermissionRequest" | "extensionSdkPath" > & { onPermissionRequest?: PermissionHandler; + /** + * Workflow handles to register when the extension joins the session. + * + * @experimental Part of the experimental Dynamic Workflows surface and may + * change or be removed in future SDK or CLI releases. + */ workflows?: WorkflowHandle[]; }; diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 89f5f0de87..cf4ec410c0 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -319,6 +319,12 @@ export class CopilotSession { /** @internal Client session API handlers, populated by CopilotClient during create/resume. */ clientSessionApis: ClientSessionApiHandlers = {}; + /** + * Friendly workflow API for running registered workflows by name or handle. + * + * @experimental Part of the experimental Dynamic Workflows surface and may + * change or be removed in future SDK or CLI releases. + */ readonly workflow: SessionWorkflowApi = { run: (async ( nameOrHandle: string | WorkflowHandle, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 0f0a9de303..1a3dc109ce 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1836,7 +1836,12 @@ export interface CanvasProviderIdentity { name?: string; } -/** Static resource ceilings declared by a workflow before it runs. */ +/** + * Static resource ceilings declared by a workflow before it runs. + * + * @experimental Part of the experimental Dynamic Workflows surface and may + * change or be removed in future SDK or CLI releases. + */ export interface WorkflowLimits { /** Maximum number of workflow subagents that may run concurrently. Must be positive when present. */ maxConcurrentSubagents?: number; @@ -1846,7 +1851,12 @@ export interface WorkflowLimits { timeout?: number; } -/** Registration metadata for an extension-authored workflow. */ +/** + * Registration metadata for an extension-authored workflow. + * + * @experimental Part of the experimental Dynamic Workflows surface and may + * change or be removed in future SDK or CLI releases. + */ export interface WorkflowMeta { /** Stable workflow name used for invocation. */ name: string; diff --git a/nodejs/src/workflow.ts b/nodejs/src/workflow.ts index 0306a0d4e1..4d701102ce 100644 --- a/nodejs/src/workflow.ts +++ b/nodejs/src/workflow.ts @@ -13,30 +13,53 @@ declare const workflowHandleBrand: unique symbol; * * Supports `type`, `required`, `enum`, `const`, recursive `properties`/`items`, * and `anyOf`/`oneOf`/`allOf`. Other JSON Schema keywords are ignored. + * + * @experimental Part of the experimental Dynamic Workflows surface and may + * change or be removed in future SDK or CLI releases. */ export type WorkflowJsonSchema = Record; -/** Options for one workflow-scoped subagent call. */ +/** + * Options for one workflow-scoped subagent call. + * + * @experimental Part of the experimental Dynamic Workflows surface and may + * change or be removed in future SDK or CLI releases. + */ export interface WorkflowAgentOptions { label?: string; schema?: WorkflowJsonSchema; model?: string; } -/** Options for a durable workflow step. */ +/** + * Options for a durable workflow step. + * + * @experimental Part of the experimental Dynamic Workflows surface and may + * change or be removed in future SDK or CLI releases. + */ export interface WorkflowStepOptions { /** Skip the journal and always invoke the producer. */ volatile?: boolean; } -/** One stage in a per-item workflow pipeline. */ +/** + * One stage in a per-item workflow pipeline. + * + * @experimental Part of the experimental Dynamic Workflows surface and may + * change or be removed in future SDK or CLI releases. + */ export type WorkflowPipelineStage = ( previous: TInput, item: unknown, index: number ) => Promise | TResult; -/** Context passed to an extension-authored workflow body. */ +/** + * Context passed to an extension-authored workflow body. + * + * @experimental Part of the experimental Dynamic Workflows surface and may + * change or be removed in future SDK or CLI releases. + */ export interface WorkflowContext { /** Spawn and await one workflow-scoped subagent. */ agent(prompt: string, options?: WorkflowAgentOptions): Promise; @@ -66,13 +89,23 @@ export interface WorkflowContext { signal: AbortSignal; } -/** Definition accepted by {@link defineWorkflow}. */ +/** + * Definition accepted by {@link defineWorkflow}. + * + * @experimental Part of the experimental Dynamic Workflows surface and may + * change or be removed in future SDK or CLI releases. + */ export interface WorkflowDefinition { meta: WorkflowMeta; run(context: WorkflowContext): Promise; } -/** Opaque reusable reference to a defined workflow. */ +/** + * Opaque reusable reference to a defined workflow. + * + * @experimental Part of the experimental Dynamic Workflows surface and may + * change or be removed in future SDK or CLI releases. + */ export interface WorkflowHandle { readonly meta: WorkflowMeta; readonly [workflowHandleBrand]: { @@ -81,7 +114,12 @@ export interface WorkflowHandle { }; } -/** Options for invoking a workflow. */ +/** + * Options for invoking a workflow. + * + * @experimental Part of the experimental Dynamic Workflows surface and may + * change or be removed in future SDK or CLI releases. + */ export interface RunOptions { /** Input surfaced as `context.args`. */ args?: TArgs; @@ -91,7 +129,12 @@ export interface RunOptions { resumeFromRunId?: string; } -/** Friendly workflow API exposed on a session. */ +/** + * Friendly workflow API exposed on a session. + * + * @experimental Part of the experimental Dynamic Workflows surface and may + * change or be removed in future SDK or CLI releases. + */ export interface SessionWorkflowApi { run(name: string, options: RunOptions & { background: true }): Promise; run( @@ -120,7 +163,12 @@ export interface SessionWorkflowApi { cancel(runId: string): Promise; } -/** Error thrown when a foreground workflow run does not complete successfully. */ +/** + * Error thrown when a foreground workflow run does not complete successfully. + * + * @experimental Part of the experimental Dynamic Workflows surface and may + * change or be removed in future SDK or CLI releases. + */ export class WorkflowRunError extends Error { constructor(public readonly envelope: WorkflowRunResult) { super( @@ -159,6 +207,9 @@ function validateLimits(meta: WorkflowMeta): void { /** * Defines an extension-authored workflow and returns an opaque registration handle. + * + * @experimental Part of the experimental Dynamic Workflows surface and may + * change or be removed in future SDK or CLI releases. */ export function defineWorkflow( definition: WorkflowDefinition From 713b4cb14cf2f75c2107940003dad5f8eea20ec6 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Thu, 16 Jul 2026 16:45:46 -0700 Subject: [PATCH 13/22] Rename Dynamic Workflows to Agent Orchestrations Renames the authoring surface from "workflow" to "orchestration" to match the new product name "agent orchestrations": - defineWorkflow -> defineOrchestration; WorkflowContext/Definition/Handle/ Meta/Limits -> Orchestration*; SessionWorkflowApi -> SessionOrchestrationApi (session.orchestration); WorkflowRunError -> OrchestrationRunError. - joinSession({ workflows }) -> joinSession({ orchestrations }). - workflow.ts -> orchestration.ts (and its test); generated/rpc.ts regenerated from the renamed runtime contract (workflow.* -> orchestration.*). The context primitives (step, agent, parallel, pipeline, phase, log, run) keep their names. Companion runtime rename lands in github/copilot-agent-runtime#12953. --- nodejs/src/client.ts | 12 +- nodejs/src/extension.ts | 36 +- nodejs/src/generated/rpc.ts | 1168 ++++++++--------- nodejs/src/index.ts | 24 +- nodejs/src/orchestration.ts | 236 ++++ nodejs/src/session.ts | 166 +-- nodejs/src/types.ts | 26 +- nodejs/src/workflow.ts | 236 ---- ...workflow.test.ts => orchestration.test.ts} | 255 ++-- 9 files changed, 1087 insertions(+), 1072 deletions(-) create mode 100644 nodejs/src/orchestration.ts delete mode 100644 nodejs/src/workflow.ts rename nodejs/test/{workflow.test.ts => orchestration.test.ts} (76%) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 6e0d818993..923674ca29 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -85,7 +85,7 @@ import type { TypedSessionLifecycleHandler, } from "./types.js"; import { defaultJoinSessionPermissionHandler } from "./types.js"; -import type { WorkflowHandle } from "./workflow.js"; +import type { OrchestrationHandle } from "./orchestration.js"; /** * Minimum protocol version this SDK can communicate with. @@ -1671,15 +1671,15 @@ export class CopilotClient { async resumeSessionForExtension( sessionId: string, config: ResumeSessionConfig, - workflows?: WorkflowHandle[] + orchestrations?: OrchestrationHandle[] ): Promise { - return this.resumeSessionInternal(sessionId, config, workflows); + return this.resumeSessionInternal(sessionId, config, orchestrations); } private async resumeSessionInternal( sessionId: string, config: ResumeSessionConfig, - workflows?: WorkflowHandle[] + orchestrations?: OrchestrationHandle[] ): Promise { if (!this.connection) { await this.start(); @@ -1697,7 +1697,7 @@ export class CopilotClient { session.registerTools(config.tools); session.registerCanvases(config.canvases); session.registerCommands(config.commands); - session.registerWorkflows(workflows); + session.registerOrchestrations(orchestrations); const { wireProvider: bearerWireProvider, wireProviders: bearerWireProviders, @@ -1769,7 +1769,7 @@ export class CopilotClient { })), toolSearch: config.toolSearch, canvases: config.canvases?.map((canvas) => canvas.declaration), - workflows: workflows?.map((workflow) => workflow.meta), + orchestrations: orchestrations?.map((orchestration) => orchestration.meta), requestCanvasRenderer: config.requestCanvasRenderer, requestExtensions: config.requestExtensions, extensionSdkPath: config.extensionSdkPath, diff --git a/nodejs/src/extension.ts b/nodejs/src/extension.ts index 7223139642..9747b06461 100644 --- a/nodejs/src/extension.ts +++ b/nodejs/src/extension.ts @@ -9,7 +9,7 @@ import { type PermissionHandler, type ResumeSessionConfig, } from "./types.js"; -import type { WorkflowHandle } from "./workflow.js"; +import type { OrchestrationHandle } from "./orchestration.js"; export { Canvas, @@ -28,28 +28,28 @@ export type JoinSessionConfig = Omit< > & { onPermissionRequest?: PermissionHandler; /** - * Workflow handles to register when the extension joins the session. + * Orchestration handles to register when the extension joins the session. * - * @experimental Part of the experimental Dynamic Workflows surface and may + * @experimental Part of the experimental Agent Orchestrations surface and may * change or be removed in future SDK or CLI releases. */ - workflows?: WorkflowHandle[]; + orchestrations?: OrchestrationHandle[]; }; -export type { ExtensionInfo, WorkflowLimits, WorkflowMeta } from "./types.js"; +export type { ExtensionInfo, OrchestrationLimits, OrchestrationMeta } from "./types.js"; export { - defineWorkflow, - WorkflowRunError, + defineOrchestration, + OrchestrationRunError, type RunOptions, - type SessionWorkflowApi, - type WorkflowAgentOptions, - type WorkflowContext, - type WorkflowDefinition, - type WorkflowHandle, - type WorkflowJsonSchema, - type WorkflowPipelineStage, - type WorkflowStepOptions, -} from "./workflow.js"; + type SessionOrchestrationApi, + type OrchestrationAgentOptions, + type OrchestrationContext, + type OrchestrationDefinition, + type OrchestrationHandle, + type OrchestrationJsonSchema, + type OrchestrationPipelineStage, + type OrchestrationStepOptions, +} from "./orchestration.js"; /** * Joins the current foreground session. @@ -80,7 +80,7 @@ export async function joinSession(config: JoinSessionConfig = {}): Promise => - connection.sendRequest("session.workflow.run", { sessionId, ...params }), + run: async (params: OrchestrationRunRequest): Promise => + connection.sendRequest("session.orchestration.run", { sessionId, ...params }), /** - * Runs a registered workflow as a child of an existing workflow run. + * Runs a registered orchestration as a child of an existing orchestration run. * - * @param params Parameters for invoking a nested workflow. + * @param params Parameters for invoking a nested orchestration. * - * @returns Complete current or terminal workflow run envelope. + * @returns Complete current or terminal orchestration run envelope. */ - runNested: async (params: WorkflowRunNestedRequest): Promise => - connection.sendRequest("session.workflow.runNested", { sessionId, ...params }), + runNested: async (params: OrchestrationRunNestedRequest): Promise => + connection.sendRequest("session.orchestration.runNested", { sessionId, ...params }), /** - * Gets the current or settled envelope for a workflow run. + * Gets the current or settled envelope for a orchestration run. * - * @param params Parameters for retrieving a workflow run. + * @param params Parameters for retrieving a orchestration run. * - * @returns Complete current or terminal workflow run envelope. + * @returns Complete current or terminal orchestration run envelope. */ - getRun: async (params: WorkflowGetRunRequest): Promise => - connection.sendRequest("session.workflow.getRun", { sessionId, ...params }), + getRun: async (params: OrchestrationGetRunRequest): Promise => + connection.sendRequest("session.orchestration.getRun", { sessionId, ...params }), /** - * Requests cancellation of a workflow run and returns its run envelope. + * Requests cancellation of a orchestration run and returns its run envelope. * - * @param params Parameters for cancelling a workflow run. + * @param params Parameters for cancelling a orchestration run. * - * @returns Complete current or terminal workflow run envelope. + * @returns Complete current or terminal orchestration run envelope. */ - cancel: async (params: WorkflowCancelRequest): Promise => - connection.sendRequest("session.workflow.cancel", { sessionId, ...params }), + cancel: async (params: OrchestrationCancelRequest): Promise => + connection.sendRequest("session.orchestration.cancel", { sessionId, ...params }), /** - * Records a batch of ordered workflow progress lines. + * Records a batch of ordered orchestration progress lines. * - * @param params Parameters for recording workflow progress. + * @param params Parameters for recording orchestration progress. * - * @returns Acknowledgement that a workflow request was accepted. + * @returns Acknowledgement that a orchestration request was accepted. */ - log: async (params: WorkflowLogRequest): Promise => - connection.sendRequest("session.workflow.log", { sessionId, ...params }), + log: async (params: OrchestrationLogRequest): Promise => + connection.sendRequest("session.orchestration.log", { sessionId, ...params }), /** - * Runs one workflow-scoped subagent and returns its result. + * Runs one orchestration-scoped subagent and returns its result. * - * @param params Parameters for one workflow-scoped subagent call. + * @param params Parameters for one orchestration-scoped subagent call. * - * @returns Result of one workflow-scoped subagent call. + * @returns Result of one orchestration-scoped subagent call. */ - agent: async (params: WorkflowAgentRequest): Promise => - connection.sendRequest("session.workflow.agent", { sessionId, ...params }), + agent: async (params: OrchestrationAgentRequest): Promise => + connection.sendRequest("session.orchestration.agent", { sessionId, ...params }), /** @experimental */ journal: { /** - * Reads a memoized workflow journal entry. + * Reads a memoized orchestration journal entry. * - * @param params Parameters for reading a workflow journal entry. + * @param params Parameters for reading a orchestration journal entry. * - * @returns Result of reading a workflow journal entry. + * @returns Result of reading a orchestration journal entry. */ - get: async (params: WorkflowJournalGetRequest): Promise => - connection.sendRequest("session.workflow.journal.get", { sessionId, ...params }), + get: async (params: OrchestrationJournalGetRequest): Promise => + connection.sendRequest("session.orchestration.journal.get", { sessionId, ...params }), /** - * Stores a memoized workflow journal entry. + * Stores a memoized orchestration journal entry. * - * @param params Parameters for storing a workflow journal entry. + * @param params Parameters for storing a orchestration journal entry. * - * @returns Acknowledgement that a workflow request was accepted. + * @returns Acknowledgement that a orchestration request was accepted. */ - put: async (params: WorkflowJournalPutRequest): Promise => - connection.sendRequest("session.workflow.journal.put", { sessionId, ...params }), + put: async (params: OrchestrationJournalPutRequest): Promise => + connection.sendRequest("session.orchestration.journal.put", { sessionId, ...params }), }, }, /** @experimental */ @@ -18609,25 +18609,25 @@ export interface ProviderTokenHandler { getToken(params: ProviderTokenAcquireRequest): Promise; } -/** Handler for `workflow` client session API methods. */ +/** Handler for `orchestration` client session API methods. */ /** @experimental */ -export interface WorkflowHandler { +export interface OrchestrationHandler { /** - * Asks the owning extension connection to execute a registered workflow closure. + * Asks the owning extension connection to execute a registered orchestration closure. * - * @param params Parameters sent to the owning extension to execute a workflow closure. + * @param params Parameters sent to the owning extension to execute a orchestration closure. * - * @returns Result returned by an extension workflow closure. + * @returns Result returned by an extension orchestration closure. */ - execute(params: WorkflowExecuteRequest): Promise; + execute(params: OrchestrationExecuteRequest): Promise; /** - * Asks the owning extension connection to abort a running workflow cooperatively. + * Asks the owning extension connection to abort a running orchestration cooperatively. * - * @param params Parameters for cooperatively aborting a workflow body. + * @param params Parameters for cooperatively aborting a orchestration body. * - * @returns Acknowledgement that a workflow request was accepted. + * @returns Acknowledgement that a orchestration request was accepted. */ - abort(params: WorkflowAbortRequest): Promise; + abort(params: OrchestrationAbortRequest): Promise; } /** Handler for `sessionFs` client session API methods. */ @@ -18761,7 +18761,7 @@ export interface CanvasHandler { /** All client session API handler groups. */ export interface ClientSessionApiHandlers { providerToken?: ProviderTokenHandler; - workflow?: WorkflowHandler; + orchestration?: OrchestrationHandler; sessionFs?: SessionFsHandler; canvas?: CanvasHandler; } @@ -18781,14 +18781,14 @@ export function registerClientSessionApiHandlers( if (!handler) throw new Error(`No providerToken handler registered for session: ${params.sessionId}`); return handler.getToken(params); }); - connection.onRequest("workflow.execute", async (params: WorkflowExecuteRequest) => { - const handler = getHandlers(params.sessionId).workflow; - if (!handler) throw new Error(`No workflow handler registered for session: ${params.sessionId}`); + connection.onRequest("orchestration.execute", async (params: OrchestrationExecuteRequest) => { + const handler = getHandlers(params.sessionId).orchestration; + if (!handler) throw new Error(`No orchestration handler registered for session: ${params.sessionId}`); return handler.execute(params); }); - connection.onRequest("workflow.abort", async (params: WorkflowAbortRequest) => { - const handler = getHandlers(params.sessionId).workflow; - if (!handler) throw new Error(`No workflow handler registered for session: ${params.sessionId}`); + connection.onRequest("orchestration.abort", async (params: OrchestrationAbortRequest) => { + const handler = getHandlers(params.sessionId).orchestration; + if (!handler) throw new Error(`No orchestration handler registered for session: ${params.sessionId}`); return handler.abort(params); }); connection.onRequest("sessionFs.readFile", async (params: SessionFsReadFileRequest) => { diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 2a4b5b06d6..1b7f41c677 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -12,7 +12,7 @@ export { CopilotClient } from "./client.js"; export { RuntimeConnection } from "./types.js"; export { BuiltInTools, ToolSet } from "./toolSet.js"; export { CopilotSession, type AssistantMessageEvent } from "./session.js"; -export { defineWorkflow, WorkflowRunError } from "./workflow.js"; +export { defineOrchestration, OrchestrationRunError } from "./orchestration.js"; export { Canvas, CanvasError, @@ -87,8 +87,8 @@ export type { LargeToolOutputConfig, MemoryConfiguration, UiInputOptions, - WorkflowLimits, - WorkflowMeta, + OrchestrationLimits, + OrchestrationMeta, MCPStdioServerConfig, MCPHTTPServerConfig, MCPServerConfig, @@ -163,12 +163,12 @@ export type { } from "./types.js"; export type { RunOptions, - SessionWorkflowApi, - WorkflowAgentOptions, - WorkflowContext, - WorkflowDefinition, - WorkflowHandle, - WorkflowJsonSchema, - WorkflowPipelineStage, - WorkflowStepOptions, -} from "./workflow.js"; + SessionOrchestrationApi, + OrchestrationAgentOptions, + OrchestrationContext, + OrchestrationDefinition, + OrchestrationHandle, + OrchestrationJsonSchema, + OrchestrationPipelineStage, + OrchestrationStepOptions, +} from "./orchestration.js"; diff --git a/nodejs/src/orchestration.ts b/nodejs/src/orchestration.ts new file mode 100644 index 0000000000..3522689d03 --- /dev/null +++ b/nodejs/src/orchestration.ts @@ -0,0 +1,236 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import type { OrchestrationRunResult } from "./generated/rpc.js"; +import type { CopilotSession } from "./session.js"; +import type { OrchestrationMeta } from "./types.js"; + +declare const orchestrationHandleBrand: unique symbol; + +/** + * Conservative JSON shape language accepted for structured orchestration agent output. + * + * Supports `type`, `required`, `enum`, `const`, recursive `properties`/`items`, + * and `anyOf`/`oneOf`/`allOf`. Other JSON Schema keywords are ignored. + * + * @experimental Part of the experimental Agent Orchestrations surface and may + * change or be removed in future SDK or CLI releases. + */ +export type OrchestrationJsonSchema = Record; + +/** + * Options for one orchestration-scoped subagent call. + * + * @experimental Part of the experimental Agent Orchestrations surface and may + * change or be removed in future SDK or CLI releases. + */ +export interface OrchestrationAgentOptions { + label?: string; + schema?: OrchestrationJsonSchema; + model?: string; +} + +/** + * Options for a durable orchestration step. + * + * @experimental Part of the experimental Agent Orchestrations surface and may + * change or be removed in future SDK or CLI releases. + */ +export interface OrchestrationStepOptions { + /** Skip the journal and always invoke the producer. */ + volatile?: boolean; +} + +/** + * One stage in a per-item orchestration pipeline. + * + * @experimental Part of the experimental Agent Orchestrations surface and may + * change or be removed in future SDK or CLI releases. + */ +export type OrchestrationPipelineStage = ( + previous: TInput, + item: unknown, + index: number +) => Promise | TResult; + +/** + * Context passed to an extension-authored orchestration body. + * + * @experimental Part of the experimental Agent Orchestrations surface and may + * change or be removed in future SDK or CLI releases. + */ +export interface OrchestrationContext { + /** Spawn and await one orchestration-scoped subagent. */ + agent(prompt: string, options?: OrchestrationAgentOptions): Promise; + /** Memoize an arbitrary producer under a stable author-supplied key. */ + step( + key: string, + producer: () => Promise | TResult, + options?: OrchestrationStepOptions + ): Promise; + /** Run thunks concurrently, returning null for a thunk that throws. */ + parallel( + thunks: Array<() => Promise | TResult> + ): Promise>; + /** Run each item through every stage without barriers between stages. */ + pipeline(items: unknown[], ...stages: OrchestrationPipelineStage[]): Promise; + /** Start a named orchestration progress phase. */ + phase(title: string): void; + /** Emit a orchestration progress line. */ + log(message: string): void; + /** Reject because nested orchestrations are not supported. */ + orchestration(name: string, args?: unknown): Promise; + /** Caller-supplied input, forwarded verbatim. */ + args: TArgs; + /** The same full session instance returned by `joinSession`. */ + session: CopilotSession; + /** Cooperative cancellation signal for the current orchestration run. */ + signal: AbortSignal; +} + +/** + * Definition accepted by {@link defineOrchestration}. + * + * @experimental Part of the experimental Agent Orchestrations surface and may + * change or be removed in future SDK or CLI releases. + */ +export interface OrchestrationDefinition { + meta: OrchestrationMeta; + run(context: OrchestrationContext): Promise; +} + +/** + * Opaque reusable reference to a defined orchestration. + * + * @experimental Part of the experimental Agent Orchestrations surface and may + * change or be removed in future SDK or CLI releases. + */ +export interface OrchestrationHandle { + readonly meta: OrchestrationMeta; + readonly [orchestrationHandleBrand]: { + readonly args: TArgs; + readonly result: TResult; + }; +} + +/** + * Options for invoking a orchestration. + * + * @experimental Part of the experimental Agent Orchestrations surface and may + * change or be removed in future SDK or CLI releases. + */ +export interface RunOptions { + /** Input surfaced as `context.args`. */ + args?: TArgs; + /** Return once the approved run starts instead of awaiting completion. */ + background?: boolean; + /** Prior run whose journal and progress should seed this run. */ + resumeFromRunId?: string; +} + +/** + * Friendly orchestration API exposed on a session. + * + * @experimental Part of the experimental Agent Orchestrations surface and may + * change or be removed in future SDK or CLI releases. + */ +export interface SessionOrchestrationApi { + run(name: string, options: RunOptions & { background: true }): Promise; + run( + name: string, + options?: RunOptions & { background?: false } + ): Promise; + run( + name: string, + options?: RunOptions + ): Promise; + run( + orchestration: OrchestrationHandle, + options: RunOptions & { background: true } + ): Promise; + run( + orchestration: OrchestrationHandle, + options?: RunOptions & { background?: false } + ): Promise; + run( + orchestration: OrchestrationHandle, + options?: RunOptions + ): Promise; + /** Read the latest durable envelope for a orchestration run. */ + getRun(runId: string): Promise; + /** Cancel a orchestration run and return its terminal envelope. */ + cancel(runId: string): Promise; +} + +/** + * Error thrown when a foreground orchestration run does not complete successfully. + * + * @experimental Part of the experimental Agent Orchestrations surface and may + * change or be removed in future SDK or CLI releases. + */ +export class OrchestrationRunError extends Error { + constructor(public readonly envelope: OrchestrationRunResult) { + super( + envelope.error ?? + envelope.reason ?? + `Orchestration run "${envelope.runId}" ended with status "${envelope.status}"` + ); + this.name = "OrchestrationRunError"; + } +} + +interface StoredOrchestration { + meta: OrchestrationMeta; + run(context: OrchestrationContext): Promise; +} + +const orchestrationHandles = new WeakMap(); + +function validateLimits(meta: OrchestrationMeta): void { + const limits = meta.limits; + if (!limits) { + return; + } + + for (const field of ["maxConcurrentSubagents", "maxTotalSubagents"] as const) { + const value = limits[field]; + if (value !== undefined && (!Number.isInteger(value) || value <= 0)) { + throw new Error(`Orchestration limit "${field}" must be a positive integer`); + } + } + + if (limits.timeout !== undefined && (!Number.isFinite(limits.timeout) || limits.timeout <= 0)) { + throw new Error('Orchestration limit "timeout" must be a positive number of milliseconds'); + } +} + +/** + * Defines an extension-authored orchestration and returns an opaque registration handle. + * + * @experimental Part of the experimental Agent Orchestrations surface and may + * change or be removed in future SDK or CLI releases. + */ +export function defineOrchestration( + definition: OrchestrationDefinition +): OrchestrationHandle { + validateLimits(definition.meta); + + const stored: StoredOrchestration = { + meta: definition.meta, + run: definition.run as StoredOrchestration["run"], + }; + const handle = Object.freeze({ meta: definition.meta }) as OrchestrationHandle; + + orchestrationHandles.set(handle, stored); + return handle; +} + +/** @internal */ +export function getOrchestrationDefinition(handle: OrchestrationHandle): StoredOrchestration { + const definition = orchestrationHandles.get(handle); + if (!definition) { + throw new Error("Invalid orchestration handle"); + } + return definition; +} diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index cf4ec410c0..11b7a67869 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -15,8 +15,8 @@ import type { CanvasActionInvokeResult, CurrentToolMetadata, McpOauthPendingRequestResponse, - WorkflowExecuteResult, - WorkflowLogLine, + OrchestrationExecuteResult, + OrchestrationLogLine, } from "./generated/rpc.js"; import { type Canvas, CanvasError } from "./canvas.js"; import type { OpenCanvasInstance } from "./generated/rpc.js"; @@ -63,14 +63,14 @@ import type { UserInputResponse, } from "./types.js"; import { - getWorkflowDefinition, - WorkflowRunError, + getOrchestrationDefinition, + OrchestrationRunError, type RunOptions, - type SessionWorkflowApi, - type WorkflowContext, - type WorkflowHandle, - type WorkflowStepOptions, -} from "./workflow.js"; + type SessionOrchestrationApi, + type OrchestrationContext, + type OrchestrationHandle, + type OrchestrationStepOptions, +} from "./orchestration.js"; /** * Convert a raw hook input received over the wire into its public-facing shape. @@ -105,18 +105,18 @@ function isOpenCanvasInstance(value: unknown): value is OpenCanvasInstance { ); } -const WORKFLOW_LOG_FLUSH_DELAY_MS = 10; -const MAX_WORKFLOW_FANOUT_ITEMS = 4096; +const ORCHESTRATION_LOG_FLUSH_DELAY_MS = 10; +const MAX_ORCHESTRATION_FANOUT_ITEMS = 4096; -function assertWorkflowFanoutSize(kind: "parallel" | "pipeline", size: number): void { - if (size > MAX_WORKFLOW_FANOUT_ITEMS) { +function assertOrchestrationFanoutSize(kind: "parallel" | "pipeline", size: number): void { + if (size > MAX_ORCHESTRATION_FANOUT_ITEMS) { throw new Error( - `${kind}() accepts at most ${MAX_WORKFLOW_FANOUT_ITEMS} items; got ${size}.` + `${kind}() accepts at most ${MAX_ORCHESTRATION_FANOUT_ITEMS} items; got ${size}.` ); } } -async function runWorkflowParallel( +async function runOrchestrationParallel( thunks: Array<() => Promise | TResult> ): Promise> { if (!Array.isArray(thunks)) { @@ -124,7 +124,7 @@ async function runWorkflowParallel( "parallel() expects an array of functions, not promises. Wrap each call: () => agent(...)" ); } - assertWorkflowFanoutSize("parallel", thunks.length); + assertOrchestrationFanoutSize("parallel", thunks.length); if (thunks.some((thunk) => typeof thunk !== "function")) { throw new Error( "parallel() expects an array of functions, not promises. Wrap each call: () => agent(...)" @@ -139,7 +139,7 @@ async function runWorkflowParallel( ); } -async function runWorkflowPipeline( +async function runOrchestrationPipeline( items: unknown[], ...stages: Array< (previous: unknown, item: unknown, index: number) => Promise | unknown @@ -148,7 +148,7 @@ async function runWorkflowPipeline( if (!Array.isArray(items)) { throw new Error("pipeline(items, ...stages): items must be an array"); } - assertWorkflowFanoutSize("pipeline", items.length); + assertOrchestrationFanoutSize("pipeline", items.length); return Promise.all( items.map(async (item, index) => { let previous = item; @@ -164,20 +164,20 @@ async function runWorkflowPipeline( ); } -class WorkflowProgressBuffer { +class OrchestrationProgressBuffer { private nextSeq = 0; - private pending: WorkflowLogLine[] = []; + private pending: OrchestrationLogLine[] = []; private flushTimer?: ReturnType; private flushTail: Promise = Promise.resolve(); private flushError: unknown; private flushFailed = false; private closed = false; - constructor(private readonly send: (lines: WorkflowLogLine[]) => Promise) {} + constructor(private readonly send: (lines: OrchestrationLogLine[]) => Promise) {} - enqueue(kind: WorkflowLogLine["kind"], text: string): void { + enqueue(kind: OrchestrationLogLine["kind"], text: string): void { if (this.closed) { - throw new Error("Cannot log after the workflow run has settled"); + throw new Error("Cannot log after the orchestration run has settled"); } this.pending.push({ seq: this.nextSeq++, kind, text }); @@ -217,7 +217,7 @@ class WorkflowProgressBuffer { this.flushTimer = setTimeout(() => { this.flushTimer = undefined; void this.flush().catch(() => {}); - }, WORKFLOW_LOG_FLUSH_DELAY_MS); + }, ORCHESTRATION_LOG_FLUSH_DELAY_MS); this.flushTimer.unref?.(); } @@ -229,14 +229,16 @@ class WorkflowProgressBuffer { } } -async function awaitWorkflowOperation( +async function awaitOrchestrationOperation( operation: Promise, signal: AbortSignal ): Promise { - throwIfWorkflowAborted(signal); + throwIfOrchestrationAborted(signal); let rejectAbort: ((reason?: unknown) => void) | undefined; const onAbort = () => - rejectAbort?.(signal.reason ?? new DOMException("Workflow run was aborted", "AbortError")); + rejectAbort?.( + signal.reason ?? new DOMException("Orchestration run was aborted", "AbortError") + ); try { return await Promise.race([ operation, @@ -250,9 +252,9 @@ async function awaitWorkflowOperation( } } -function throwIfWorkflowAborted(signal: AbortSignal): void { +function throwIfOrchestrationAborted(signal: AbortSignal): void { if (signal.aborted) { - throw signal.reason ?? new DOMException("Workflow run was aborted", "AbortError"); + throw signal.reason ?? new DOMException("Orchestration run was aborted", "AbortError"); } } @@ -300,8 +302,8 @@ export class CopilotSession { private canvases: Map = new Map(); private bearerTokenProviders: Map = new Map(); private commandHandlers: Map = new Map(); - private workflows = new Map>(); - private workflowAbortControllers = new Map(); + private orchestrations = new Map>(); + private orchestrationAbortControllers = new Map(); private permissionHandler?: PermissionHandler; private mcpAuthHandler?: McpAuthHandler; private userInputHandler?: UserInputHandler; @@ -320,24 +322,24 @@ export class CopilotSession { clientSessionApis: ClientSessionApiHandlers = {}; /** - * Friendly workflow API for running registered workflows by name or handle. + * Friendly orchestration API for running registered orchestrations by name or handle. * - * @experimental Part of the experimental Dynamic Workflows surface and may + * @experimental Part of the experimental Agent Orchestrations surface and may * change or be removed in future SDK or CLI releases. */ - readonly workflow: SessionWorkflowApi = { + readonly orchestration: SessionOrchestrationApi = { run: (async ( - nameOrHandle: string | WorkflowHandle, + nameOrHandle: string | OrchestrationHandle, options?: RunOptions ): Promise => { const name = typeof nameOrHandle === "string" ? nameOrHandle - : getWorkflowDefinition(nameOrHandle).meta.name; - const envelope = await this.rpc.workflow.run({ + : getOrchestrationDefinition(nameOrHandle).meta.name; + const envelope = await this.rpc.orchestration.run({ name, args: (options?.args === undefined ? {} : options.args) as Parameters< - typeof this.rpc.workflow.run + typeof this.rpc.orchestration.run >[0]["args"], options: { background: options?.background, @@ -349,12 +351,12 @@ export class CopilotSession { return envelope; } if (envelope.status !== "completed") { - throw new WorkflowRunError(envelope); + throw new OrchestrationRunError(envelope); } return envelope.result; - }) as SessionWorkflowApi["run"], - getRun: (runId) => this.rpc.workflow.getRun({ runId }), - cancel: (runId) => this.rpc.workflow.cancel({ runId }), + }) as SessionOrchestrationApi["run"], + getRun: (runId) => this.rpc.orchestration.getRun({ runId }), + cancel: (runId) => this.rpc.orchestration.cancel({ runId }), }; /** @@ -560,11 +562,11 @@ export class CopilotSession { this.autoModeSwitchHandler = undefined; this.commandHandlers.clear(); this.canvases.clear(); - this.workflows.clear(); - for (const controller of this.workflowAbortControllers.values()) { + this.orchestrations.clear(); + for (const controller of this.orchestrationAbortControllers.values()) { controller.abort(); } - this.workflowAbortControllers.clear(); + this.orchestrationAbortControllers.clear(); this.transformCallbacks?.clear(); } @@ -1080,58 +1082,58 @@ export class CopilotSession { } /** - * Registers workflow closures and reverse-RPC handlers for this session. + * Registers orchestration closures and reverse-RPC handlers for this session. * - * @param workflows - Workflow handles declared by the joining extension. + * @param orchestrations - Orchestration handles declared by the joining extension. * @internal Called by the SDK when an extension joins a session. */ - registerWorkflows(workflows?: WorkflowHandle[]): void { - this.workflows.clear(); - if (!workflows || workflows.length === 0) { - delete this.clientSessionApis.workflow; + registerOrchestrations(orchestrations?: OrchestrationHandle[]): void { + this.orchestrations.clear(); + if (!orchestrations || orchestrations.length === 0) { + delete this.clientSessionApis.orchestration; return; } - for (const handle of workflows) { - const definition = getWorkflowDefinition(handle); - this.workflows.set(definition.meta.name, definition); + for (const handle of orchestrations) { + const definition = getOrchestrationDefinition(handle); + this.orchestrations.set(definition.meta.name, definition); } const self = this; - this.clientSessionApis.workflow = { + this.clientSessionApis.orchestration = { async execute(params) { - const definition = self.workflows.get(params.name); + const definition = self.orchestrations.get(params.name); if (!definition) { - const message = `No workflow registered with name "${params.name}"`; + const message = `No orchestration registered with name "${params.name}"`; throw new ResponseError(ErrorCodes.InvalidParams, message, { - code: "workflow_not_found", + code: "orchestration_not_found", name: params.name, }); } const controller = new AbortController(); - self.workflowAbortControllers.set(params.runId, controller); - const progress = new WorkflowProgressBuffer(async (lines) => { - await self.rpc.workflow.log({ runId: params.runId, lines }); + self.orchestrationAbortControllers.set(params.runId, controller); + const progress = new OrchestrationProgressBuffer(async (lines) => { + await self.rpc.orchestration.log({ runId: params.runId, lines }); }); try { - const context: WorkflowContext = { + const context: OrchestrationContext = { args: params.args, session: self, signal: controller.signal, phase: (title: string) => { - throwIfWorkflowAborted(controller.signal); + throwIfOrchestrationAborted(controller.signal); progress.enqueue("phase", title); }, log: (message: string) => { - throwIfWorkflowAborted(controller.signal); + throwIfOrchestrationAborted(controller.signal); progress.enqueue("log", message); }, agent: async (prompt, options = {}) => { await progress.flush(); - const response = await awaitWorkflowOperation( - self.rpc.workflow.agent({ - workflowRunId: params.runId, + const response = await awaitOrchestrationOperation( + self.rpc.orchestration.agent({ + orchestrationRunId: params.runId, prompt, opts: { label: options.label, @@ -1146,14 +1148,14 @@ export class CopilotSession { step: async ( key: string, producer: () => Promise | TResult, - options: WorkflowStepOptions = {} + options: OrchestrationStepOptions = {} ): Promise => { await progress.flush(); if (options.volatile) { return producer(); } - const cached = await awaitWorkflowOperation( - self.rpc.workflow.journal.get({ + const cached = await awaitOrchestrationOperation( + self.rpc.orchestration.journal.get({ runId: params.runId, key, }), @@ -1172,40 +1174,40 @@ export class CopilotSession { `step("${key}") returned a value that is not JSON-serializable` ); } - await awaitWorkflowOperation( - self.rpc.workflow.journal.put({ + await awaitOrchestrationOperation( + self.rpc.orchestration.journal.put({ runId: params.runId, key, resultJson: result as Parameters< - typeof self.rpc.workflow.journal.put + typeof self.rpc.orchestration.journal.put >[0]["resultJson"], }), controller.signal ); return result; }, - parallel: runWorkflowParallel, - pipeline: runWorkflowPipeline, - workflow: async () => { - throw new Error("nested workflows are not supported"); + parallel: runOrchestrationParallel, + pipeline: runOrchestrationPipeline, + orchestration: async () => { + throw new Error("nested orchestrations are not supported"); }, }; const result = await definition.run(context); - return { result } as WorkflowExecuteResult; + return { result } as OrchestrationExecuteResult; } finally { try { await progress.close(); } finally { - if (self.workflowAbortControllers.get(params.runId) === controller) { - self.workflowAbortControllers.delete(params.runId); + if (self.orchestrationAbortControllers.get(params.runId) === controller) { + self.orchestrationAbortControllers.delete(params.runId); } } } }, async abort(params) { - self.workflowAbortControllers + self.orchestrationAbortControllers .get(params.runId) - ?.abort(new DOMException("Workflow run was aborted", "AbortError")); + ?.abort(new DOMException("Orchestration run was aborted", "AbortError")); return {}; }, }; diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 1a3dc109ce..041a776199 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1837,35 +1837,35 @@ export interface CanvasProviderIdentity { } /** - * Static resource ceilings declared by a workflow before it runs. + * Static resource ceilings declared by a orchestration before it runs. * - * @experimental Part of the experimental Dynamic Workflows surface and may + * @experimental Part of the experimental Agent Orchestrations surface and may * change or be removed in future SDK or CLI releases. */ -export interface WorkflowLimits { - /** Maximum number of workflow subagents that may run concurrently. Must be positive when present. */ +export interface OrchestrationLimits { + /** Maximum number of orchestration subagents that may run concurrently. Must be positive when present. */ maxConcurrentSubagents?: number; - /** Maximum total number of workflow subagents that may be spawned. Must be positive when present. */ + /** Maximum total number of orchestration subagents that may be spawned. Must be positive when present. */ maxTotalSubagents?: number; - /** Wall-clock timeout for the workflow run, in milliseconds. Must be positive when present. */ + /** Wall-clock timeout for the orchestration run, in milliseconds. Must be positive when present. */ timeout?: number; } /** - * Registration metadata for an extension-authored workflow. + * Registration metadata for an extension-authored orchestration. * - * @experimental Part of the experimental Dynamic Workflows surface and may + * @experimental Part of the experimental Agent Orchestrations surface and may * change or be removed in future SDK or CLI releases. */ -export interface WorkflowMeta { - /** Stable workflow name used for invocation. */ +export interface OrchestrationMeta { + /** Stable orchestration name used for invocation. */ name: string; - /** Human-readable workflow description. */ + /** Human-readable orchestration description. */ description: string; - /** Display metadata for the progress phases the workflow may report. */ + /** Display metadata for the progress phases the orchestration may report. */ phases: Array<{ title: string; detail?: string }>; /** Optional resource ceilings presented to the user before execution. */ - limits?: WorkflowLimits; + limits?: OrchestrationLimits; } /** diff --git a/nodejs/src/workflow.ts b/nodejs/src/workflow.ts deleted file mode 100644 index 4d701102ce..0000000000 --- a/nodejs/src/workflow.ts +++ /dev/null @@ -1,236 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - *--------------------------------------------------------------------------------------------*/ - -import type { WorkflowRunResult } from "./generated/rpc.js"; -import type { CopilotSession } from "./session.js"; -import type { WorkflowMeta } from "./types.js"; - -declare const workflowHandleBrand: unique symbol; - -/** - * Conservative JSON shape language accepted for structured workflow agent output. - * - * Supports `type`, `required`, `enum`, `const`, recursive `properties`/`items`, - * and `anyOf`/`oneOf`/`allOf`. Other JSON Schema keywords are ignored. - * - * @experimental Part of the experimental Dynamic Workflows surface and may - * change or be removed in future SDK or CLI releases. - */ -export type WorkflowJsonSchema = Record; - -/** - * Options for one workflow-scoped subagent call. - * - * @experimental Part of the experimental Dynamic Workflows surface and may - * change or be removed in future SDK or CLI releases. - */ -export interface WorkflowAgentOptions { - label?: string; - schema?: WorkflowJsonSchema; - model?: string; -} - -/** - * Options for a durable workflow step. - * - * @experimental Part of the experimental Dynamic Workflows surface and may - * change or be removed in future SDK or CLI releases. - */ -export interface WorkflowStepOptions { - /** Skip the journal and always invoke the producer. */ - volatile?: boolean; -} - -/** - * One stage in a per-item workflow pipeline. - * - * @experimental Part of the experimental Dynamic Workflows surface and may - * change or be removed in future SDK or CLI releases. - */ -export type WorkflowPipelineStage = ( - previous: TInput, - item: unknown, - index: number -) => Promise | TResult; - -/** - * Context passed to an extension-authored workflow body. - * - * @experimental Part of the experimental Dynamic Workflows surface and may - * change or be removed in future SDK or CLI releases. - */ -export interface WorkflowContext { - /** Spawn and await one workflow-scoped subagent. */ - agent(prompt: string, options?: WorkflowAgentOptions): Promise; - /** Memoize an arbitrary producer under a stable author-supplied key. */ - step( - key: string, - producer: () => Promise | TResult, - options?: WorkflowStepOptions - ): Promise; - /** Run thunks concurrently, returning null for a thunk that throws. */ - parallel( - thunks: Array<() => Promise | TResult> - ): Promise>; - /** Run each item through every stage without barriers between stages. */ - pipeline(items: unknown[], ...stages: WorkflowPipelineStage[]): Promise; - /** Start a named workflow progress phase. */ - phase(title: string): void; - /** Emit a workflow progress line. */ - log(message: string): void; - /** Reject because nested workflows are not supported. */ - workflow(name: string, args?: unknown): Promise; - /** Caller-supplied input, forwarded verbatim. */ - args: TArgs; - /** The same full session instance returned by `joinSession`. */ - session: CopilotSession; - /** Cooperative cancellation signal for the current workflow run. */ - signal: AbortSignal; -} - -/** - * Definition accepted by {@link defineWorkflow}. - * - * @experimental Part of the experimental Dynamic Workflows surface and may - * change or be removed in future SDK or CLI releases. - */ -export interface WorkflowDefinition { - meta: WorkflowMeta; - run(context: WorkflowContext): Promise; -} - -/** - * Opaque reusable reference to a defined workflow. - * - * @experimental Part of the experimental Dynamic Workflows surface and may - * change or be removed in future SDK or CLI releases. - */ -export interface WorkflowHandle { - readonly meta: WorkflowMeta; - readonly [workflowHandleBrand]: { - readonly args: TArgs; - readonly result: TResult; - }; -} - -/** - * Options for invoking a workflow. - * - * @experimental Part of the experimental Dynamic Workflows surface and may - * change or be removed in future SDK or CLI releases. - */ -export interface RunOptions { - /** Input surfaced as `context.args`. */ - args?: TArgs; - /** Return once the approved run starts instead of awaiting completion. */ - background?: boolean; - /** Prior run whose journal and progress should seed this run. */ - resumeFromRunId?: string; -} - -/** - * Friendly workflow API exposed on a session. - * - * @experimental Part of the experimental Dynamic Workflows surface and may - * change or be removed in future SDK or CLI releases. - */ -export interface SessionWorkflowApi { - run(name: string, options: RunOptions & { background: true }): Promise; - run( - name: string, - options?: RunOptions & { background?: false } - ): Promise; - run( - name: string, - options?: RunOptions - ): Promise; - run( - workflow: WorkflowHandle, - options: RunOptions & { background: true } - ): Promise; - run( - workflow: WorkflowHandle, - options?: RunOptions & { background?: false } - ): Promise; - run( - workflow: WorkflowHandle, - options?: RunOptions - ): Promise; - /** Read the latest durable envelope for a workflow run. */ - getRun(runId: string): Promise; - /** Cancel a workflow run and return its terminal envelope. */ - cancel(runId: string): Promise; -} - -/** - * Error thrown when a foreground workflow run does not complete successfully. - * - * @experimental Part of the experimental Dynamic Workflows surface and may - * change or be removed in future SDK or CLI releases. - */ -export class WorkflowRunError extends Error { - constructor(public readonly envelope: WorkflowRunResult) { - super( - envelope.error ?? - envelope.reason ?? - `Workflow run "${envelope.runId}" ended with status "${envelope.status}"` - ); - this.name = "WorkflowRunError"; - } -} - -interface StoredWorkflow { - meta: WorkflowMeta; - run(context: WorkflowContext): Promise; -} - -const workflowHandles = new WeakMap(); - -function validateLimits(meta: WorkflowMeta): void { - const limits = meta.limits; - if (!limits) { - return; - } - - for (const field of ["maxConcurrentSubagents", "maxTotalSubagents"] as const) { - const value = limits[field]; - if (value !== undefined && (!Number.isInteger(value) || value <= 0)) { - throw new Error(`Workflow limit "${field}" must be a positive integer`); - } - } - - if (limits.timeout !== undefined && (!Number.isFinite(limits.timeout) || limits.timeout <= 0)) { - throw new Error('Workflow limit "timeout" must be a positive number of milliseconds'); - } -} - -/** - * Defines an extension-authored workflow and returns an opaque registration handle. - * - * @experimental Part of the experimental Dynamic Workflows surface and may - * change or be removed in future SDK or CLI releases. - */ -export function defineWorkflow( - definition: WorkflowDefinition -): WorkflowHandle { - validateLimits(definition.meta); - - const stored: StoredWorkflow = { - meta: definition.meta, - run: definition.run as StoredWorkflow["run"], - }; - const handle = Object.freeze({ meta: definition.meta }) as WorkflowHandle; - - workflowHandles.set(handle, stored); - return handle; -} - -/** @internal */ -export function getWorkflowDefinition(handle: WorkflowHandle): StoredWorkflow { - const definition = workflowHandles.get(handle); - if (!definition) { - throw new Error("Invalid workflow handle"); - } - return definition; -} diff --git a/nodejs/test/workflow.test.ts b/nodejs/test/orchestration.test.ts similarity index 76% rename from nodejs/test/workflow.test.ts rename to nodejs/test/orchestration.test.ts index a602d2eef3..2415f59a17 100644 --- a/nodejs/test/workflow.test.ts +++ b/nodejs/test/orchestration.test.ts @@ -8,17 +8,17 @@ import { CopilotClient } from "../src/client.js"; import { joinSession } from "../src/extension.js"; import { CopilotSession } from "../src/session.js"; import { - defineWorkflow, - WorkflowRunError, - type WorkflowAgentOptions, - type WorkflowDefinition, -} from "../src/workflow.js"; + defineOrchestration, + OrchestrationRunError, + type OrchestrationAgentOptions, + type OrchestrationDefinition, +} from "../src/orchestration.js"; async function stopClient(client: CopilotClient): Promise { await client.stop(); } -describe("workflows", () => { +describe("orchestrations", () => { const originalSessionId = process.env.SESSION_ID; afterEach(() => { @@ -33,18 +33,18 @@ describe("workflows", () => { it("defines a stable handle and accepts omitted limits", async () => { const meta = { name: "no-limits", - description: "A workflow without resource limits", + description: "A orchestration without resource limits", phases: [], }; const run = vi.fn(async ({ args }: { args: unknown }) => args); - const handle = defineWorkflow({ meta, run }); + const handle = defineOrchestration({ meta, run }); expect(handle.meta).toBe(meta); expect(Object.isFrozen(handle)).toBe(true); const session = new CopilotSession("session-1", {} as never); - session.registerWorkflows([handle]); - const result = await session.clientSessionApis.workflow!.execute({ + session.registerOrchestrations([handle]); + const result = await session.clientSessionApis.orchestration!.execute({ sessionId: session.sessionId, name: meta.name, runId: "run-1", @@ -66,23 +66,23 @@ describe("workflows", () => { const definition = { meta: { name: `invalid-${field}-${String(value)}`, - description: "Invalid workflow", + description: "Invalid orchestration", phases: [], limits: { [field]: value }, }, run: async () => null, - } as WorkflowDefinition; + } as OrchestrationDefinition; - expect(() => defineWorkflow(definition)).toThrow(/must be a positive/); + expect(() => defineOrchestration(definition)).toThrow(/must be a positive/); }); - it("serializes only workflow metadata in the extension resume payload", async () => { + it("serializes only orchestration metadata in the extension resume payload", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => stopClient(client)); const run = vi.fn(async () => ({ ok: true })); - const workflow = defineWorkflow({ + const orchestration = defineOrchestration({ meta: { name: "registered", description: "Registration test", @@ -101,7 +101,7 @@ describe("workflows", () => { const sessions = (client as never as { sessions: Map }) .sessions; expect( - sessions.get(params.sessionId as string)?.clientSessionApis.workflow + sessions.get(params.sessionId as string)?.clientSessionApis.orchestration ).toBeDefined(); return { sessionId: params.sessionId }; } @@ -111,22 +111,22 @@ describe("workflows", () => { await client.resumeSessionForExtension( "session-registration", { onPermissionRequest: () => ({ kind: "approved" }) }, - [workflow] + [orchestration] ); const payload = sendRequest.mock.calls.find( ([method]) => method === "session.resume" )![1] as { - workflows: unknown[]; + orchestrations: unknown[]; }; - expect(payload.workflows).toEqual([workflow.meta]); - expect(payload.workflows[0]).not.toHaveProperty("run"); - expect(JSON.stringify(payload.workflows)).not.toContain("async"); + expect(payload.orchestrations).toEqual([orchestration.meta]); + expect(payload.orchestrations[0]).not.toHaveProperty("run"); + expect(JSON.stringify(payload.orchestrations)).not.toContain("async"); }); - it("passes workflows only through the extension join path", async () => { + it("passes orchestrations only through the extension join path", async () => { process.env.SESSION_ID = "session-extension"; - const workflow = defineWorkflow({ + const orchestration = defineOrchestration({ meta: { name: "extension-only", description: "Extension-only registration", @@ -138,19 +138,19 @@ describe("workflows", () => { .spyOn(CopilotClient.prototype, "resumeSessionForExtension") .mockResolvedValue({} as CopilotSession); - await joinSession({ workflows: [workflow] }); + await joinSession({ orchestrations: [orchestration] }); expect(resumeSessionForExtension).toHaveBeenCalledWith( "session-extension", expect.objectContaining({ suppressResumeEvent: true }), - [workflow] + [orchestration] ); }); - it("builds the workflow context with the unrestricted joined session identity", async () => { + it("builds the orchestration context with the unrestricted joined session identity", async () => { process.env.SESSION_ID = "session-context"; const sendRequest = vi.fn(async (method: string) => { - if (method === "session.workflow.log") { + if (method === "session.orchestration.log") { return {}; } if (method === "session.tasks.list") { @@ -164,7 +164,7 @@ describe("workflows", () => { session: CopilotSession; signal: AbortSignal; }>(); - const workflow = defineWorkflow({ + const orchestration = defineOrchestration({ meta: { name: "context", description: "Context test", @@ -179,14 +179,14 @@ describe("workflows", () => { }, }); vi.spyOn(CopilotClient.prototype, "resumeSessionForExtension").mockImplementation( - async (_sessionId, _config, workflows) => { - joinedSession.registerWorkflows(workflows); + async (_sessionId, _config, orchestrations) => { + joinedSession.registerOrchestrations(orchestrations); return joinedSession; } ); - const joinSessionResult = await joinSession({ workflows: [workflow] }); - const executeResult = await joinSessionResult.clientSessionApis.workflow!.execute({ + const joinSessionResult = await joinSession({ orchestrations: [orchestration] }); + const executeResult = await joinSessionResult.clientSessionApis.orchestration!.execute({ sessionId: joinSessionResult.sessionId, name: "context", runId: "run-context", @@ -202,7 +202,7 @@ describe("workflows", () => { expect(sendRequest).toHaveBeenCalledWith("session.tasks.list", { sessionId: joinSessionResult.sessionId, }); - expect(sendRequest).toHaveBeenCalledWith("session.workflow.log", { + expect(sendRequest).toHaveBeenCalledWith("session.orchestration.log", { sessionId: joinSessionResult.sessionId, runId: "run-context", lines: [ @@ -212,37 +212,37 @@ describe("workflows", () => { }); }); - it("rejects nested workflows without forwarding a runNested request", async () => { + it("rejects nested orchestrations without forwarding a runNested request", async () => { const sendRequest = vi.fn(async () => { throw new Error("Unexpected forward request"); }); const session = new CopilotSession("session-no-nesting", { sendRequest } as never); - const workflow = defineWorkflow({ + const orchestration = defineOrchestration({ meta: { name: "no-nesting", - description: "Nested workflow rejection test", + description: "Nested orchestration rejection test", phases: [], }, - run: async (context) => context.workflow("nested", { value: 42 }), + run: async (context) => context.orchestration("nested", { value: 42 }), }); - session.registerWorkflows([workflow]); + session.registerOrchestrations([orchestration]); await expect( - session.clientSessionApis.workflow!.execute({ + session.clientSessionApis.orchestration!.execute({ sessionId: session.sessionId, name: "no-nesting", runId: "run-no-nesting", args: {}, }) - ).rejects.toThrow("nested workflows are not supported"); + ).rejects.toThrow("nested orchestrations are not supported"); expect(sendRequest).not.toHaveBeenCalled(); }); - it("flushes progress incrementally while a workflow body is awaiting", async () => { + it("flushes progress incrementally while a orchestration body is awaiting", async () => { const sendRequest = vi.fn(async () => ({})); const session = new CopilotSession("session-live-progress", { sendRequest } as never); const body = Promise.withResolvers(); - const workflow = defineWorkflow({ + const orchestration = defineOrchestration({ meta: { name: "live-progress", description: "Incremental progress test", @@ -254,16 +254,16 @@ describe("workflows", () => { return "done"; }, }); - session.registerWorkflows([workflow]); + session.registerOrchestrations([orchestration]); - const execution = session.clientSessionApis.workflow!.execute({ + const execution = session.clientSessionApis.orchestration!.execute({ sessionId: session.sessionId, name: "live-progress", runId: "run-live-progress", args: {}, }); await vi.waitFor(() => { - expect(sendRequest).toHaveBeenCalledWith("session.workflow.log", { + expect(sendRequest).toHaveBeenCalledWith("session.orchestration.log", { sessionId: session.sessionId, runId: "run-live-progress", lines: [{ seq: 0, kind: "log", text: "before await" }], @@ -274,15 +274,15 @@ describe("workflows", () => { await expect(execution).resolves.toEqual({ result: "done" }); }); - it("calls workflow.agent with the current run id and returns its text", async () => { + it("calls orchestration.agent with the current run id and returns its text", async () => { const sendRequest = vi.fn(async (method: string) => { - if (method === "session.workflow.agent") { + if (method === "session.orchestration.agent") { return { result: "pong" }; } throw new Error(`Unexpected method: ${method}`); }); const session = new CopilotSession("session-agent", { sendRequest } as never); - const workflow = defineWorkflow({ + const orchestration = defineOrchestration({ meta: { name: "agent", description: "Agent context test", @@ -294,21 +294,21 @@ describe("workflows", () => { model: "gpt-test", schema: { type: "string" }, effort: "high", - } as WorkflowAgentOptions), + } as OrchestrationAgentOptions), }); - session.registerWorkflows([workflow]); + session.registerOrchestrations([orchestration]); await expect( - session.clientSessionApis.workflow!.execute({ + session.clientSessionApis.orchestration!.execute({ sessionId: session.sessionId, name: "agent", runId: "run-agent", args: {}, }) ).resolves.toEqual({ result: "pong" }); - expect(sendRequest).toHaveBeenCalledWith("session.workflow.agent", { + expect(sendRequest).toHaveBeenCalledWith("session.orchestration.agent", { sessionId: session.sessionId, - workflowRunId: "run-agent", + orchestrationRunId: "run-agent", prompt: "Reply with pong", opts: { label: "Pong helper", @@ -322,12 +322,12 @@ describe("workflows", () => { const journal = new Map(); const sendRequest = vi.fn( async (method: string, params: { key?: string; resultJson?: unknown }) => { - if (method === "session.workflow.journal.get") { + if (method === "session.orchestration.journal.get") { return journal.has(params.key!) ? { hit: true, resultJson: journal.get(params.key!) } : { hit: false }; } - if (method === "session.workflow.journal.put") { + if (method === "session.orchestration.journal.put") { journal.set(params.key!, params.resultJson); return {}; } @@ -337,7 +337,7 @@ describe("workflows", () => { const session = new CopilotSession("session-step", { sendRequest } as never); let cachedProducerCalls = 0; let failingProducerCalls = 0; - const workflow = defineWorkflow({ + const orchestration = defineOrchestration({ meta: { name: "step", description: "Durable step context test", @@ -363,10 +363,10 @@ describe("workflows", () => { return { first, second, failed, retried }; }, }); - session.registerWorkflows([workflow]); + session.registerOrchestrations([orchestration]); await expect( - session.clientSessionApis.workflow!.execute({ + session.clientSessionApis.orchestration!.execute({ sessionId: session.sessionId, name: "step", runId: "run-step", @@ -378,29 +378,31 @@ describe("workflows", () => { expect(cachedProducerCalls).toBe(1); expect(failingProducerCalls).toBe(2); expect( - sendRequest.mock.calls.filter(([method]) => method === "session.workflow.journal.put") + sendRequest.mock.calls.filter( + ([method]) => method === "session.orchestration.journal.put" + ) ).toHaveLength(2); }); - it("exposes workflow getRun and forwards the run id", async () => { + it("exposes orchestration getRun and forwards the run id", async () => { const envelope = { runId: "run-read", status: "error", error: "failed" }; const sendRequest = vi.fn(async () => envelope); const session = new CopilotSession("session-read", { sendRequest } as never); - await expect(session.workflow.getRun("run-read")).resolves.toEqual(envelope); - expect(sendRequest).toHaveBeenCalledWith("session.workflow.getRun", { + await expect(session.orchestration.getRun("run-read")).resolves.toEqual(envelope); + expect(sendRequest).toHaveBeenCalledWith("session.orchestration.getRun", { sessionId: session.sessionId, runId: "run-read", }); }); - it("exposes workflow cancel and forwards the run id", async () => { + it("exposes orchestration cancel and forwards the run id", async () => { const envelope = { runId: "run-cancel", status: "cancelled", reason: "cancelled" }; const sendRequest = vi.fn(async () => envelope); const session = new CopilotSession("session-cancel", { sendRequest } as never); - await expect(session.workflow.cancel("run-cancel")).resolves.toEqual(envelope); - expect(sendRequest).toHaveBeenCalledWith("session.workflow.cancel", { + await expect(session.orchestration.cancel("run-cancel")).resolves.toEqual(envelope); + expect(sendRequest).toHaveBeenCalledWith("session.orchestration.cancel", { sessionId: session.sessionId, runId: "run-cancel", }); @@ -411,7 +413,7 @@ describe("workflows", () => { const second = Promise.withResolvers(); const started: string[] = []; const session = new CopilotSession("session-parallel", {} as never); - const workflow = defineWorkflow({ + const orchestration = defineOrchestration({ meta: { name: "parallel", description: "Parallel combinator test", @@ -433,11 +435,11 @@ describe("workflows", () => { }, ]), }); - session.registerWorkflows([workflow]); + session.registerOrchestrations([orchestration]); let settled = false; const execution = session.clientSessionApis - .workflow!.execute({ + .orchestration!.execute({ sessionId: session.sessionId, name: "parallel", runId: "run-parallel", @@ -458,7 +460,7 @@ describe("workflows", () => { it("rejects already-invoked promises passed to parallel with a clear diagnostic", async () => { const session = new CopilotSession("session-parallel-promises", {} as never); - const workflow = defineWorkflow({ + const orchestration = defineOrchestration({ meta: { name: "parallel-promises", description: "Parallel misuse diagnostic", @@ -469,10 +471,10 @@ describe("workflows", () => { () => Promise >), }); - session.registerWorkflows([workflow]); + session.registerOrchestrations([orchestration]); await expect( - session.clientSessionApis.workflow!.execute({ + session.clientSessionApis.orchestration!.execute({ sessionId: session.sessionId, name: "parallel-promises", runId: "run-parallel-promises", @@ -488,7 +490,7 @@ describe("workflows", () => { const secondStageStarted = Promise.withResolvers(); const finalStageItems: string[] = []; const session = new CopilotSession("session-pipeline", {} as never); - const workflow = defineWorkflow({ + const orchestration = defineOrchestration({ meta: { name: "pipeline", description: "Pipeline combinator test", @@ -515,9 +517,9 @@ describe("workflows", () => { } ), }); - session.registerWorkflows([workflow]); + session.registerOrchestrations([orchestration]); - const execution = session.clientSessionApis.workflow!.execute({ + const execution = session.clientSessionApis.orchestration!.execute({ sessionId: session.sessionId, name: "pipeline", runId: "run-pipeline", @@ -535,7 +537,7 @@ describe("workflows", () => { it("enforces the 4096-item cap for parallel and pipeline", async () => { const session = new CopilotSession("session-fanout-cap", {} as never); - const workflow = defineWorkflow({ + const orchestration = defineOrchestration({ meta: { name: "fanout-cap", description: "Fan-out cap test", @@ -553,10 +555,10 @@ describe("workflows", () => { }; }, }); - session.registerWorkflows([workflow]); + session.registerOrchestrations([orchestration]); await expect( - session.clientSessionApis.workflow!.execute({ + session.clientSessionApis.orchestration!.execute({ sessionId: session.sessionId, name: "fanout-cap", runId: "run-fanout-cap", @@ -576,7 +578,7 @@ describe("workflows", () => { let tail = Promise.resolve(); const sendRequest = vi.fn( async (method: string, params: { prompt: string }): Promise<{ result: string }> => { - if (method !== "session.workflow.agent") { + if (method !== "session.orchestration.agent") { throw new Error(`Unexpected method: ${method}`); } const previous = tail; @@ -594,7 +596,7 @@ describe("workflows", () => { const session = new CopilotSession("session-nested-combinators", { sendRequest, } as never); - const workflow = defineWorkflow({ + const orchestration = defineOrchestration({ meta: { name: "nested-combinators", description: "Nested combinator deadlock regression", @@ -606,10 +608,10 @@ describe("workflows", () => { () => pipeline(["c"], (_previous, item) => agent(item as string)), ]), }); - session.registerWorkflows([workflow]); + session.registerOrchestrations([orchestration]); await expect( - session.clientSessionApis.workflow!.execute({ + session.clientSessionApis.orchestration!.execute({ sessionId: session.sessionId, name: "nested-combinators", runId: "run-nested-combinators", @@ -620,10 +622,10 @@ describe("workflows", () => { expect(sendRequest).toHaveBeenCalledTimes(3); }); - it("flushes buffered progress in finally when the workflow body throws", async () => { + it("flushes buffered progress in finally when the orchestration body throws", async () => { const sendRequest = vi.fn(async () => ({})); const session = new CopilotSession("session-throw-progress", { sendRequest } as never); - const workflow = defineWorkflow({ + const orchestration = defineOrchestration({ meta: { name: "throw-progress", description: "Throwing progress test", @@ -634,27 +636,27 @@ describe("workflows", () => { throw new Error("body failed"); }, }); - session.registerWorkflows([workflow]); + session.registerOrchestrations([orchestration]); await expect( - session.clientSessionApis.workflow!.execute({ + session.clientSessionApis.orchestration!.execute({ sessionId: session.sessionId, name: "throw-progress", runId: "run-throw-progress", args: {}, }) ).rejects.toThrow("body failed"); - expect(sendRequest).toHaveBeenCalledWith("session.workflow.log", { + expect(sendRequest).toHaveBeenCalledWith("session.orchestration.log", { sessionId: session.sessionId, runId: "run-throw-progress", lines: [{ seq: 0, kind: "log", text: "before throw" }], }); }); - it("surfaces the per-run abort signal on the workflow context", async () => { + it("surfaces the per-run abort signal on the orchestration context", async () => { const session = new CopilotSession("session-abort-signal", {} as never); const signalSeen = Promise.withResolvers(); - const workflow = defineWorkflow({ + const orchestration = defineOrchestration({ meta: { name: "abort-signal", description: "Abort signal test", @@ -668,9 +670,9 @@ describe("workflows", () => { return signal.aborted; }, }); - session.registerWorkflows([workflow]); + session.registerOrchestrations([orchestration]); - const execution = session.clientSessionApis.workflow!.execute({ + const execution = session.clientSessionApis.orchestration!.execute({ sessionId: session.sessionId, name: "abort-signal", runId: "run-abort-signal", @@ -679,7 +681,7 @@ describe("workflows", () => { const signal = await signalSeen.promise; expect(signal.aborted).toBe(false); - await session.clientSessionApis.workflow!.abort({ + await session.clientSessionApis.orchestration!.abort({ sessionId: session.sessionId, runId: "run-abort-signal", }); @@ -688,34 +690,39 @@ describe("workflows", () => { await expect(execution).resolves.toEqual({ result: true }); }); - it("rejects an in-flight runtime-backed await when workflow.abort trips the signal", async () => { + it("rejects an in-flight runtime-backed await when orchestration.abort trips the signal", async () => { const agentResponse = Promise.withResolvers<{ result: string }>(); const sendRequest = vi.fn(async (method: string) => { - if (method === "session.workflow.agent") { + if (method === "session.orchestration.agent") { return agentResponse.promise; } return {}; }); const session = new CopilotSession("session-abort-await", { sendRequest } as never); - const workflow = defineWorkflow({ + const orchestration = defineOrchestration({ meta: { name: "abort-await", - description: "Abort an in-flight workflow await", + description: "Abort an in-flight orchestration await", phases: [], }, run: async ({ agent }) => agent("wait forever"), }); - session.registerWorkflows([workflow]); + session.registerOrchestrations([orchestration]); - const execution = session.clientSessionApis.workflow!.execute({ + const execution = session.clientSessionApis.orchestration!.execute({ sessionId: session.sessionId, name: "abort-await", runId: "run-abort-await", args: {}, }); - await vi.waitFor(() => expect(sendRequest).toHaveBeenCalledWith("session.workflow.agent", expect.anything())); + await vi.waitFor(() => + expect(sendRequest).toHaveBeenCalledWith( + "session.orchestration.agent", + expect.anything() + ) + ); - await session.clientSessionApis.workflow!.abort({ + await session.clientSessionApis.orchestration!.abort({ sessionId: session.sessionId, runId: "run-abort-await", }); @@ -724,24 +731,24 @@ describe("workflows", () => { agentResponse.resolve({ result: "late" }); }); - it("dispatches workflow.execute to the registered workflow selected by name", async () => { + it("dispatches orchestration.execute to the registered orchestration selected by name", async () => { const firstRun = vi.fn(async () => ({ selected: "first" })); const secondRun = vi.fn(async ({ args, log }) => { log("executing"); return { selected: "second", echoed: args }; }); - const firstWorkflow = defineWorkflow({ + const firstOrchestration = defineOrchestration({ meta: { name: "first", - description: "First workflow", + description: "First orchestration", phases: [], }, run: firstRun, }); - const secondWorkflow = defineWorkflow({ + const secondOrchestration = defineOrchestration({ meta: { name: "second", - description: "Second workflow", + description: "Second orchestration", phases: [], }, run: secondRun, @@ -749,10 +756,10 @@ describe("workflows", () => { const session = new CopilotSession("session-execute", { sendRequest: vi.fn(async () => ({})), } as never); - session.registerWorkflows([firstWorkflow, secondWorkflow]); + session.registerOrchestrations([firstOrchestration, secondOrchestration]); await expect( - session.clientSessionApis.workflow!.execute({ + session.clientSessionApis.orchestration!.execute({ sessionId: session.sessionId, name: "second", runId: "run-echo", @@ -765,7 +772,7 @@ describe("workflows", () => { expect(secondRun).toHaveBeenCalledOnce(); const error = await session.clientSessionApis - .workflow!.execute({ + .orchestration!.execute({ sessionId: session.sessionId, name: "missing", runId: "run-missing", @@ -774,13 +781,13 @@ describe("workflows", () => { .catch((caught: unknown) => caught); expect(error).toBeInstanceOf(ResponseError); expect((error as ResponseError<{ code: string; name: string }>).data).toEqual({ - code: "workflow_not_found", + code: "orchestration_not_found", name: "missing", }); }); - it("runs workflows by name or handle and unwraps only foreground results", async () => { - const workflow = defineWorkflow({ + it("runs orchestrations by name or handle and unwraps only foreground results", async () => { + const orchestration = defineOrchestration({ meta: { name: "friendly-run", description: "Friendly run wrapper", @@ -803,22 +810,28 @@ describe("workflows", () => { ); const session = new CopilotSession("session-run", { sendRequest } as never); - await expect(session.workflow.run("by-name", { args: { value: 1 } })).resolves.toEqual({ - name: "by-name", + await expect(session.orchestration.run("by-name", { args: { value: 1 } })).resolves.toEqual( + { + name: "by-name", + } + ); + await expect(session.orchestration.run(orchestration)).resolves.toEqual({ + name: "friendly-run", }); - await expect(session.workflow.run(workflow)).resolves.toEqual({ name: "friendly-run" }); - await expect(session.workflow.run("background", { background: true })).resolves.toEqual({ + await expect( + session.orchestration.run("background", { background: true }) + ).resolves.toEqual({ runId: "run-background", status: "running", }); - expect(sendRequest).toHaveBeenNthCalledWith(1, "session.workflow.run", { + expect(sendRequest).toHaveBeenNthCalledWith(1, "session.orchestration.run", { sessionId: session.sessionId, name: "by-name", args: { value: 1 }, options: { background: undefined, resumeFromRunId: undefined }, }); - expect(sendRequest).toHaveBeenNthCalledWith(2, "session.workflow.run", { + expect(sendRequest).toHaveBeenNthCalledWith(2, "session.orchestration.run", { sessionId: session.sessionId, name: "friendly-run", args: {}, @@ -826,19 +839,19 @@ describe("workflows", () => { }); }); - it("throws WorkflowRunError with the full foreground envelope", async () => { + it("throws OrchestrationRunError with the full foreground envelope", async () => { const envelope = { runId: "run-error", status: "error" as const, - error: "workflow failed", + error: "orchestration failed", snapshot: { completed: 1 }, }; const session = new CopilotSession("session-error", { sendRequest: vi.fn(async () => envelope), } as never); - const error = await session.workflow.run("failing").catch((caught: unknown) => caught); - expect(error).toBeInstanceOf(WorkflowRunError); - expect((error as WorkflowRunError).envelope).toBe(envelope); + const error = await session.orchestration.run("failing").catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(OrchestrationRunError); + expect((error as OrchestrationRunError).envelope).toBe(envelope); }); }); From 41e181e5ad0d895385d4dc32658dec7a8a119baf Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Thu, 16 Jul 2026 18:20:12 -0700 Subject: [PATCH 14/22] CCR 1 Address Copilot review feedback on the agent orchestrations PR (SDK side): - Document the restricted structured-output schema subset at the contract boundary on OrchestrationJsonSchema: only a conservative keyword set is honored (type/required/enum/const/properties/items/anyOf/oneOf/allOf); unsupported constraints (pattern, minLength, format, additionalProperties, numeric ranges, boolean schemas) are ignored, and oneOf is treated like anyOf. It is a best-effort accept-or-retry guard, not full JSON Schema. - Regenerate rpc.ts from the updated runtime contract, dropping the removed orchestration.runNested method and OrchestrationRunNestedRequest (nesting is forbidden; the context-level rejection is covered by orchestration.test.ts). --- nodejs/src/generated/rpc.ts | 36 ++---------------------------------- nodejs/src/orchestration.ts | 14 ++++++++++++-- 2 files changed, 14 insertions(+), 36 deletions(-) diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 89f954e24f..1d74c3a296 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -8416,29 +8416,6 @@ export interface OrchestrationLogRequest { */ lines: OrchestrationLogLine[]; } -/** - * Parameters for invoking a nested orchestration. - * - * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "OrchestrationRunNestedRequest". - */ -/** @experimental */ -export interface OrchestrationRunNestedRequest { - /** - * Parent orchestration run identifier. - */ - parentRunId: string; - /** - * Registered child orchestration name. - */ - name: string; - /** - * Child orchestration input value. - */ - args: { - [k: string]: unknown | undefined; - }; -} /** * Parameters for invoking a registered orchestration. * @@ -10728,7 +10705,7 @@ export interface PushAttachmentGitHubActionsJob { type: "github_actions_job"; repo: PushGitHubRepoRef; /** - * Job id within the orchestration run + * Job id within the workflow run */ jobId: number; /** @@ -10736,7 +10713,7 @@ export interface PushAttachmentGitHubActionsJob { */ jobName: string; /** - * Display name of the orchestration the job ran in + * Display name of the workflow the job ran in */ workflowName: string; /** @@ -17054,15 +17031,6 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin */ run: async (params: OrchestrationRunRequest): Promise => connection.sendRequest("session.orchestration.run", { sessionId, ...params }), - /** - * Runs a registered orchestration as a child of an existing orchestration run. - * - * @param params Parameters for invoking a nested orchestration. - * - * @returns Complete current or terminal orchestration run envelope. - */ - runNested: async (params: OrchestrationRunNestedRequest): Promise => - connection.sendRequest("session.orchestration.runNested", { sessionId, ...params }), /** * Gets the current or settled envelope for a orchestration run. * diff --git a/nodejs/src/orchestration.ts b/nodejs/src/orchestration.ts index 3522689d03..fe672e1715 100644 --- a/nodejs/src/orchestration.ts +++ b/nodejs/src/orchestration.ts @@ -11,8 +11,18 @@ declare const orchestrationHandleBrand: unique symbol; /** * Conservative JSON shape language accepted for structured orchestration agent output. * - * Supports `type`, `required`, `enum`, `const`, recursive `properties`/`items`, - * and `anyOf`/`oneOf`/`allOf`. Other JSON Schema keywords are ignored. + * This is a best-effort structural guard used to decide whether a subagent's + * structured output should be accepted or retried — **not** a full JSON Schema + * validator. Only these keywords are honored: `type`, `required`, `enum`, + * `const`, recursive `properties`/`items`, and `anyOf`/`oneOf`/`allOf`. + * + * Everything else is **ignored, not enforced**. In particular, string + * constraints (`pattern`, `minLength`, `maxLength`, `format`), numeric ranges + * (`minimum`, `maximum`), `additionalProperties`, and boolean (`true`/`false`) + * schemas do not reject non-conforming output. `oneOf` is treated like `anyOf` + * (at least one branch must match) rather than strict exactly-one. Author + * schemas within this subset; do not rely on unsupported constraints for + * correctness. * * @experimental Part of the experimental Agent Orchestrations surface and may * change or be removed in future SDK or CLI releases. From 59af1ba39067e479299f1b4c58784c0ae994aefe Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Thu, 16 Jul 2026 19:09:23 -0700 Subject: [PATCH 15/22] Rename Agent Orchestrations to Agent Factories Renames the authoring surface from "orchestration" to "factory" per the updated product name "agent factories": - defineOrchestration -> defineFactory; OrchestrationContext/Definition/Handle/ Meta/Limits -> Factory*; SessionOrchestrationApi -> SessionFactoryApi (session.factory); OrchestrationRunError -> FactoryRunError. - joinSession({ orchestrations }) -> joinSession({ factories }). - orchestration.ts -> factory.ts (and its test); generated/rpc.ts regenerated from the renamed runtime contract (orchestration.* -> factory.*). The context primitives (step, agent, parallel, pipeline, phase, log, run) keep their names. Companion runtime rename lands in github/copilot-agent-runtime#12953. --- nodejs/src/client.ts | 12 +- nodejs/src/extension.ts | 36 +- nodejs/src/factory.ts | 246 ++++ nodejs/src/generated/rpc.ts | 1012 ++++++++--------- nodejs/src/index.ts | 24 +- nodejs/src/orchestration.ts | 246 ---- nodejs/src/session.ts | 164 +-- nodejs/src/types.ts | 26 +- ...{orchestration.test.ts => factory.test.ts} | 240 ++-- 9 files changed, 1003 insertions(+), 1003 deletions(-) create mode 100644 nodejs/src/factory.ts delete mode 100644 nodejs/src/orchestration.ts rename nodejs/test/{orchestration.test.ts => factory.test.ts} (77%) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 923674ca29..6d99ce49e3 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -85,7 +85,7 @@ import type { TypedSessionLifecycleHandler, } from "./types.js"; import { defaultJoinSessionPermissionHandler } from "./types.js"; -import type { OrchestrationHandle } from "./orchestration.js"; +import type { FactoryHandle } from "./factory.js"; /** * Minimum protocol version this SDK can communicate with. @@ -1671,15 +1671,15 @@ export class CopilotClient { async resumeSessionForExtension( sessionId: string, config: ResumeSessionConfig, - orchestrations?: OrchestrationHandle[] + factories?: FactoryHandle[] ): Promise { - return this.resumeSessionInternal(sessionId, config, orchestrations); + return this.resumeSessionInternal(sessionId, config, factories); } private async resumeSessionInternal( sessionId: string, config: ResumeSessionConfig, - orchestrations?: OrchestrationHandle[] + factories?: FactoryHandle[] ): Promise { if (!this.connection) { await this.start(); @@ -1697,7 +1697,7 @@ export class CopilotClient { session.registerTools(config.tools); session.registerCanvases(config.canvases); session.registerCommands(config.commands); - session.registerOrchestrations(orchestrations); + session.registerFactories(factories); const { wireProvider: bearerWireProvider, wireProviders: bearerWireProviders, @@ -1769,7 +1769,7 @@ export class CopilotClient { })), toolSearch: config.toolSearch, canvases: config.canvases?.map((canvas) => canvas.declaration), - orchestrations: orchestrations?.map((orchestration) => orchestration.meta), + factories: factories?.map((factory) => factory.meta), requestCanvasRenderer: config.requestCanvasRenderer, requestExtensions: config.requestExtensions, extensionSdkPath: config.extensionSdkPath, diff --git a/nodejs/src/extension.ts b/nodejs/src/extension.ts index 9747b06461..147aa9b0c9 100644 --- a/nodejs/src/extension.ts +++ b/nodejs/src/extension.ts @@ -9,7 +9,7 @@ import { type PermissionHandler, type ResumeSessionConfig, } from "./types.js"; -import type { OrchestrationHandle } from "./orchestration.js"; +import type { FactoryHandle } from "./factory.js"; export { Canvas, @@ -28,28 +28,28 @@ export type JoinSessionConfig = Omit< > & { onPermissionRequest?: PermissionHandler; /** - * Orchestration handles to register when the extension joins the session. + * Factory handles to register when the extension joins the session. * - * @experimental Part of the experimental Agent Orchestrations surface and may + * @experimental Part of the experimental Agent Factories surface and may * change or be removed in future SDK or CLI releases. */ - orchestrations?: OrchestrationHandle[]; + factories?: FactoryHandle[]; }; -export type { ExtensionInfo, OrchestrationLimits, OrchestrationMeta } from "./types.js"; +export type { ExtensionInfo, FactoryLimits, FactoryMeta } from "./types.js"; export { - defineOrchestration, - OrchestrationRunError, + defineFactory, + FactoryRunError, type RunOptions, - type SessionOrchestrationApi, - type OrchestrationAgentOptions, - type OrchestrationContext, - type OrchestrationDefinition, - type OrchestrationHandle, - type OrchestrationJsonSchema, - type OrchestrationPipelineStage, - type OrchestrationStepOptions, -} from "./orchestration.js"; + type SessionFactoryApi, + type FactoryAgentOptions, + type FactoryContext, + type FactoryDefinition, + type FactoryHandle, + type FactoryJsonSchema, + type FactoryPipelineStage, + type FactoryStepOptions, +} from "./factory.js"; /** * Joins the current foreground session. @@ -80,7 +80,7 @@ export async function joinSession(config: JoinSessionConfig = {}): Promise; + +/** + * Options for one factory-scoped subagent call. + * + * @experimental Part of the experimental Agent Factories surface and may + * change or be removed in future SDK or CLI releases. + */ +export interface FactoryAgentOptions { + label?: string; + schema?: FactoryJsonSchema; + model?: string; +} + +/** + * Options for a durable factory step. + * + * @experimental Part of the experimental Agent Factories surface and may + * change or be removed in future SDK or CLI releases. + */ +export interface FactoryStepOptions { + /** Skip the journal and always invoke the producer. */ + volatile?: boolean; +} + +/** + * One stage in a per-item factory pipeline. + * + * @experimental Part of the experimental Agent Factories surface and may + * change or be removed in future SDK or CLI releases. + */ +export type FactoryPipelineStage = ( + previous: TInput, + item: unknown, + index: number +) => Promise | TResult; + +/** + * Context passed to an extension-authored factory body. + * + * @experimental Part of the experimental Agent Factories surface and may + * change or be removed in future SDK or CLI releases. + */ +export interface FactoryContext { + /** Spawn and await one factory-scoped subagent. */ + agent(prompt: string, options?: FactoryAgentOptions): Promise; + /** Memoize an arbitrary producer under a stable author-supplied key. */ + step( + key: string, + producer: () => Promise | TResult, + options?: FactoryStepOptions + ): Promise; + /** Run thunks concurrently, returning null for a thunk that throws. */ + parallel( + thunks: Array<() => Promise | TResult> + ): Promise>; + /** Run each item through every stage without barriers between stages. */ + pipeline(items: unknown[], ...stages: FactoryPipelineStage[]): Promise; + /** Start a named factory progress phase. */ + phase(title: string): void; + /** Emit a factory progress line. */ + log(message: string): void; + /** Reject because nested factories are not supported. */ + factory(name: string, args?: unknown): Promise; + /** Caller-supplied input, forwarded verbatim. */ + args: TArgs; + /** The same full session instance returned by `joinSession`. */ + session: CopilotSession; + /** Cooperative cancellation signal for the current factory run. */ + signal: AbortSignal; +} + +/** + * Definition accepted by {@link defineFactory}. + * + * @experimental Part of the experimental Agent Factories surface and may + * change or be removed in future SDK or CLI releases. + */ +export interface FactoryDefinition { + meta: FactoryMeta; + run(context: FactoryContext): Promise; +} + +/** + * Opaque reusable reference to a defined factory. + * + * @experimental Part of the experimental Agent Factories surface and may + * change or be removed in future SDK or CLI releases. + */ +export interface FactoryHandle { + readonly meta: FactoryMeta; + readonly [factoryHandleBrand]: { + readonly args: TArgs; + readonly result: TResult; + }; +} + +/** + * Options for invoking a factory. + * + * @experimental Part of the experimental Agent Factories surface and may + * change or be removed in future SDK or CLI releases. + */ +export interface RunOptions { + /** Input surfaced as `context.args`. */ + args?: TArgs; + /** Return once the approved run starts instead of awaiting completion. */ + background?: boolean; + /** Prior run whose journal and progress should seed this run. */ + resumeFromRunId?: string; +} + +/** + * Friendly factory API exposed on a session. + * + * @experimental Part of the experimental Agent Factories surface and may + * change or be removed in future SDK or CLI releases. + */ +export interface SessionFactoryApi { + run(name: string, options: RunOptions & { background: true }): Promise; + run( + name: string, + options?: RunOptions & { background?: false } + ): Promise; + run( + name: string, + options?: RunOptions + ): Promise; + run( + factory: FactoryHandle, + options: RunOptions & { background: true } + ): Promise; + run( + factory: FactoryHandle, + options?: RunOptions & { background?: false } + ): Promise; + run( + factory: FactoryHandle, + options?: RunOptions + ): Promise; + /** Read the latest durable envelope for a factory run. */ + getRun(runId: string): Promise; + /** Cancel a factory run and return its terminal envelope. */ + cancel(runId: string): Promise; +} + +/** + * Error thrown when a foreground factory run does not complete successfully. + * + * @experimental Part of the experimental Agent Factories surface and may + * change or be removed in future SDK or CLI releases. + */ +export class FactoryRunError extends Error { + constructor(public readonly envelope: FactoryRunResult) { + super( + envelope.error ?? + envelope.reason ?? + `Factory run "${envelope.runId}" ended with status "${envelope.status}"` + ); + this.name = "FactoryRunError"; + } +} + +interface StoredFactory { + meta: FactoryMeta; + run(context: FactoryContext): Promise; +} + +const factoryHandles = new WeakMap(); + +function validateLimits(meta: FactoryMeta): void { + const limits = meta.limits; + if (!limits) { + return; + } + + for (const field of ["maxConcurrentSubagents", "maxTotalSubagents"] as const) { + const value = limits[field]; + if (value !== undefined && (!Number.isInteger(value) || value <= 0)) { + throw new Error(`Factory limit "${field}" must be a positive integer`); + } + } + + if (limits.timeout !== undefined && (!Number.isFinite(limits.timeout) || limits.timeout <= 0)) { + throw new Error('Factory limit "timeout" must be a positive number of milliseconds'); + } +} + +/** + * Defines an extension-authored factory and returns an opaque registration handle. + * + * @experimental Part of the experimental Agent Factories surface and may + * change or be removed in future SDK or CLI releases. + */ +export function defineFactory( + definition: FactoryDefinition +): FactoryHandle { + validateLimits(definition.meta); + + const stored: StoredFactory = { + meta: definition.meta, + run: definition.run as StoredFactory["run"], + }; + const handle = Object.freeze({ meta: definition.meta }) as FactoryHandle; + + factoryHandles.set(handle, stored); + return handle; +} + +/** @internal */ +export function getFactoryDefinition(handle: FactoryHandle): StoredFactory { + const definition = factoryHandles.get(handle); + if (!definition) { + throw new Error("Invalid factory handle"); + } + return definition; +} diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 1d74c3a296..c0702e5b5f 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -509,6 +509,38 @@ export type ExternalToolTextResultForLlmContentResourceLinkIconTheme = export type ExternalToolTextResultForLlmContentResourceDetails = | EmbeddedTextResourceContents | EmbeddedBlobResourceContents; +/** + * Kind of factory progress line. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "FactoryLogLineKind". + */ +/** @experimental */ +export type FactoryLogLineKind = + /** A narrator log line. */ + | "log" + /** A named factory phase marker. */ + | "phase"; +/** + * Current or terminal state of a factory run. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "FactoryRunStatus". + */ +/** @experimental */ +export type FactoryRunStatus = + /** The run was minted and is awaiting approval. */ + | "pending" + /** The run is executing. */ + | "running" + /** The run completed successfully. */ + | "completed" + /** The run stopped after reaching a resource ceiling. */ + | "halted" + /** The run was cancelled before completion. */ + | "cancelled" + /** The factory body failed. */ + | "error"; /** * Content filtering mode to apply to all tools, or a map of tool name to content filtering mode. * @@ -1220,38 +1252,6 @@ export type OptionsUpdateToolFilterPrecedence = | "available" /** A tool is enabled if and only if it matches the allowlist (or the allowlist is unset) AND it does not match the denylist. Makes 'all except X' expressible by combining the two lists. */ | "excluded"; -/** - * Kind of orchestration progress line. - * - * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "OrchestrationLogLineKind". - */ -/** @experimental */ -export type OrchestrationLogLineKind = - /** A narrator log line. */ - | "log" - /** A named orchestration phase marker. */ - | "phase"; -/** - * Current or terminal state of a orchestration run. - * - * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "OrchestrationRunStatus". - */ -/** @experimental */ -export type OrchestrationRunStatus = - /** The run was minted and is awaiting approval. */ - | "pending" - /** The run is executing. */ - | "running" - /** The run completed successfully. */ - | "completed" - /** The run stopped after reaching a resource ceiling. */ - | "halted" - /** The run was cancelled before completion. */ - | "cancelled" - /** The orchestration body failed. */ - | "error"; /** * The client's response to the pending permission prompt * @@ -4866,225 +4866,543 @@ export interface ExternalToolTextResultForLlmContentResource { resource: ExternalToolTextResultForLlmContentResourceDetails; } /** - * Optional user prompt to combine with the fleet orchestration instructions. + * Parameters for cooperatively aborting a factory body. * * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "FleetStartRequest". + * via the `definition` "FactoryAbortRequest". */ /** @experimental */ -export interface FleetStartRequest { +export interface FactoryAbortRequest { /** - * Optional user prompt to combine with fleet instructions + * Target session identifier */ - prompt?: string; + sessionId: string; + /** + * Factory run identifier. + */ + runId: string; } /** - * Indicates whether fleet mode was successfully activated. + * Acknowledgement that a factory request was accepted. * * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "FleetStartResult". + * via the `definition` "FactoryAckResult". */ /** @experimental */ -export interface FleetStartResult { +export interface FactoryAckResult {} +/** + * Options for one factory-scoped subagent call. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "FactoryAgentOptions". + */ +/** @experimental */ +export interface FactoryAgentOptions { /** - * Whether fleet mode was successfully activated + * Optional label distinguishing otherwise identical memoized agent calls. */ - started: boolean; + label?: string; + /** + * Optional JSON Schema for structured agent output. + */ + schema?: { + [k: string]: unknown | undefined; + }; + /** + * Optional model identifier for the subagent. + */ + model?: string; } /** - * Folder path to add to trusted folders. + * Parameters for one factory-scoped subagent call. * * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "FolderTrustAddParams". + * via the `definition` "FactoryAgentRequest". */ /** @experimental */ -export interface FolderTrustAddParams { +export interface FactoryAgentRequest { /** - * Folder path to mark as trusted + * Factory run identifier that owns the subagent. */ - path: string; + factoryRunId: string; + /** + * Prompt to send to the subagent. + */ + prompt: string; + opts: FactoryAgentOptions; } /** - * Folder path to check for trust. + * Result of one factory-scoped subagent call. * * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "FolderTrustCheckParams". + * via the `definition` "FactoryAgentResult". */ /** @experimental */ -export interface FolderTrustCheckParams { +export interface FactoryAgentResult { /** - * Folder path to check + * Agent result, omitted when the agent produced no result. */ - path: string; + result?: { + [k: string]: unknown | undefined; + }; } /** - * Folder trust check result. + * Parameters for cancelling a factory run. * * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "FolderTrustCheckResult". + * via the `definition` "FactoryCancelRequest". */ /** @experimental */ -export interface FolderTrustCheckResult { +export interface FactoryCancelRequest { /** - * Whether the folder is trusted + * Factory run identifier. */ - trusted: boolean; + runId: string; } /** - * Client environment metadata describing the process that produced a telemetry event. + * Parameters sent to the owning extension to execute a factory closure. * * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "GitHubTelemetryClientInfo". + * via the `definition` "FactoryExecuteRequest". */ /** @experimental */ -export interface GitHubTelemetryClientInfo { - /** - * Copilot CLI version string. - */ - cli_version: string; - /** - * Operating system platform (e.g. darwin, linux, win32). - */ - os_platform: string; - /** - * Operating system version string. - */ - os_version: string; +export interface FactoryExecuteRequest { /** - * Operating system architecture (e.g. arm64, x64). + * Target session identifier */ - os_arch: string; + sessionId: string; /** - * Node.js runtime version string. + * Registered factory name. */ - node_version: string; + name: string; /** - * Copilot subscription plan, when known. + * Factory run identifier. */ - copilot_plan?: string; + runId: string; /** - * Type of client. + * Factory input value. */ - client_type?: string; + args: { + [k: string]: unknown | undefined; + }; /** - * Name of the client application. + * Parent factory run identifier for nested execution. */ - client_name?: string; + parentRunId?: string; +} +/** + * Result returned by an extension factory closure. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "FactoryExecuteResult". + */ +/** @experimental */ +export interface FactoryExecuteResult { /** - * Whether the user is a GitHub/Microsoft staff member. + * Factory result value. */ - is_staff?: boolean; + result: { + [k: string]: unknown | undefined; + }; +} +/** + * Parameters for retrieving a factory run. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "FactoryGetRunRequest". + */ +/** @experimental */ +export interface FactoryGetRunRequest { /** - * Stable machine identifier for the device. + * Factory run identifier. */ - dev_device_id?: string; + runId: string; } /** - * A single telemetry event in the runtime's native GitHub-shaped telemetry format, forwarded verbatim to opted-in hosts. The `restricted` flag on the enclosing GitHubTelemetryNotification distinguishes standard from restricted events; the payload shape is identical for both. + * Parameters for reading a factory journal entry. * * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "GitHubTelemetryEvent". + * via the `definition` "FactoryJournalGetRequest". */ /** @experimental */ -export interface GitHubTelemetryEvent { +export interface FactoryJournalGetRequest { /** - * Event type/kind (e.g. get_completion_with_tools_turn, tool_call_executed). + * Factory run identifier. */ - kind: string; + runId: string; /** - * Timestamp when the event was created (ISO 8601 format). + * Namespaced journal key. */ - created_at?: string; + key: string; +} +/** + * Result of reading a factory journal entry. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "FactoryJournalGetResult". + */ +/** @experimental */ +export interface FactoryJournalGetResult { /** - * Reference to the model call that produced this event. + * Whether the journal contained the requested key. */ - model_call_id?: string; + hit: boolean; /** - * String-valued properties as a map from key to value. + * Cached JSON result. The hit field distinguishes a cached JSON null from a miss. */ - properties: { - [k: string]: string | undefined; + resultJson?: { + [k: string]: unknown | undefined; }; +} +/** + * Parameters for storing a factory journal entry. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "FactoryJournalPutRequest". + */ +/** @experimental */ +export interface FactoryJournalPutRequest { /** - * Numeric metrics as a map from key to value. + * Factory run identifier. */ - metrics: { - [k: string]: number | undefined; - }; + runId: string; /** - * Experiment assignment context. + * Namespaced journal key. */ - exp_assignment_context?: string; + key: string; /** - * Feature flags enabled for this session, as a map from flag to value. + * JSON result to memoize. */ - features?: { - [k: string]: string | undefined; + resultJson: { + [k: string]: unknown | undefined; }; +} +/** + * One ordered factory progress line. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "FactoryLogLine". + */ +/** @experimental */ +export interface FactoryLogLine { /** - * Session identifier the event belongs to. + * Monotonic sequence number within the factory run. */ - session_id?: string; + seq: number; + kind: FactoryLogLineKind; /** - * Copilot tracking ID for user-level attribution. + * Progress text. */ - copilot_tracking_id?: string; - client?: GitHubTelemetryClientInfo; + text: string; } /** - * Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry forwarding during the `server.connect` handshake. + * Parameters for recording factory progress. * * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "GitHubTelemetryNotification". + * via the `definition` "FactoryLogRequest". */ /** @experimental */ -export interface GitHubTelemetryNotification { +export interface FactoryLogRequest { /** - * Session the telemetry event belongs to, when it is session-scoped. Omitted for sessionless events (for example, `server.sendTelemetry` calls with no session id), which are still forwarded to opted-in connections. + * Factory run identifier. */ - sessionId?: string; + runId: string; /** - * Whether this is a restricted telemetry event (cli.restricted_telemetry). Hosts must route restricted events to first-party Microsoft stores only. + * Ordered progress lines to append. */ - restricted: boolean; - event: GitHubTelemetryEvent; + lines: FactoryLogLine[]; } /** - * Pending external tool call request ID, with the tool result or an error describing why it failed. + * Parameters for invoking a registered factory. * * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "HandlePendingToolCallRequest". + * via the `definition` "FactoryRunRequest". */ /** @experimental */ -export interface HandlePendingToolCallRequest { +export interface FactoryRunRequest { /** - * Request ID of the pending tool call + * Registered factory name. */ - requestId: string; - result?: ExternalToolResult; + name: string; /** - * Error message if the tool call failed + * Factory input value. */ - error?: string; + args: { + [k: string]: unknown | undefined; + }; + options?: RunOptions; } /** - * Indicates whether the external tool call result was handled successfully. + * Options controlling factory invocation. * * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "HandlePendingToolCallResult". + * via the `definition` "RunOptions". */ /** @experimental */ -export interface HandlePendingToolCallResult { +export interface RunOptions { /** - * Whether the tool call result was handled successfully + * Whether to return once the approved run starts instead of awaiting its terminal result. */ - success: boolean; + background?: boolean; + /** + * Run identifier whose journal and progress should seed this resumed run. + */ + resumeFromRunId?: string; } /** - * Indicates whether an in-progress manual compaction was aborted. + * Complete current or terminal factory run envelope. * * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "HistoryAbortManualCompactionResult". + * via the `definition` "FactoryRunResult". + */ +/** @experimental */ +export interface FactoryRunResult { + /** + * Factory run identifier. + */ + runId: string; + status: FactoryRunStatus; + /** + * Completed factory result. + */ + result?: { + [k: string]: unknown | undefined; + }; + /** + * Error message for an errored run. + */ + error?: string; + /** + * Reason for a halted or cancelled run. + */ + reason?: string; + /** + * Partial journal and progress snapshot for a halted or cancelled run. + */ + snapshot?: { + [k: string]: unknown | undefined; + }; +} +/** + * Optional user prompt to combine with the fleet orchestration instructions. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "FleetStartRequest". + */ +/** @experimental */ +export interface FleetStartRequest { + /** + * Optional user prompt to combine with fleet instructions + */ + prompt?: string; +} +/** + * Indicates whether fleet mode was successfully activated. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "FleetStartResult". + */ +/** @experimental */ +export interface FleetStartResult { + /** + * Whether fleet mode was successfully activated + */ + started: boolean; +} +/** + * Folder path to add to trusted folders. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "FolderTrustAddParams". + */ +/** @experimental */ +export interface FolderTrustAddParams { + /** + * Folder path to mark as trusted + */ + path: string; +} +/** + * Folder path to check for trust. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "FolderTrustCheckParams". + */ +/** @experimental */ +export interface FolderTrustCheckParams { + /** + * Folder path to check + */ + path: string; +} +/** + * Folder trust check result. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "FolderTrustCheckResult". + */ +/** @experimental */ +export interface FolderTrustCheckResult { + /** + * Whether the folder is trusted + */ + trusted: boolean; +} +/** + * Client environment metadata describing the process that produced a telemetry event. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "GitHubTelemetryClientInfo". + */ +/** @experimental */ +export interface GitHubTelemetryClientInfo { + /** + * Copilot CLI version string. + */ + cli_version: string; + /** + * Operating system platform (e.g. darwin, linux, win32). + */ + os_platform: string; + /** + * Operating system version string. + */ + os_version: string; + /** + * Operating system architecture (e.g. arm64, x64). + */ + os_arch: string; + /** + * Node.js runtime version string. + */ + node_version: string; + /** + * Copilot subscription plan, when known. + */ + copilot_plan?: string; + /** + * Type of client. + */ + client_type?: string; + /** + * Name of the client application. + */ + client_name?: string; + /** + * Whether the user is a GitHub/Microsoft staff member. + */ + is_staff?: boolean; + /** + * Stable machine identifier for the device. + */ + dev_device_id?: string; +} +/** + * A single telemetry event in the runtime's native GitHub-shaped telemetry format, forwarded verbatim to opted-in hosts. The `restricted` flag on the enclosing GitHubTelemetryNotification distinguishes standard from restricted events; the payload shape is identical for both. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "GitHubTelemetryEvent". + */ +/** @experimental */ +export interface GitHubTelemetryEvent { + /** + * Event type/kind (e.g. get_completion_with_tools_turn, tool_call_executed). + */ + kind: string; + /** + * Timestamp when the event was created (ISO 8601 format). + */ + created_at?: string; + /** + * Reference to the model call that produced this event. + */ + model_call_id?: string; + /** + * String-valued properties as a map from key to value. + */ + properties: { + [k: string]: string | undefined; + }; + /** + * Numeric metrics as a map from key to value. + */ + metrics: { + [k: string]: number | undefined; + }; + /** + * Experiment assignment context. + */ + exp_assignment_context?: string; + /** + * Feature flags enabled for this session, as a map from flag to value. + */ + features?: { + [k: string]: string | undefined; + }; + /** + * Session identifier the event belongs to. + */ + session_id?: string; + /** + * Copilot tracking ID for user-level attribution. + */ + copilot_tracking_id?: string; + client?: GitHubTelemetryClientInfo; +} +/** + * Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry forwarding during the `server.connect` handshake. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "GitHubTelemetryNotification". + */ +/** @experimental */ +export interface GitHubTelemetryNotification { + /** + * Session the telemetry event belongs to, when it is session-scoped. Omitted for sessionless events (for example, `server.sendTelemetry` calls with no session id), which are still forwarded to opted-in connections. + */ + sessionId?: string; + /** + * Whether this is a restricted telemetry event (cli.restricted_telemetry). Hosts must route restricted events to first-party Microsoft stores only. + */ + restricted: boolean; + event: GitHubTelemetryEvent; +} +/** + * Pending external tool call request ID, with the tool result or an error describing why it failed. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "HandlePendingToolCallRequest". + */ +/** @experimental */ +export interface HandlePendingToolCallRequest { + /** + * Request ID of the pending tool call + */ + requestId: string; + result?: ExternalToolResult; + /** + * Error message if the tool call failed + */ + error?: string; +} +/** + * Indicates whether the external tool call result was handled successfully. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "HandlePendingToolCallResult". + */ +/** @experimental */ +export interface HandlePendingToolCallResult { + /** + * Whether the tool call result was handled successfully + */ + success: boolean; +} +/** + * Indicates whether an in-progress manual compaction was aborted. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "HistoryAbortManualCompactionResult". */ /** @experimental */ export interface HistoryAbortManualCompactionResult { @@ -8169,324 +8487,6 @@ export interface OptionsUpdateAdditionalContentExclusionPolicyRuleSource { name: string; type: string; } -/** - * Parameters for cooperatively aborting a orchestration body. - * - * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "OrchestrationAbortRequest". - */ -/** @experimental */ -export interface OrchestrationAbortRequest { - /** - * Target session identifier - */ - sessionId: string; - /** - * Orchestration run identifier. - */ - runId: string; -} -/** - * Acknowledgement that a orchestration request was accepted. - * - * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "OrchestrationAckResult". - */ -/** @experimental */ -export interface OrchestrationAckResult {} -/** - * Options for one orchestration-scoped subagent call. - * - * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "OrchestrationAgentOptions". - */ -/** @experimental */ -export interface OrchestrationAgentOptions { - /** - * Optional label distinguishing otherwise identical memoized agent calls. - */ - label?: string; - /** - * Optional JSON Schema for structured agent output. - */ - schema?: { - [k: string]: unknown | undefined; - }; - /** - * Optional model identifier for the subagent. - */ - model?: string; -} -/** - * Parameters for one orchestration-scoped subagent call. - * - * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "OrchestrationAgentRequest". - */ -/** @experimental */ -export interface OrchestrationAgentRequest { - /** - * Orchestration run identifier that owns the subagent. - */ - orchestrationRunId: string; - /** - * Prompt to send to the subagent. - */ - prompt: string; - opts: OrchestrationAgentOptions; -} -/** - * Result of one orchestration-scoped subagent call. - * - * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "OrchestrationAgentResult". - */ -/** @experimental */ -export interface OrchestrationAgentResult { - /** - * Agent result, omitted when the agent produced no result. - */ - result?: { - [k: string]: unknown | undefined; - }; -} -/** - * Parameters for cancelling a orchestration run. - * - * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "OrchestrationCancelRequest". - */ -/** @experimental */ -export interface OrchestrationCancelRequest { - /** - * Orchestration run identifier. - */ - runId: string; -} -/** - * Parameters sent to the owning extension to execute a orchestration closure. - * - * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "OrchestrationExecuteRequest". - */ -/** @experimental */ -export interface OrchestrationExecuteRequest { - /** - * Target session identifier - */ - sessionId: string; - /** - * Registered orchestration name. - */ - name: string; - /** - * Orchestration run identifier. - */ - runId: string; - /** - * Orchestration input value. - */ - args: { - [k: string]: unknown | undefined; - }; - /** - * Parent orchestration run identifier for nested execution. - */ - parentRunId?: string; -} -/** - * Result returned by an extension orchestration closure. - * - * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "OrchestrationExecuteResult". - */ -/** @experimental */ -export interface OrchestrationExecuteResult { - /** - * Orchestration result value. - */ - result: { - [k: string]: unknown | undefined; - }; -} -/** - * Parameters for retrieving a orchestration run. - * - * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "OrchestrationGetRunRequest". - */ -/** @experimental */ -export interface OrchestrationGetRunRequest { - /** - * Orchestration run identifier. - */ - runId: string; -} -/** - * Parameters for reading a orchestration journal entry. - * - * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "OrchestrationJournalGetRequest". - */ -/** @experimental */ -export interface OrchestrationJournalGetRequest { - /** - * Orchestration run identifier. - */ - runId: string; - /** - * Namespaced journal key. - */ - key: string; -} -/** - * Result of reading a orchestration journal entry. - * - * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "OrchestrationJournalGetResult". - */ -/** @experimental */ -export interface OrchestrationJournalGetResult { - /** - * Whether the journal contained the requested key. - */ - hit: boolean; - /** - * Cached JSON result. The hit field distinguishes a cached JSON null from a miss. - */ - resultJson?: { - [k: string]: unknown | undefined; - }; -} -/** - * Parameters for storing a orchestration journal entry. - * - * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "OrchestrationJournalPutRequest". - */ -/** @experimental */ -export interface OrchestrationJournalPutRequest { - /** - * Orchestration run identifier. - */ - runId: string; - /** - * Namespaced journal key. - */ - key: string; - /** - * JSON result to memoize. - */ - resultJson: { - [k: string]: unknown | undefined; - }; -} -/** - * One ordered orchestration progress line. - * - * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "OrchestrationLogLine". - */ -/** @experimental */ -export interface OrchestrationLogLine { - /** - * Monotonic sequence number within the orchestration run. - */ - seq: number; - kind: OrchestrationLogLineKind; - /** - * Progress text. - */ - text: string; -} -/** - * Parameters for recording orchestration progress. - * - * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "OrchestrationLogRequest". - */ -/** @experimental */ -export interface OrchestrationLogRequest { - /** - * Orchestration run identifier. - */ - runId: string; - /** - * Ordered progress lines to append. - */ - lines: OrchestrationLogLine[]; -} -/** - * Parameters for invoking a registered orchestration. - * - * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "OrchestrationRunRequest". - */ -/** @experimental */ -export interface OrchestrationRunRequest { - /** - * Registered orchestration name. - */ - name: string; - /** - * Orchestration input value. - */ - args: { - [k: string]: unknown | undefined; - }; - options?: RunOptions; -} -/** - * Options controlling orchestration invocation. - * - * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "RunOptions". - */ -/** @experimental */ -export interface RunOptions { - /** - * Whether to return once the approved run starts instead of awaiting its terminal result. - */ - background?: boolean; - /** - * Run identifier whose journal and progress should seed this resumed run. - */ - resumeFromRunId?: string; -} -/** - * Complete current or terminal orchestration run envelope. - * - * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "OrchestrationRunResult". - */ -/** @experimental */ -export interface OrchestrationRunResult { - /** - * Orchestration run identifier. - */ - runId: string; - status: OrchestrationRunStatus; - /** - * Completed orchestration result. - */ - result?: { - [k: string]: unknown | undefined; - }; - /** - * Error message for an errored run. - */ - error?: string; - /** - * Reason for a halted or cancelled run. - */ - reason?: string; - /** - * Partial journal and progress snapshot for a halted or cancelled run. - */ - snapshot?: { - [k: string]: unknown | undefined; - }; -} /** * Pending permission prompt reconstructed from event history, with request ID and user-facing prompt details. * @@ -17021,72 +17021,72 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin }, }, /** @experimental */ - orchestration: { + factory: { /** - * Runs a registered orchestration by name at the top level. + * Runs a registered factory by name at the top level. * - * @param params Parameters for invoking a registered orchestration. + * @param params Parameters for invoking a registered factory. * - * @returns Complete current or terminal orchestration run envelope. + * @returns Complete current or terminal factory run envelope. */ - run: async (params: OrchestrationRunRequest): Promise => - connection.sendRequest("session.orchestration.run", { sessionId, ...params }), + run: async (params: FactoryRunRequest): Promise => + connection.sendRequest("session.factory.run", { sessionId, ...params }), /** - * Gets the current or settled envelope for a orchestration run. + * Gets the current or settled envelope for a factory run. * - * @param params Parameters for retrieving a orchestration run. + * @param params Parameters for retrieving a factory run. * - * @returns Complete current or terminal orchestration run envelope. + * @returns Complete current or terminal factory run envelope. */ - getRun: async (params: OrchestrationGetRunRequest): Promise => - connection.sendRequest("session.orchestration.getRun", { sessionId, ...params }), + getRun: async (params: FactoryGetRunRequest): Promise => + connection.sendRequest("session.factory.getRun", { sessionId, ...params }), /** - * Requests cancellation of a orchestration run and returns its run envelope. + * Requests cancellation of a factory run and returns its run envelope. * - * @param params Parameters for cancelling a orchestration run. + * @param params Parameters for cancelling a factory run. * - * @returns Complete current or terminal orchestration run envelope. + * @returns Complete current or terminal factory run envelope. */ - cancel: async (params: OrchestrationCancelRequest): Promise => - connection.sendRequest("session.orchestration.cancel", { sessionId, ...params }), + cancel: async (params: FactoryCancelRequest): Promise => + connection.sendRequest("session.factory.cancel", { sessionId, ...params }), /** - * Records a batch of ordered orchestration progress lines. + * Records a batch of ordered factory progress lines. * - * @param params Parameters for recording orchestration progress. + * @param params Parameters for recording factory progress. * - * @returns Acknowledgement that a orchestration request was accepted. + * @returns Acknowledgement that a factory request was accepted. */ - log: async (params: OrchestrationLogRequest): Promise => - connection.sendRequest("session.orchestration.log", { sessionId, ...params }), + log: async (params: FactoryLogRequest): Promise => + connection.sendRequest("session.factory.log", { sessionId, ...params }), /** - * Runs one orchestration-scoped subagent and returns its result. + * Runs one factory-scoped subagent and returns its result. * - * @param params Parameters for one orchestration-scoped subagent call. + * @param params Parameters for one factory-scoped subagent call. * - * @returns Result of one orchestration-scoped subagent call. + * @returns Result of one factory-scoped subagent call. */ - agent: async (params: OrchestrationAgentRequest): Promise => - connection.sendRequest("session.orchestration.agent", { sessionId, ...params }), + agent: async (params: FactoryAgentRequest): Promise => + connection.sendRequest("session.factory.agent", { sessionId, ...params }), /** @experimental */ journal: { /** - * Reads a memoized orchestration journal entry. + * Reads a memoized factory journal entry. * - * @param params Parameters for reading a orchestration journal entry. + * @param params Parameters for reading a factory journal entry. * - * @returns Result of reading a orchestration journal entry. + * @returns Result of reading a factory journal entry. */ - get: async (params: OrchestrationJournalGetRequest): Promise => - connection.sendRequest("session.orchestration.journal.get", { sessionId, ...params }), + get: async (params: FactoryJournalGetRequest): Promise => + connection.sendRequest("session.factory.journal.get", { sessionId, ...params }), /** - * Stores a memoized orchestration journal entry. + * Stores a memoized factory journal entry. * - * @param params Parameters for storing a orchestration journal entry. + * @param params Parameters for storing a factory journal entry. * - * @returns Acknowledgement that a orchestration request was accepted. + * @returns Acknowledgement that a factory request was accepted. */ - put: async (params: OrchestrationJournalPutRequest): Promise => - connection.sendRequest("session.orchestration.journal.put", { sessionId, ...params }), + put: async (params: FactoryJournalPutRequest): Promise => + connection.sendRequest("session.factory.journal.put", { sessionId, ...params }), }, }, /** @experimental */ @@ -18577,25 +18577,25 @@ export interface ProviderTokenHandler { getToken(params: ProviderTokenAcquireRequest): Promise; } -/** Handler for `orchestration` client session API methods. */ +/** Handler for `factory` client session API methods. */ /** @experimental */ -export interface OrchestrationHandler { +export interface FactoryHandler { /** - * Asks the owning extension connection to execute a registered orchestration closure. + * Asks the owning extension connection to execute a registered factory closure. * - * @param params Parameters sent to the owning extension to execute a orchestration closure. + * @param params Parameters sent to the owning extension to execute a factory closure. * - * @returns Result returned by an extension orchestration closure. + * @returns Result returned by an extension factory closure. */ - execute(params: OrchestrationExecuteRequest): Promise; + execute(params: FactoryExecuteRequest): Promise; /** - * Asks the owning extension connection to abort a running orchestration cooperatively. + * Asks the owning extension connection to abort a running factory cooperatively. * - * @param params Parameters for cooperatively aborting a orchestration body. + * @param params Parameters for cooperatively aborting a factory body. * - * @returns Acknowledgement that a orchestration request was accepted. + * @returns Acknowledgement that a factory request was accepted. */ - abort(params: OrchestrationAbortRequest): Promise; + abort(params: FactoryAbortRequest): Promise; } /** Handler for `sessionFs` client session API methods. */ @@ -18729,7 +18729,7 @@ export interface CanvasHandler { /** All client session API handler groups. */ export interface ClientSessionApiHandlers { providerToken?: ProviderTokenHandler; - orchestration?: OrchestrationHandler; + factory?: FactoryHandler; sessionFs?: SessionFsHandler; canvas?: CanvasHandler; } @@ -18749,14 +18749,14 @@ export function registerClientSessionApiHandlers( if (!handler) throw new Error(`No providerToken handler registered for session: ${params.sessionId}`); return handler.getToken(params); }); - connection.onRequest("orchestration.execute", async (params: OrchestrationExecuteRequest) => { - const handler = getHandlers(params.sessionId).orchestration; - if (!handler) throw new Error(`No orchestration handler registered for session: ${params.sessionId}`); + connection.onRequest("factory.execute", async (params: FactoryExecuteRequest) => { + const handler = getHandlers(params.sessionId).factory; + if (!handler) throw new Error(`No factory handler registered for session: ${params.sessionId}`); return handler.execute(params); }); - connection.onRequest("orchestration.abort", async (params: OrchestrationAbortRequest) => { - const handler = getHandlers(params.sessionId).orchestration; - if (!handler) throw new Error(`No orchestration handler registered for session: ${params.sessionId}`); + connection.onRequest("factory.abort", async (params: FactoryAbortRequest) => { + const handler = getHandlers(params.sessionId).factory; + if (!handler) throw new Error(`No factory handler registered for session: ${params.sessionId}`); return handler.abort(params); }); connection.onRequest("sessionFs.readFile", async (params: SessionFsReadFileRequest) => { diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 1b7f41c677..a90ecc612f 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -12,7 +12,7 @@ export { CopilotClient } from "./client.js"; export { RuntimeConnection } from "./types.js"; export { BuiltInTools, ToolSet } from "./toolSet.js"; export { CopilotSession, type AssistantMessageEvent } from "./session.js"; -export { defineOrchestration, OrchestrationRunError } from "./orchestration.js"; +export { defineFactory, FactoryRunError } from "./factory.js"; export { Canvas, CanvasError, @@ -87,8 +87,8 @@ export type { LargeToolOutputConfig, MemoryConfiguration, UiInputOptions, - OrchestrationLimits, - OrchestrationMeta, + FactoryLimits, + FactoryMeta, MCPStdioServerConfig, MCPHTTPServerConfig, MCPServerConfig, @@ -163,12 +163,12 @@ export type { } from "./types.js"; export type { RunOptions, - SessionOrchestrationApi, - OrchestrationAgentOptions, - OrchestrationContext, - OrchestrationDefinition, - OrchestrationHandle, - OrchestrationJsonSchema, - OrchestrationPipelineStage, - OrchestrationStepOptions, -} from "./orchestration.js"; + SessionFactoryApi, + FactoryAgentOptions, + FactoryContext, + FactoryDefinition, + FactoryHandle, + FactoryJsonSchema, + FactoryPipelineStage, + FactoryStepOptions, +} from "./factory.js"; diff --git a/nodejs/src/orchestration.ts b/nodejs/src/orchestration.ts deleted file mode 100644 index fe672e1715..0000000000 --- a/nodejs/src/orchestration.ts +++ /dev/null @@ -1,246 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - *--------------------------------------------------------------------------------------------*/ - -import type { OrchestrationRunResult } from "./generated/rpc.js"; -import type { CopilotSession } from "./session.js"; -import type { OrchestrationMeta } from "./types.js"; - -declare const orchestrationHandleBrand: unique symbol; - -/** - * Conservative JSON shape language accepted for structured orchestration agent output. - * - * This is a best-effort structural guard used to decide whether a subagent's - * structured output should be accepted or retried — **not** a full JSON Schema - * validator. Only these keywords are honored: `type`, `required`, `enum`, - * `const`, recursive `properties`/`items`, and `anyOf`/`oneOf`/`allOf`. - * - * Everything else is **ignored, not enforced**. In particular, string - * constraints (`pattern`, `minLength`, `maxLength`, `format`), numeric ranges - * (`minimum`, `maximum`), `additionalProperties`, and boolean (`true`/`false`) - * schemas do not reject non-conforming output. `oneOf` is treated like `anyOf` - * (at least one branch must match) rather than strict exactly-one. Author - * schemas within this subset; do not rely on unsupported constraints for - * correctness. - * - * @experimental Part of the experimental Agent Orchestrations surface and may - * change or be removed in future SDK or CLI releases. - */ -export type OrchestrationJsonSchema = Record; - -/** - * Options for one orchestration-scoped subagent call. - * - * @experimental Part of the experimental Agent Orchestrations surface and may - * change or be removed in future SDK or CLI releases. - */ -export interface OrchestrationAgentOptions { - label?: string; - schema?: OrchestrationJsonSchema; - model?: string; -} - -/** - * Options for a durable orchestration step. - * - * @experimental Part of the experimental Agent Orchestrations surface and may - * change or be removed in future SDK or CLI releases. - */ -export interface OrchestrationStepOptions { - /** Skip the journal and always invoke the producer. */ - volatile?: boolean; -} - -/** - * One stage in a per-item orchestration pipeline. - * - * @experimental Part of the experimental Agent Orchestrations surface and may - * change or be removed in future SDK or CLI releases. - */ -export type OrchestrationPipelineStage = ( - previous: TInput, - item: unknown, - index: number -) => Promise | TResult; - -/** - * Context passed to an extension-authored orchestration body. - * - * @experimental Part of the experimental Agent Orchestrations surface and may - * change or be removed in future SDK or CLI releases. - */ -export interface OrchestrationContext { - /** Spawn and await one orchestration-scoped subagent. */ - agent(prompt: string, options?: OrchestrationAgentOptions): Promise; - /** Memoize an arbitrary producer under a stable author-supplied key. */ - step( - key: string, - producer: () => Promise | TResult, - options?: OrchestrationStepOptions - ): Promise; - /** Run thunks concurrently, returning null for a thunk that throws. */ - parallel( - thunks: Array<() => Promise | TResult> - ): Promise>; - /** Run each item through every stage without barriers between stages. */ - pipeline(items: unknown[], ...stages: OrchestrationPipelineStage[]): Promise; - /** Start a named orchestration progress phase. */ - phase(title: string): void; - /** Emit a orchestration progress line. */ - log(message: string): void; - /** Reject because nested orchestrations are not supported. */ - orchestration(name: string, args?: unknown): Promise; - /** Caller-supplied input, forwarded verbatim. */ - args: TArgs; - /** The same full session instance returned by `joinSession`. */ - session: CopilotSession; - /** Cooperative cancellation signal for the current orchestration run. */ - signal: AbortSignal; -} - -/** - * Definition accepted by {@link defineOrchestration}. - * - * @experimental Part of the experimental Agent Orchestrations surface and may - * change or be removed in future SDK or CLI releases. - */ -export interface OrchestrationDefinition { - meta: OrchestrationMeta; - run(context: OrchestrationContext): Promise; -} - -/** - * Opaque reusable reference to a defined orchestration. - * - * @experimental Part of the experimental Agent Orchestrations surface and may - * change or be removed in future SDK or CLI releases. - */ -export interface OrchestrationHandle { - readonly meta: OrchestrationMeta; - readonly [orchestrationHandleBrand]: { - readonly args: TArgs; - readonly result: TResult; - }; -} - -/** - * Options for invoking a orchestration. - * - * @experimental Part of the experimental Agent Orchestrations surface and may - * change or be removed in future SDK or CLI releases. - */ -export interface RunOptions { - /** Input surfaced as `context.args`. */ - args?: TArgs; - /** Return once the approved run starts instead of awaiting completion. */ - background?: boolean; - /** Prior run whose journal and progress should seed this run. */ - resumeFromRunId?: string; -} - -/** - * Friendly orchestration API exposed on a session. - * - * @experimental Part of the experimental Agent Orchestrations surface and may - * change or be removed in future SDK or CLI releases. - */ -export interface SessionOrchestrationApi { - run(name: string, options: RunOptions & { background: true }): Promise; - run( - name: string, - options?: RunOptions & { background?: false } - ): Promise; - run( - name: string, - options?: RunOptions - ): Promise; - run( - orchestration: OrchestrationHandle, - options: RunOptions & { background: true } - ): Promise; - run( - orchestration: OrchestrationHandle, - options?: RunOptions & { background?: false } - ): Promise; - run( - orchestration: OrchestrationHandle, - options?: RunOptions - ): Promise; - /** Read the latest durable envelope for a orchestration run. */ - getRun(runId: string): Promise; - /** Cancel a orchestration run and return its terminal envelope. */ - cancel(runId: string): Promise; -} - -/** - * Error thrown when a foreground orchestration run does not complete successfully. - * - * @experimental Part of the experimental Agent Orchestrations surface and may - * change or be removed in future SDK or CLI releases. - */ -export class OrchestrationRunError extends Error { - constructor(public readonly envelope: OrchestrationRunResult) { - super( - envelope.error ?? - envelope.reason ?? - `Orchestration run "${envelope.runId}" ended with status "${envelope.status}"` - ); - this.name = "OrchestrationRunError"; - } -} - -interface StoredOrchestration { - meta: OrchestrationMeta; - run(context: OrchestrationContext): Promise; -} - -const orchestrationHandles = new WeakMap(); - -function validateLimits(meta: OrchestrationMeta): void { - const limits = meta.limits; - if (!limits) { - return; - } - - for (const field of ["maxConcurrentSubagents", "maxTotalSubagents"] as const) { - const value = limits[field]; - if (value !== undefined && (!Number.isInteger(value) || value <= 0)) { - throw new Error(`Orchestration limit "${field}" must be a positive integer`); - } - } - - if (limits.timeout !== undefined && (!Number.isFinite(limits.timeout) || limits.timeout <= 0)) { - throw new Error('Orchestration limit "timeout" must be a positive number of milliseconds'); - } -} - -/** - * Defines an extension-authored orchestration and returns an opaque registration handle. - * - * @experimental Part of the experimental Agent Orchestrations surface and may - * change or be removed in future SDK or CLI releases. - */ -export function defineOrchestration( - definition: OrchestrationDefinition -): OrchestrationHandle { - validateLimits(definition.meta); - - const stored: StoredOrchestration = { - meta: definition.meta, - run: definition.run as StoredOrchestration["run"], - }; - const handle = Object.freeze({ meta: definition.meta }) as OrchestrationHandle; - - orchestrationHandles.set(handle, stored); - return handle; -} - -/** @internal */ -export function getOrchestrationDefinition(handle: OrchestrationHandle): StoredOrchestration { - const definition = orchestrationHandles.get(handle); - if (!definition) { - throw new Error("Invalid orchestration handle"); - } - return definition; -} diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 11b7a67869..2d3327e4ff 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -15,8 +15,8 @@ import type { CanvasActionInvokeResult, CurrentToolMetadata, McpOauthPendingRequestResponse, - OrchestrationExecuteResult, - OrchestrationLogLine, + FactoryExecuteResult, + FactoryLogLine, } from "./generated/rpc.js"; import { type Canvas, CanvasError } from "./canvas.js"; import type { OpenCanvasInstance } from "./generated/rpc.js"; @@ -63,14 +63,14 @@ import type { UserInputResponse, } from "./types.js"; import { - getOrchestrationDefinition, - OrchestrationRunError, + getFactoryDefinition, + FactoryRunError, type RunOptions, - type SessionOrchestrationApi, - type OrchestrationContext, - type OrchestrationHandle, - type OrchestrationStepOptions, -} from "./orchestration.js"; + type SessionFactoryApi, + type FactoryContext, + type FactoryHandle, + type FactoryStepOptions, +} from "./factory.js"; /** * Convert a raw hook input received over the wire into its public-facing shape. @@ -105,18 +105,18 @@ function isOpenCanvasInstance(value: unknown): value is OpenCanvasInstance { ); } -const ORCHESTRATION_LOG_FLUSH_DELAY_MS = 10; -const MAX_ORCHESTRATION_FANOUT_ITEMS = 4096; +const FACTORY_LOG_FLUSH_DELAY_MS = 10; +const MAX_FACTORY_FANOUT_ITEMS = 4096; -function assertOrchestrationFanoutSize(kind: "parallel" | "pipeline", size: number): void { - if (size > MAX_ORCHESTRATION_FANOUT_ITEMS) { +function assertFactoryFanoutSize(kind: "parallel" | "pipeline", size: number): void { + if (size > MAX_FACTORY_FANOUT_ITEMS) { throw new Error( - `${kind}() accepts at most ${MAX_ORCHESTRATION_FANOUT_ITEMS} items; got ${size}.` + `${kind}() accepts at most ${MAX_FACTORY_FANOUT_ITEMS} items; got ${size}.` ); } } -async function runOrchestrationParallel( +async function runFactoryParallel( thunks: Array<() => Promise | TResult> ): Promise> { if (!Array.isArray(thunks)) { @@ -124,7 +124,7 @@ async function runOrchestrationParallel( "parallel() expects an array of functions, not promises. Wrap each call: () => agent(...)" ); } - assertOrchestrationFanoutSize("parallel", thunks.length); + assertFactoryFanoutSize("parallel", thunks.length); if (thunks.some((thunk) => typeof thunk !== "function")) { throw new Error( "parallel() expects an array of functions, not promises. Wrap each call: () => agent(...)" @@ -139,7 +139,7 @@ async function runOrchestrationParallel( ); } -async function runOrchestrationPipeline( +async function runFactoryPipeline( items: unknown[], ...stages: Array< (previous: unknown, item: unknown, index: number) => Promise | unknown @@ -148,7 +148,7 @@ async function runOrchestrationPipeline( if (!Array.isArray(items)) { throw new Error("pipeline(items, ...stages): items must be an array"); } - assertOrchestrationFanoutSize("pipeline", items.length); + assertFactoryFanoutSize("pipeline", items.length); return Promise.all( items.map(async (item, index) => { let previous = item; @@ -164,20 +164,20 @@ async function runOrchestrationPipeline( ); } -class OrchestrationProgressBuffer { +class FactoryProgressBuffer { private nextSeq = 0; - private pending: OrchestrationLogLine[] = []; + private pending: FactoryLogLine[] = []; private flushTimer?: ReturnType; private flushTail: Promise = Promise.resolve(); private flushError: unknown; private flushFailed = false; private closed = false; - constructor(private readonly send: (lines: OrchestrationLogLine[]) => Promise) {} + constructor(private readonly send: (lines: FactoryLogLine[]) => Promise) {} - enqueue(kind: OrchestrationLogLine["kind"], text: string): void { + enqueue(kind: FactoryLogLine["kind"], text: string): void { if (this.closed) { - throw new Error("Cannot log after the orchestration run has settled"); + throw new Error("Cannot log after the factory run has settled"); } this.pending.push({ seq: this.nextSeq++, kind, text }); @@ -217,7 +217,7 @@ class OrchestrationProgressBuffer { this.flushTimer = setTimeout(() => { this.flushTimer = undefined; void this.flush().catch(() => {}); - }, ORCHESTRATION_LOG_FLUSH_DELAY_MS); + }, FACTORY_LOG_FLUSH_DELAY_MS); this.flushTimer.unref?.(); } @@ -229,15 +229,15 @@ class OrchestrationProgressBuffer { } } -async function awaitOrchestrationOperation( +async function awaitFactoryOperation( operation: Promise, signal: AbortSignal ): Promise { - throwIfOrchestrationAborted(signal); + throwIfFactoryAborted(signal); let rejectAbort: ((reason?: unknown) => void) | undefined; const onAbort = () => rejectAbort?.( - signal.reason ?? new DOMException("Orchestration run was aborted", "AbortError") + signal.reason ?? new DOMException("Factory run was aborted", "AbortError") ); try { return await Promise.race([ @@ -252,9 +252,9 @@ async function awaitOrchestrationOperation( } } -function throwIfOrchestrationAborted(signal: AbortSignal): void { +function throwIfFactoryAborted(signal: AbortSignal): void { if (signal.aborted) { - throw signal.reason ?? new DOMException("Orchestration run was aborted", "AbortError"); + throw signal.reason ?? new DOMException("Factory run was aborted", "AbortError"); } } @@ -302,8 +302,8 @@ export class CopilotSession { private canvases: Map = new Map(); private bearerTokenProviders: Map = new Map(); private commandHandlers: Map = new Map(); - private orchestrations = new Map>(); - private orchestrationAbortControllers = new Map(); + private factories = new Map>(); + private factoryAbortControllers = new Map(); private permissionHandler?: PermissionHandler; private mcpAuthHandler?: McpAuthHandler; private userInputHandler?: UserInputHandler; @@ -322,24 +322,24 @@ export class CopilotSession { clientSessionApis: ClientSessionApiHandlers = {}; /** - * Friendly orchestration API for running registered orchestrations by name or handle. + * Friendly factory API for running registered factories by name or handle. * - * @experimental Part of the experimental Agent Orchestrations surface and may + * @experimental Part of the experimental Agent Factories surface and may * change or be removed in future SDK or CLI releases. */ - readonly orchestration: SessionOrchestrationApi = { + readonly factory: SessionFactoryApi = { run: (async ( - nameOrHandle: string | OrchestrationHandle, + nameOrHandle: string | FactoryHandle, options?: RunOptions ): Promise => { const name = typeof nameOrHandle === "string" ? nameOrHandle - : getOrchestrationDefinition(nameOrHandle).meta.name; - const envelope = await this.rpc.orchestration.run({ + : getFactoryDefinition(nameOrHandle).meta.name; + const envelope = await this.rpc.factory.run({ name, args: (options?.args === undefined ? {} : options.args) as Parameters< - typeof this.rpc.orchestration.run + typeof this.rpc.factory.run >[0]["args"], options: { background: options?.background, @@ -351,12 +351,12 @@ export class CopilotSession { return envelope; } if (envelope.status !== "completed") { - throw new OrchestrationRunError(envelope); + throw new FactoryRunError(envelope); } return envelope.result; - }) as SessionOrchestrationApi["run"], - getRun: (runId) => this.rpc.orchestration.getRun({ runId }), - cancel: (runId) => this.rpc.orchestration.cancel({ runId }), + }) as SessionFactoryApi["run"], + getRun: (runId) => this.rpc.factory.getRun({ runId }), + cancel: (runId) => this.rpc.factory.cancel({ runId }), }; /** @@ -562,11 +562,11 @@ export class CopilotSession { this.autoModeSwitchHandler = undefined; this.commandHandlers.clear(); this.canvases.clear(); - this.orchestrations.clear(); - for (const controller of this.orchestrationAbortControllers.values()) { + this.factories.clear(); + for (const controller of this.factoryAbortControllers.values()) { controller.abort(); } - this.orchestrationAbortControllers.clear(); + this.factoryAbortControllers.clear(); this.transformCallbacks?.clear(); } @@ -1082,58 +1082,58 @@ export class CopilotSession { } /** - * Registers orchestration closures and reverse-RPC handlers for this session. + * Registers factory closures and reverse-RPC handlers for this session. * - * @param orchestrations - Orchestration handles declared by the joining extension. + * @param factories - Factory handles declared by the joining extension. * @internal Called by the SDK when an extension joins a session. */ - registerOrchestrations(orchestrations?: OrchestrationHandle[]): void { - this.orchestrations.clear(); - if (!orchestrations || orchestrations.length === 0) { - delete this.clientSessionApis.orchestration; + registerFactories(factories?: FactoryHandle[]): void { + this.factories.clear(); + if (!factories || factories.length === 0) { + delete this.clientSessionApis.factory; return; } - for (const handle of orchestrations) { - const definition = getOrchestrationDefinition(handle); - this.orchestrations.set(definition.meta.name, definition); + for (const handle of factories) { + const definition = getFactoryDefinition(handle); + this.factories.set(definition.meta.name, definition); } const self = this; - this.clientSessionApis.orchestration = { + this.clientSessionApis.factory = { async execute(params) { - const definition = self.orchestrations.get(params.name); + const definition = self.factories.get(params.name); if (!definition) { - const message = `No orchestration registered with name "${params.name}"`; + const message = `No factory registered with name "${params.name}"`; throw new ResponseError(ErrorCodes.InvalidParams, message, { - code: "orchestration_not_found", + code: "factory_not_found", name: params.name, }); } const controller = new AbortController(); - self.orchestrationAbortControllers.set(params.runId, controller); - const progress = new OrchestrationProgressBuffer(async (lines) => { - await self.rpc.orchestration.log({ runId: params.runId, lines }); + self.factoryAbortControllers.set(params.runId, controller); + const progress = new FactoryProgressBuffer(async (lines) => { + await self.rpc.factory.log({ runId: params.runId, lines }); }); try { - const context: OrchestrationContext = { + const context: FactoryContext = { args: params.args, session: self, signal: controller.signal, phase: (title: string) => { - throwIfOrchestrationAborted(controller.signal); + throwIfFactoryAborted(controller.signal); progress.enqueue("phase", title); }, log: (message: string) => { - throwIfOrchestrationAborted(controller.signal); + throwIfFactoryAborted(controller.signal); progress.enqueue("log", message); }, agent: async (prompt, options = {}) => { await progress.flush(); - const response = await awaitOrchestrationOperation( - self.rpc.orchestration.agent({ - orchestrationRunId: params.runId, + const response = await awaitFactoryOperation( + self.rpc.factory.agent({ + factoryRunId: params.runId, prompt, opts: { label: options.label, @@ -1148,14 +1148,14 @@ export class CopilotSession { step: async ( key: string, producer: () => Promise | TResult, - options: OrchestrationStepOptions = {} + options: FactoryStepOptions = {} ): Promise => { await progress.flush(); if (options.volatile) { return producer(); } - const cached = await awaitOrchestrationOperation( - self.rpc.orchestration.journal.get({ + const cached = await awaitFactoryOperation( + self.rpc.factory.journal.get({ runId: params.runId, key, }), @@ -1174,40 +1174,40 @@ export class CopilotSession { `step("${key}") returned a value that is not JSON-serializable` ); } - await awaitOrchestrationOperation( - self.rpc.orchestration.journal.put({ + await awaitFactoryOperation( + self.rpc.factory.journal.put({ runId: params.runId, key, resultJson: result as Parameters< - typeof self.rpc.orchestration.journal.put + typeof self.rpc.factory.journal.put >[0]["resultJson"], }), controller.signal ); return result; }, - parallel: runOrchestrationParallel, - pipeline: runOrchestrationPipeline, - orchestration: async () => { - throw new Error("nested orchestrations are not supported"); + parallel: runFactoryParallel, + pipeline: runFactoryPipeline, + factory: async () => { + throw new Error("nested factories are not supported"); }, }; const result = await definition.run(context); - return { result } as OrchestrationExecuteResult; + return { result } as FactoryExecuteResult; } finally { try { await progress.close(); } finally { - if (self.orchestrationAbortControllers.get(params.runId) === controller) { - self.orchestrationAbortControllers.delete(params.runId); + if (self.factoryAbortControllers.get(params.runId) === controller) { + self.factoryAbortControllers.delete(params.runId); } } } }, async abort(params) { - self.orchestrationAbortControllers + self.factoryAbortControllers .get(params.runId) - ?.abort(new DOMException("Orchestration run was aborted", "AbortError")); + ?.abort(new DOMException("Factory run was aborted", "AbortError")); return {}; }, }; diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 041a776199..0311c70b66 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1837,35 +1837,35 @@ export interface CanvasProviderIdentity { } /** - * Static resource ceilings declared by a orchestration before it runs. + * Static resource ceilings declared by a factory before it runs. * - * @experimental Part of the experimental Agent Orchestrations surface and may + * @experimental Part of the experimental Agent Factories surface and may * change or be removed in future SDK or CLI releases. */ -export interface OrchestrationLimits { - /** Maximum number of orchestration subagents that may run concurrently. Must be positive when present. */ +export interface FactoryLimits { + /** Maximum number of factory subagents that may run concurrently. Must be positive when present. */ maxConcurrentSubagents?: number; - /** Maximum total number of orchestration subagents that may be spawned. Must be positive when present. */ + /** Maximum total number of factory subagents that may be spawned. Must be positive when present. */ maxTotalSubagents?: number; - /** Wall-clock timeout for the orchestration run, in milliseconds. Must be positive when present. */ + /** Wall-clock timeout for the factory run, in milliseconds. Must be positive when present. */ timeout?: number; } /** - * Registration metadata for an extension-authored orchestration. + * Registration metadata for an extension-authored factory. * - * @experimental Part of the experimental Agent Orchestrations surface and may + * @experimental Part of the experimental Agent Factories surface and may * change or be removed in future SDK or CLI releases. */ -export interface OrchestrationMeta { - /** Stable orchestration name used for invocation. */ +export interface FactoryMeta { + /** Stable factory name used for invocation. */ name: string; - /** Human-readable orchestration description. */ + /** Human-readable factory description. */ description: string; - /** Display metadata for the progress phases the orchestration may report. */ + /** Display metadata for the progress phases the factory may report. */ phases: Array<{ title: string; detail?: string }>; /** Optional resource ceilings presented to the user before execution. */ - limits?: OrchestrationLimits; + limits?: FactoryLimits; } /** diff --git a/nodejs/test/orchestration.test.ts b/nodejs/test/factory.test.ts similarity index 77% rename from nodejs/test/orchestration.test.ts rename to nodejs/test/factory.test.ts index 2415f59a17..77a3b5d429 100644 --- a/nodejs/test/orchestration.test.ts +++ b/nodejs/test/factory.test.ts @@ -8,17 +8,17 @@ import { CopilotClient } from "../src/client.js"; import { joinSession } from "../src/extension.js"; import { CopilotSession } from "../src/session.js"; import { - defineOrchestration, - OrchestrationRunError, - type OrchestrationAgentOptions, - type OrchestrationDefinition, -} from "../src/orchestration.js"; + defineFactory, + FactoryRunError, + type FactoryAgentOptions, + type FactoryDefinition, +} from "../src/factory.js"; async function stopClient(client: CopilotClient): Promise { await client.stop(); } -describe("orchestrations", () => { +describe("factories", () => { const originalSessionId = process.env.SESSION_ID; afterEach(() => { @@ -33,18 +33,18 @@ describe("orchestrations", () => { it("defines a stable handle and accepts omitted limits", async () => { const meta = { name: "no-limits", - description: "A orchestration without resource limits", + description: "A factory without resource limits", phases: [], }; const run = vi.fn(async ({ args }: { args: unknown }) => args); - const handle = defineOrchestration({ meta, run }); + const handle = defineFactory({ meta, run }); expect(handle.meta).toBe(meta); expect(Object.isFrozen(handle)).toBe(true); const session = new CopilotSession("session-1", {} as never); - session.registerOrchestrations([handle]); - const result = await session.clientSessionApis.orchestration!.execute({ + session.registerFactories([handle]); + const result = await session.clientSessionApis.factory!.execute({ sessionId: session.sessionId, name: meta.name, runId: "run-1", @@ -66,23 +66,23 @@ describe("orchestrations", () => { const definition = { meta: { name: `invalid-${field}-${String(value)}`, - description: "Invalid orchestration", + description: "Invalid factory", phases: [], limits: { [field]: value }, }, run: async () => null, - } as OrchestrationDefinition; + } as FactoryDefinition; - expect(() => defineOrchestration(definition)).toThrow(/must be a positive/); + expect(() => defineFactory(definition)).toThrow(/must be a positive/); }); - it("serializes only orchestration metadata in the extension resume payload", async () => { + it("serializes only factory metadata in the extension resume payload", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => stopClient(client)); const run = vi.fn(async () => ({ ok: true })); - const orchestration = defineOrchestration({ + const factory = defineFactory({ meta: { name: "registered", description: "Registration test", @@ -101,7 +101,7 @@ describe("orchestrations", () => { const sessions = (client as never as { sessions: Map }) .sessions; expect( - sessions.get(params.sessionId as string)?.clientSessionApis.orchestration + sessions.get(params.sessionId as string)?.clientSessionApis.factory ).toBeDefined(); return { sessionId: params.sessionId }; } @@ -111,22 +111,22 @@ describe("orchestrations", () => { await client.resumeSessionForExtension( "session-registration", { onPermissionRequest: () => ({ kind: "approved" }) }, - [orchestration] + [factory] ); const payload = sendRequest.mock.calls.find( ([method]) => method === "session.resume" )![1] as { - orchestrations: unknown[]; + factories: unknown[]; }; - expect(payload.orchestrations).toEqual([orchestration.meta]); - expect(payload.orchestrations[0]).not.toHaveProperty("run"); - expect(JSON.stringify(payload.orchestrations)).not.toContain("async"); + expect(payload.factories).toEqual([factory.meta]); + expect(payload.factories[0]).not.toHaveProperty("run"); + expect(JSON.stringify(payload.factories)).not.toContain("async"); }); - it("passes orchestrations only through the extension join path", async () => { + it("passes factories only through the extension join path", async () => { process.env.SESSION_ID = "session-extension"; - const orchestration = defineOrchestration({ + const factory = defineFactory({ meta: { name: "extension-only", description: "Extension-only registration", @@ -138,19 +138,19 @@ describe("orchestrations", () => { .spyOn(CopilotClient.prototype, "resumeSessionForExtension") .mockResolvedValue({} as CopilotSession); - await joinSession({ orchestrations: [orchestration] }); + await joinSession({ factories: [factory] }); expect(resumeSessionForExtension).toHaveBeenCalledWith( "session-extension", expect.objectContaining({ suppressResumeEvent: true }), - [orchestration] + [factory] ); }); - it("builds the orchestration context with the unrestricted joined session identity", async () => { + it("builds the factory context with the unrestricted joined session identity", async () => { process.env.SESSION_ID = "session-context"; const sendRequest = vi.fn(async (method: string) => { - if (method === "session.orchestration.log") { + if (method === "session.factory.log") { return {}; } if (method === "session.tasks.list") { @@ -164,7 +164,7 @@ describe("orchestrations", () => { session: CopilotSession; signal: AbortSignal; }>(); - const orchestration = defineOrchestration({ + const factory = defineFactory({ meta: { name: "context", description: "Context test", @@ -179,14 +179,14 @@ describe("orchestrations", () => { }, }); vi.spyOn(CopilotClient.prototype, "resumeSessionForExtension").mockImplementation( - async (_sessionId, _config, orchestrations) => { - joinedSession.registerOrchestrations(orchestrations); + async (_sessionId, _config, factories) => { + joinedSession.registerFactories(factories); return joinedSession; } ); - const joinSessionResult = await joinSession({ orchestrations: [orchestration] }); - const executeResult = await joinSessionResult.clientSessionApis.orchestration!.execute({ + const joinSessionResult = await joinSession({ factories: [factory] }); + const executeResult = await joinSessionResult.clientSessionApis.factory!.execute({ sessionId: joinSessionResult.sessionId, name: "context", runId: "run-context", @@ -202,7 +202,7 @@ describe("orchestrations", () => { expect(sendRequest).toHaveBeenCalledWith("session.tasks.list", { sessionId: joinSessionResult.sessionId, }); - expect(sendRequest).toHaveBeenCalledWith("session.orchestration.log", { + expect(sendRequest).toHaveBeenCalledWith("session.factory.log", { sessionId: joinSessionResult.sessionId, runId: "run-context", lines: [ @@ -212,37 +212,37 @@ describe("orchestrations", () => { }); }); - it("rejects nested orchestrations without forwarding a runNested request", async () => { + it("rejects nested factories without forwarding a runNested request", async () => { const sendRequest = vi.fn(async () => { throw new Error("Unexpected forward request"); }); const session = new CopilotSession("session-no-nesting", { sendRequest } as never); - const orchestration = defineOrchestration({ + const factory = defineFactory({ meta: { name: "no-nesting", - description: "Nested orchestration rejection test", + description: "Nested factory rejection test", phases: [], }, - run: async (context) => context.orchestration("nested", { value: 42 }), + run: async (context) => context.factory("nested", { value: 42 }), }); - session.registerOrchestrations([orchestration]); + session.registerFactories([factory]); await expect( - session.clientSessionApis.orchestration!.execute({ + session.clientSessionApis.factory!.execute({ sessionId: session.sessionId, name: "no-nesting", runId: "run-no-nesting", args: {}, }) - ).rejects.toThrow("nested orchestrations are not supported"); + ).rejects.toThrow("nested factories are not supported"); expect(sendRequest).not.toHaveBeenCalled(); }); - it("flushes progress incrementally while a orchestration body is awaiting", async () => { + it("flushes progress incrementally while a factory body is awaiting", async () => { const sendRequest = vi.fn(async () => ({})); const session = new CopilotSession("session-live-progress", { sendRequest } as never); const body = Promise.withResolvers(); - const orchestration = defineOrchestration({ + const factory = defineFactory({ meta: { name: "live-progress", description: "Incremental progress test", @@ -254,16 +254,16 @@ describe("orchestrations", () => { return "done"; }, }); - session.registerOrchestrations([orchestration]); + session.registerFactories([factory]); - const execution = session.clientSessionApis.orchestration!.execute({ + const execution = session.clientSessionApis.factory!.execute({ sessionId: session.sessionId, name: "live-progress", runId: "run-live-progress", args: {}, }); await vi.waitFor(() => { - expect(sendRequest).toHaveBeenCalledWith("session.orchestration.log", { + expect(sendRequest).toHaveBeenCalledWith("session.factory.log", { sessionId: session.sessionId, runId: "run-live-progress", lines: [{ seq: 0, kind: "log", text: "before await" }], @@ -274,15 +274,15 @@ describe("orchestrations", () => { await expect(execution).resolves.toEqual({ result: "done" }); }); - it("calls orchestration.agent with the current run id and returns its text", async () => { + it("calls factory.agent with the current run id and returns its text", async () => { const sendRequest = vi.fn(async (method: string) => { - if (method === "session.orchestration.agent") { + if (method === "session.factory.agent") { return { result: "pong" }; } throw new Error(`Unexpected method: ${method}`); }); const session = new CopilotSession("session-agent", { sendRequest } as never); - const orchestration = defineOrchestration({ + const factory = defineFactory({ meta: { name: "agent", description: "Agent context test", @@ -294,21 +294,21 @@ describe("orchestrations", () => { model: "gpt-test", schema: { type: "string" }, effort: "high", - } as OrchestrationAgentOptions), + } as FactoryAgentOptions), }); - session.registerOrchestrations([orchestration]); + session.registerFactories([factory]); await expect( - session.clientSessionApis.orchestration!.execute({ + session.clientSessionApis.factory!.execute({ sessionId: session.sessionId, name: "agent", runId: "run-agent", args: {}, }) ).resolves.toEqual({ result: "pong" }); - expect(sendRequest).toHaveBeenCalledWith("session.orchestration.agent", { + expect(sendRequest).toHaveBeenCalledWith("session.factory.agent", { sessionId: session.sessionId, - orchestrationRunId: "run-agent", + factoryRunId: "run-agent", prompt: "Reply with pong", opts: { label: "Pong helper", @@ -322,12 +322,12 @@ describe("orchestrations", () => { const journal = new Map(); const sendRequest = vi.fn( async (method: string, params: { key?: string; resultJson?: unknown }) => { - if (method === "session.orchestration.journal.get") { + if (method === "session.factory.journal.get") { return journal.has(params.key!) ? { hit: true, resultJson: journal.get(params.key!) } : { hit: false }; } - if (method === "session.orchestration.journal.put") { + if (method === "session.factory.journal.put") { journal.set(params.key!, params.resultJson); return {}; } @@ -337,7 +337,7 @@ describe("orchestrations", () => { const session = new CopilotSession("session-step", { sendRequest } as never); let cachedProducerCalls = 0; let failingProducerCalls = 0; - const orchestration = defineOrchestration({ + const factory = defineFactory({ meta: { name: "step", description: "Durable step context test", @@ -363,10 +363,10 @@ describe("orchestrations", () => { return { first, second, failed, retried }; }, }); - session.registerOrchestrations([orchestration]); + session.registerFactories([factory]); await expect( - session.clientSessionApis.orchestration!.execute({ + session.clientSessionApis.factory!.execute({ sessionId: session.sessionId, name: "step", runId: "run-step", @@ -379,30 +379,30 @@ describe("orchestrations", () => { expect(failingProducerCalls).toBe(2); expect( sendRequest.mock.calls.filter( - ([method]) => method === "session.orchestration.journal.put" + ([method]) => method === "session.factory.journal.put" ) ).toHaveLength(2); }); - it("exposes orchestration getRun and forwards the run id", async () => { + it("exposes factory getRun and forwards the run id", async () => { const envelope = { runId: "run-read", status: "error", error: "failed" }; const sendRequest = vi.fn(async () => envelope); const session = new CopilotSession("session-read", { sendRequest } as never); - await expect(session.orchestration.getRun("run-read")).resolves.toEqual(envelope); - expect(sendRequest).toHaveBeenCalledWith("session.orchestration.getRun", { + await expect(session.factory.getRun("run-read")).resolves.toEqual(envelope); + expect(sendRequest).toHaveBeenCalledWith("session.factory.getRun", { sessionId: session.sessionId, runId: "run-read", }); }); - it("exposes orchestration cancel and forwards the run id", async () => { + it("exposes factory cancel and forwards the run id", async () => { const envelope = { runId: "run-cancel", status: "cancelled", reason: "cancelled" }; const sendRequest = vi.fn(async () => envelope); const session = new CopilotSession("session-cancel", { sendRequest } as never); - await expect(session.orchestration.cancel("run-cancel")).resolves.toEqual(envelope); - expect(sendRequest).toHaveBeenCalledWith("session.orchestration.cancel", { + await expect(session.factory.cancel("run-cancel")).resolves.toEqual(envelope); + expect(sendRequest).toHaveBeenCalledWith("session.factory.cancel", { sessionId: session.sessionId, runId: "run-cancel", }); @@ -413,7 +413,7 @@ describe("orchestrations", () => { const second = Promise.withResolvers(); const started: string[] = []; const session = new CopilotSession("session-parallel", {} as never); - const orchestration = defineOrchestration({ + const factory = defineFactory({ meta: { name: "parallel", description: "Parallel combinator test", @@ -435,11 +435,11 @@ describe("orchestrations", () => { }, ]), }); - session.registerOrchestrations([orchestration]); + session.registerFactories([factory]); let settled = false; const execution = session.clientSessionApis - .orchestration!.execute({ + .factory!.execute({ sessionId: session.sessionId, name: "parallel", runId: "run-parallel", @@ -460,7 +460,7 @@ describe("orchestrations", () => { it("rejects already-invoked promises passed to parallel with a clear diagnostic", async () => { const session = new CopilotSession("session-parallel-promises", {} as never); - const orchestration = defineOrchestration({ + const factory = defineFactory({ meta: { name: "parallel-promises", description: "Parallel misuse diagnostic", @@ -471,10 +471,10 @@ describe("orchestrations", () => { () => Promise >), }); - session.registerOrchestrations([orchestration]); + session.registerFactories([factory]); await expect( - session.clientSessionApis.orchestration!.execute({ + session.clientSessionApis.factory!.execute({ sessionId: session.sessionId, name: "parallel-promises", runId: "run-parallel-promises", @@ -490,7 +490,7 @@ describe("orchestrations", () => { const secondStageStarted = Promise.withResolvers(); const finalStageItems: string[] = []; const session = new CopilotSession("session-pipeline", {} as never); - const orchestration = defineOrchestration({ + const factory = defineFactory({ meta: { name: "pipeline", description: "Pipeline combinator test", @@ -517,9 +517,9 @@ describe("orchestrations", () => { } ), }); - session.registerOrchestrations([orchestration]); + session.registerFactories([factory]); - const execution = session.clientSessionApis.orchestration!.execute({ + const execution = session.clientSessionApis.factory!.execute({ sessionId: session.sessionId, name: "pipeline", runId: "run-pipeline", @@ -537,7 +537,7 @@ describe("orchestrations", () => { it("enforces the 4096-item cap for parallel and pipeline", async () => { const session = new CopilotSession("session-fanout-cap", {} as never); - const orchestration = defineOrchestration({ + const factory = defineFactory({ meta: { name: "fanout-cap", description: "Fan-out cap test", @@ -555,10 +555,10 @@ describe("orchestrations", () => { }; }, }); - session.registerOrchestrations([orchestration]); + session.registerFactories([factory]); await expect( - session.clientSessionApis.orchestration!.execute({ + session.clientSessionApis.factory!.execute({ sessionId: session.sessionId, name: "fanout-cap", runId: "run-fanout-cap", @@ -578,7 +578,7 @@ describe("orchestrations", () => { let tail = Promise.resolve(); const sendRequest = vi.fn( async (method: string, params: { prompt: string }): Promise<{ result: string }> => { - if (method !== "session.orchestration.agent") { + if (method !== "session.factory.agent") { throw new Error(`Unexpected method: ${method}`); } const previous = tail; @@ -596,7 +596,7 @@ describe("orchestrations", () => { const session = new CopilotSession("session-nested-combinators", { sendRequest, } as never); - const orchestration = defineOrchestration({ + const factory = defineFactory({ meta: { name: "nested-combinators", description: "Nested combinator deadlock regression", @@ -608,10 +608,10 @@ describe("orchestrations", () => { () => pipeline(["c"], (_previous, item) => agent(item as string)), ]), }); - session.registerOrchestrations([orchestration]); + session.registerFactories([factory]); await expect( - session.clientSessionApis.orchestration!.execute({ + session.clientSessionApis.factory!.execute({ sessionId: session.sessionId, name: "nested-combinators", runId: "run-nested-combinators", @@ -622,10 +622,10 @@ describe("orchestrations", () => { expect(sendRequest).toHaveBeenCalledTimes(3); }); - it("flushes buffered progress in finally when the orchestration body throws", async () => { + it("flushes buffered progress in finally when the factory body throws", async () => { const sendRequest = vi.fn(async () => ({})); const session = new CopilotSession("session-throw-progress", { sendRequest } as never); - const orchestration = defineOrchestration({ + const factory = defineFactory({ meta: { name: "throw-progress", description: "Throwing progress test", @@ -636,27 +636,27 @@ describe("orchestrations", () => { throw new Error("body failed"); }, }); - session.registerOrchestrations([orchestration]); + session.registerFactories([factory]); await expect( - session.clientSessionApis.orchestration!.execute({ + session.clientSessionApis.factory!.execute({ sessionId: session.sessionId, name: "throw-progress", runId: "run-throw-progress", args: {}, }) ).rejects.toThrow("body failed"); - expect(sendRequest).toHaveBeenCalledWith("session.orchestration.log", { + expect(sendRequest).toHaveBeenCalledWith("session.factory.log", { sessionId: session.sessionId, runId: "run-throw-progress", lines: [{ seq: 0, kind: "log", text: "before throw" }], }); }); - it("surfaces the per-run abort signal on the orchestration context", async () => { + it("surfaces the per-run abort signal on the factory context", async () => { const session = new CopilotSession("session-abort-signal", {} as never); const signalSeen = Promise.withResolvers(); - const orchestration = defineOrchestration({ + const factory = defineFactory({ meta: { name: "abort-signal", description: "Abort signal test", @@ -670,9 +670,9 @@ describe("orchestrations", () => { return signal.aborted; }, }); - session.registerOrchestrations([orchestration]); + session.registerFactories([factory]); - const execution = session.clientSessionApis.orchestration!.execute({ + const execution = session.clientSessionApis.factory!.execute({ sessionId: session.sessionId, name: "abort-signal", runId: "run-abort-signal", @@ -681,7 +681,7 @@ describe("orchestrations", () => { const signal = await signalSeen.promise; expect(signal.aborted).toBe(false); - await session.clientSessionApis.orchestration!.abort({ + await session.clientSessionApis.factory!.abort({ sessionId: session.sessionId, runId: "run-abort-signal", }); @@ -690,26 +690,26 @@ describe("orchestrations", () => { await expect(execution).resolves.toEqual({ result: true }); }); - it("rejects an in-flight runtime-backed await when orchestration.abort trips the signal", async () => { + it("rejects an in-flight runtime-backed await when factory.abort trips the signal", async () => { const agentResponse = Promise.withResolvers<{ result: string }>(); const sendRequest = vi.fn(async (method: string) => { - if (method === "session.orchestration.agent") { + if (method === "session.factory.agent") { return agentResponse.promise; } return {}; }); const session = new CopilotSession("session-abort-await", { sendRequest } as never); - const orchestration = defineOrchestration({ + const factory = defineFactory({ meta: { name: "abort-await", - description: "Abort an in-flight orchestration await", + description: "Abort an in-flight factory await", phases: [], }, run: async ({ agent }) => agent("wait forever"), }); - session.registerOrchestrations([orchestration]); + session.registerFactories([factory]); - const execution = session.clientSessionApis.orchestration!.execute({ + const execution = session.clientSessionApis.factory!.execute({ sessionId: session.sessionId, name: "abort-await", runId: "run-abort-await", @@ -717,12 +717,12 @@ describe("orchestrations", () => { }); await vi.waitFor(() => expect(sendRequest).toHaveBeenCalledWith( - "session.orchestration.agent", + "session.factory.agent", expect.anything() ) ); - await session.clientSessionApis.orchestration!.abort({ + await session.clientSessionApis.factory!.abort({ sessionId: session.sessionId, runId: "run-abort-await", }); @@ -731,24 +731,24 @@ describe("orchestrations", () => { agentResponse.resolve({ result: "late" }); }); - it("dispatches orchestration.execute to the registered orchestration selected by name", async () => { + it("dispatches factory.execute to the registered factory selected by name", async () => { const firstRun = vi.fn(async () => ({ selected: "first" })); const secondRun = vi.fn(async ({ args, log }) => { log("executing"); return { selected: "second", echoed: args }; }); - const firstOrchestration = defineOrchestration({ + const firstFactory = defineFactory({ meta: { name: "first", - description: "First orchestration", + description: "First factory", phases: [], }, run: firstRun, }); - const secondOrchestration = defineOrchestration({ + const secondFactory = defineFactory({ meta: { name: "second", - description: "Second orchestration", + description: "Second factory", phases: [], }, run: secondRun, @@ -756,10 +756,10 @@ describe("orchestrations", () => { const session = new CopilotSession("session-execute", { sendRequest: vi.fn(async () => ({})), } as never); - session.registerOrchestrations([firstOrchestration, secondOrchestration]); + session.registerFactories([firstFactory, secondFactory]); await expect( - session.clientSessionApis.orchestration!.execute({ + session.clientSessionApis.factory!.execute({ sessionId: session.sessionId, name: "second", runId: "run-echo", @@ -772,7 +772,7 @@ describe("orchestrations", () => { expect(secondRun).toHaveBeenCalledOnce(); const error = await session.clientSessionApis - .orchestration!.execute({ + .factory!.execute({ sessionId: session.sessionId, name: "missing", runId: "run-missing", @@ -781,13 +781,13 @@ describe("orchestrations", () => { .catch((caught: unknown) => caught); expect(error).toBeInstanceOf(ResponseError); expect((error as ResponseError<{ code: string; name: string }>).data).toEqual({ - code: "orchestration_not_found", + code: "factory_not_found", name: "missing", }); }); - it("runs orchestrations by name or handle and unwraps only foreground results", async () => { - const orchestration = defineOrchestration({ + it("runs factories by name or handle and unwraps only foreground results", async () => { + const factory = defineFactory({ meta: { name: "friendly-run", description: "Friendly run wrapper", @@ -810,28 +810,28 @@ describe("orchestrations", () => { ); const session = new CopilotSession("session-run", { sendRequest } as never); - await expect(session.orchestration.run("by-name", { args: { value: 1 } })).resolves.toEqual( + await expect(session.factory.run("by-name", { args: { value: 1 } })).resolves.toEqual( { name: "by-name", } ); - await expect(session.orchestration.run(orchestration)).resolves.toEqual({ + await expect(session.factory.run(factory)).resolves.toEqual({ name: "friendly-run", }); await expect( - session.orchestration.run("background", { background: true }) + session.factory.run("background", { background: true }) ).resolves.toEqual({ runId: "run-background", status: "running", }); - expect(sendRequest).toHaveBeenNthCalledWith(1, "session.orchestration.run", { + expect(sendRequest).toHaveBeenNthCalledWith(1, "session.factory.run", { sessionId: session.sessionId, name: "by-name", args: { value: 1 }, options: { background: undefined, resumeFromRunId: undefined }, }); - expect(sendRequest).toHaveBeenNthCalledWith(2, "session.orchestration.run", { + expect(sendRequest).toHaveBeenNthCalledWith(2, "session.factory.run", { sessionId: session.sessionId, name: "friendly-run", args: {}, @@ -839,19 +839,19 @@ describe("orchestrations", () => { }); }); - it("throws OrchestrationRunError with the full foreground envelope", async () => { + it("throws FactoryRunError with the full foreground envelope", async () => { const envelope = { runId: "run-error", status: "error" as const, - error: "orchestration failed", + error: "factory failed", snapshot: { completed: 1 }, }; const session = new CopilotSession("session-error", { sendRequest: vi.fn(async () => envelope), } as never); - const error = await session.orchestration.run("failing").catch((caught: unknown) => caught); - expect(error).toBeInstanceOf(OrchestrationRunError); - expect((error as OrchestrationRunError).envelope).toBe(envelope); + const error = await session.factory.run("failing").catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(FactoryRunError); + expect((error as FactoryRunError).envelope).toBe(envelope); }); }); From 8eaa7768400bf98ea6e1b98551282c7fdea22123 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Thu, 16 Jul 2026 20:00:55 -0700 Subject: [PATCH 16/22] CCR 2 Address Copilot review feedback on the agent factories PR (SDK side): - Reject a factory `timeout` limit above 2^31-1 ms in defineFactory's limit validation. Node clamps setTimeout delays above that to ~1ms, which would make a large declared timeout halt the run almost immediately. Adds a test. - Regenerate rpc.ts to drop the removed `parentRunId` field from FactoryExecuteRequest (factory nesting is forbidden; the field was never populated by the runtime). --- nodejs/src/factory.ts | 14 ++++++++++++++ nodejs/src/generated/rpc.ts | 4 ---- nodejs/test/factory.test.ts | 14 ++++++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/nodejs/src/factory.ts b/nodejs/src/factory.ts index bab22fedd4..71d742b61a 100644 --- a/nodejs/src/factory.ts +++ b/nodejs/src/factory.ts @@ -197,6 +197,13 @@ interface StoredFactory { const factoryHandles = new WeakMap(); +/** + * Maximum accepted factory timeout in milliseconds (2^31-1). Node clamps + * `setTimeout` delays above this to ~1ms, so a larger value would invert the + * declared timeout into an immediate halt. + */ +const MAX_FACTORY_TIMEOUT_MS = 2_147_483_647; + function validateLimits(meta: FactoryMeta): void { const limits = meta.limits; if (!limits) { @@ -213,6 +220,13 @@ function validateLimits(meta: FactoryMeta): void { if (limits.timeout !== undefined && (!Number.isFinite(limits.timeout) || limits.timeout <= 0)) { throw new Error('Factory limit "timeout" must be a positive number of milliseconds'); } + // Node clamps setTimeout delays above 2^31-1 ms to ~1ms, which would make a + // large timeout halt the run almost immediately. Reject it up front. + if (limits.timeout !== undefined && limits.timeout > MAX_FACTORY_TIMEOUT_MS) { + throw new Error( + `Factory limit "timeout" must not exceed ${MAX_FACTORY_TIMEOUT_MS} milliseconds (~24.8 days)` + ); + } } /** diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index c0702e5b5f..361e0f2592 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -4985,10 +4985,6 @@ export interface FactoryExecuteRequest { args: { [k: string]: unknown | undefined; }; - /** - * Parent factory run identifier for nested execution. - */ - parentRunId?: string; } /** * Result returned by an extension factory closure. diff --git a/nodejs/test/factory.test.ts b/nodejs/test/factory.test.ts index 77a3b5d429..52f536625e 100644 --- a/nodejs/test/factory.test.ts +++ b/nodejs/test/factory.test.ts @@ -76,6 +76,20 @@ describe("factories", () => { expect(() => defineFactory(definition)).toThrow(/must be a positive/); }); + it("rejects a timeout above the Node setTimeout ceiling", () => { + const definition = { + meta: { + name: "oversized-timeout", + description: "Factory with an out-of-range timeout", + phases: [], + limits: { timeout: 2_147_483_648 }, + }, + run: async () => null, + } as FactoryDefinition; + + expect(() => defineFactory(definition)).toThrow(/must not exceed/); + }); + it("serializes only factory metadata in the extension resume payload", async () => { const client = new CopilotClient(); await client.start(); From 611c7c165ff554ebba1afcd78d639ad8599642fe Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Thu, 16 Jul 2026 20:13:02 -0700 Subject: [PATCH 17/22] CCR 3 Fix prettier formatting on factory.ts, session.ts, and factory.test.ts (collapse multi-line signatures/expressions to Prettier print width). --- nodejs/src/factory.ts | 5 +---- nodejs/src/session.ts | 4 +--- nodejs/test/factory.test.ts | 21 ++++++--------------- 3 files changed, 8 insertions(+), 22 deletions(-) diff --git a/nodejs/src/factory.ts b/nodejs/src/factory.ts index 71d742b61a..127363fe2f 100644 --- a/nodejs/src/factory.ts +++ b/nodejs/src/factory.ts @@ -151,10 +151,7 @@ export interface SessionFactoryApi { name: string, options?: RunOptions & { background?: false } ): Promise; - run( - name: string, - options?: RunOptions - ): Promise; + run(name: string, options?: RunOptions): Promise; run( factory: FactoryHandle, options: RunOptions & { background: true } diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 2d3327e4ff..2c0e95b627 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -236,9 +236,7 @@ async function awaitFactoryOperation( throwIfFactoryAborted(signal); let rejectAbort: ((reason?: unknown) => void) | undefined; const onAbort = () => - rejectAbort?.( - signal.reason ?? new DOMException("Factory run was aborted", "AbortError") - ); + rejectAbort?.(signal.reason ?? new DOMException("Factory run was aborted", "AbortError")); try { return await Promise.race([ operation, diff --git a/nodejs/test/factory.test.ts b/nodejs/test/factory.test.ts index 52f536625e..1826b860b1 100644 --- a/nodejs/test/factory.test.ts +++ b/nodejs/test/factory.test.ts @@ -392,9 +392,7 @@ describe("factories", () => { expect(cachedProducerCalls).toBe(1); expect(failingProducerCalls).toBe(2); expect( - sendRequest.mock.calls.filter( - ([method]) => method === "session.factory.journal.put" - ) + sendRequest.mock.calls.filter(([method]) => method === "session.factory.journal.put") ).toHaveLength(2); }); @@ -730,10 +728,7 @@ describe("factories", () => { args: {}, }); await vi.waitFor(() => - expect(sendRequest).toHaveBeenCalledWith( - "session.factory.agent", - expect.anything() - ) + expect(sendRequest).toHaveBeenCalledWith("session.factory.agent", expect.anything()) ); await session.clientSessionApis.factory!.abort({ @@ -824,17 +819,13 @@ describe("factories", () => { ); const session = new CopilotSession("session-run", { sendRequest } as never); - await expect(session.factory.run("by-name", { args: { value: 1 } })).resolves.toEqual( - { - name: "by-name", - } - ); + await expect(session.factory.run("by-name", { args: { value: 1 } })).resolves.toEqual({ + name: "by-name", + }); await expect(session.factory.run(factory)).resolves.toEqual({ name: "friendly-run", }); - await expect( - session.factory.run("background", { background: true }) - ).resolves.toEqual({ + await expect(session.factory.run("background", { background: true })).resolves.toEqual({ runId: "run-background", status: "running", }); From 36baf441fb938366f5fe110e558b452fa8d95165 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Thu, 16 Jul 2026 20:28:53 -0700 Subject: [PATCH 18/22] CCR 4 Update joinSession onPermissionRequest tests to spy on resumeSessionForExtension (the method joinSession now calls to thread factories). The old spy on resumeSession no longer intercepts, so the real method ran and hung to a 30s timeout once the prettier gate stopped short-circuiting the suite. --- nodejs/test/extension.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/nodejs/test/extension.test.ts b/nodejs/test/extension.test.ts index 1baa83a3ab..e94ad2204f 100644 --- a/nodejs/test/extension.test.ts +++ b/nodejs/test/extension.test.ts @@ -18,13 +18,13 @@ describe("joinSession", () => { it("defaults onPermissionRequest to no-result", async () => { process.env.SESSION_ID = "session-123"; - const resumeSession = vi - .spyOn(CopilotClient.prototype, "resumeSession") + const resumeForExtension = vi + .spyOn(CopilotClient.prototype, "resumeSessionForExtension") .mockResolvedValue({} as any); await joinSession({ tools: [] }); - const [, config] = resumeSession.mock.calls[0]!; + const [, config] = resumeForExtension.mock.calls[0]!; expect(config.onPermissionRequest).toBeDefined(); expect(config.onPermissionRequest).toBe(defaultJoinSessionPermissionHandler); const result = await Promise.resolve( @@ -36,13 +36,13 @@ describe("joinSession", () => { it("preserves an explicit onPermissionRequest handler", async () => { process.env.SESSION_ID = "session-123"; - const resumeSession = vi - .spyOn(CopilotClient.prototype, "resumeSession") + const resumeForExtension = vi + .spyOn(CopilotClient.prototype, "resumeSessionForExtension") .mockResolvedValue({} as any); await joinSession({ onPermissionRequest: approveAll, suppressResumeEvent: false }); - const [, config] = resumeSession.mock.calls[0]!; + const [, config] = resumeForExtension.mock.calls[0]!; expect(config.onPermissionRequest).toBe(approveAll); expect(config.suppressResumeEvent).toBe(false); }); From 58a7f07e1fdb5a0028f5342fb381a1ae3233ed83 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Thu, 16 Jul 2026 21:48:29 -0700 Subject: [PATCH 19/22] CCR 5 Address CCR review feedback (clear/low-churn items): - Reject duplicate factory names in registerFactories so reverse dispatch is deterministic (silently overwriting a name left the runtime advertising two declarations for one closure). Adds a test. - Re-export FactoryRunResult from factory.ts, the package root, and the extension entry point so consumers can name the type returned by SessionFactoryApi methods and carried on FactoryRunError.envelope. --- nodejs/src/extension.ts | 1 + nodejs/src/factory.ts | 7 +++++++ nodejs/src/index.ts | 1 + nodejs/src/session.ts | 5 +++++ nodejs/test/factory.test.ts | 17 +++++++++++++++++ 5 files changed, 31 insertions(+) diff --git a/nodejs/src/extension.ts b/nodejs/src/extension.ts index 147aa9b0c9..8553d7d726 100644 --- a/nodejs/src/extension.ts +++ b/nodejs/src/extension.ts @@ -49,6 +49,7 @@ export { type FactoryJsonSchema, type FactoryPipelineStage, type FactoryStepOptions, + type FactoryRunResult, } from "./factory.js"; /** diff --git a/nodejs/src/factory.ts b/nodejs/src/factory.ts index 127363fe2f..8898fbbb1d 100644 --- a/nodejs/src/factory.ts +++ b/nodejs/src/factory.ts @@ -6,6 +6,13 @@ import type { FactoryRunResult } from "./generated/rpc.js"; import type { CopilotSession } from "./session.js"; import type { FactoryMeta } from "./types.js"; +/** + * The terminal envelope describing a factory run's outcome (status, result, + * reason). Re-exported so consumers can name the type returned by + * {@link SessionFactoryApi} methods and carried on {@link FactoryRunError}. + */ +export type { FactoryRunResult } from "./generated/rpc.js"; + declare const factoryHandleBrand: unique symbol; /** diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index a90ecc612f..16c512dffb 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -171,4 +171,5 @@ export type { FactoryJsonSchema, FactoryPipelineStage, FactoryStepOptions, + FactoryRunResult, } from "./factory.js"; diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 2c0e95b627..cf3de6f3d3 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -1094,6 +1094,11 @@ export class CopilotSession { for (const handle of factories) { const definition = getFactoryDefinition(handle); + if (this.factories.has(definition.meta.name)) { + throw new Error( + `Duplicate factory name "${definition.meta.name}". Factory names must be unique within a joinSession call.` + ); + } this.factories.set(definition.meta.name, definition); } diff --git a/nodejs/test/factory.test.ts b/nodejs/test/factory.test.ts index 1826b860b1..d44e095e1e 100644 --- a/nodejs/test/factory.test.ts +++ b/nodejs/test/factory.test.ts @@ -55,6 +55,23 @@ describe("factories", () => { expect(result).toEqual({ result: { value: 42 } }); }); + it("rejects duplicate factory names within a single registration", () => { + const run = async () => null; + const first = defineFactory({ + meta: { name: "dup", description: "first", phases: [] }, + run, + }); + const second = defineFactory({ + meta: { name: "dup", description: "second", phases: [] }, + run, + }); + + const session = new CopilotSession("session-dup", {} as never); + expect(() => session.registerFactories([first, second])).toThrow( + /Duplicate factory name "dup"/ + ); + }); + it.each([ ["maxConcurrentSubagents", 0], ["maxConcurrentSubagents", 1.5], From 754c28f187c9ea4106d99ed0ae7f055871bfe453 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Fri, 17 Jul 2026 00:20:34 -0700 Subject: [PATCH 20/22] Factories: Propagate cancellation out of parallel/pipeline (CR-5) Deviation-review CR-5 (SDK side): the parallel/pipeline combinators mapped every thunk/stage error to null, including the AbortError raised when a run is cancelled mid-agent(). That let an aborted run report success with null results instead of propagating cancellation. Re-throw abort-shaped failures from both combinators (via an isFactoryAbortError guard) so cancellation bubbles out; ordinary per-item failures still map to null. Adds a test that a parallel-wrapped aborted agent() call rejects with AbortError. --- nodejs/src/session.ts | 31 ++++++++++++++++++++++++++-- nodejs/test/factory.test.ts | 40 +++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index cf3de6f3d3..6e5fae21d8 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -134,7 +134,15 @@ async function runFactoryParallel( thunks.map((thunk) => Promise.resolve() .then(() => thunk()) - .catch(() => null) + .catch((error) => { + // Cancellation must propagate out of the combinator rather + // than be mapped to a successful `null`; otherwise an aborted + // run could be reported as completed. + if (isFactoryAbortError(error)) { + throw error; + } + return null; + }) ) ); } @@ -155,7 +163,12 @@ async function runFactoryPipeline( for (const stage of stages) { try { previous = await stage(previous, item, index); - } catch { + } catch (error) { + // Propagate cancellation instead of mapping it to `null`, so + // an aborted stage does not let the run report success. + if (isFactoryAbortError(error)) { + throw error; + } return null; } } @@ -256,6 +269,20 @@ function throwIfFactoryAborted(signal: AbortSignal): void { } } +/** + * Whether an error represents factory run cancellation (an `AbortError`-shaped + * rejection from {@link awaitFactoryOperation}). Cancellation must bubble out of + * `parallel`/`pipeline` rather than being flattened into a `null` result. + */ +function isFactoryAbortError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "name" in error && + (error as { name?: unknown }).name === "AbortError" + ); +} + /** Assistant message event - the final response from the assistant. */ export type AssistantMessageEvent = Extract; diff --git a/nodejs/test/factory.test.ts b/nodejs/test/factory.test.ts index d44e095e1e..1b05c88d9d 100644 --- a/nodejs/test/factory.test.ts +++ b/nodejs/test/factory.test.ts @@ -757,6 +757,46 @@ describe("factories", () => { agentResponse.resolve({ result: "late" }); }); + it("propagates cancellation out of parallel/pipeline instead of mapping it to null", async () => { + const agentResponse = Promise.withResolvers<{ result: string }>(); + const sendRequest = vi.fn(async (method: string) => { + if (method === "session.factory.agent") { + return agentResponse.promise; + } + return {}; + }); + const session = new CopilotSession("session-abort-parallel", { sendRequest } as never); + const factory = defineFactory({ + meta: { + name: "abort-parallel", + description: "Cancellation must bubble out of a combinator", + phases: [], + }, + // If the combinator swallowed the AbortError to null, this run would + // resolve successfully with [null] despite the run being cancelled. + run: async ({ agent, parallel }) => parallel([() => agent("wait forever")]), + }); + session.registerFactories([factory]); + + const execution = session.clientSessionApis.factory!.execute({ + sessionId: session.sessionId, + name: "abort-parallel", + runId: "run-abort-parallel", + args: {}, + }); + await vi.waitFor(() => + expect(sendRequest).toHaveBeenCalledWith("session.factory.agent", expect.anything()) + ); + + await session.clientSessionApis.factory!.abort({ + sessionId: session.sessionId, + runId: "run-abort-parallel", + }); + + await expect(execution).rejects.toMatchObject({ name: "AbortError" }); + agentResponse.resolve({ result: "late" }); + }); + it("dispatches factory.execute to the registered factory selected by name", async () => { const firstRun = vi.fn(async () => ({ selected: "first" })); const secondRun = vi.fn(async ({ args, log }) => { From 428ff9d4c6215273b2c497a7cc4cba450595df7a Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Fri, 17 Jul 2026 17:11:10 -0700 Subject: [PATCH 21/22] Shift 3 Phase 3 (SDK side): ad-hoc limits and resume consent. Adds limits to the public RunOptions (typed as the public FactoryLimits) and forwards it plus resumeFromRunId in the session.factory.run request copy; refactors validateFactoryLimits to take a limits object; regenerates the wire types (FactoryRunLimits, factory_resume_declined). Paired with runtime Shift 3. Verification (Node 22): npm run build (0); npm test -- factory (31). Ships-dark: generated types track the runtime schema; SDK CI codegen is expectedly red until the runtime publishes and the @github/copilot pin bumps. --- nodejs/src/factory.ts | 4 +- nodejs/src/generated/rpc.ts | 76 +++++++++++++++++++++++++++++++++++-- nodejs/src/session.ts | 1 + nodejs/test/factory.test.ts | 50 ++++++++++++++++++++++-- 4 files changed, 123 insertions(+), 8 deletions(-) diff --git a/nodejs/src/factory.ts b/nodejs/src/factory.ts index 8898fbbb1d..49e1409ded 100644 --- a/nodejs/src/factory.ts +++ b/nodejs/src/factory.ts @@ -4,7 +4,7 @@ import type { FactoryRunResult } from "./generated/rpc.js"; import type { CopilotSession } from "./session.js"; -import type { FactoryMeta } from "./types.js"; +import type { FactoryLimits, FactoryMeta } from "./types.js"; /** * The terminal envelope describing a factory run's outcome (status, result, @@ -142,6 +142,8 @@ export interface RunOptions { args?: TArgs; /** Return once the approved run starts instead of awaiting completion. */ background?: boolean; + /** Optional per-invocation resource ceiling overrides. */ + limits?: FactoryLimits; /** Prior run whose journal and progress should seed this run. */ resumeFromRunId?: string; } diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 361e0f2592..023cf0dd6c 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -521,6 +521,49 @@ export type FactoryLogLineKind = | "log" /** A named factory phase marker. */ | "phase"; +/** + * Machine-readable factory run failure. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "FactoryRunFailure". + */ +/** @experimental */ +export type FactoryRunFailure = + | { + kind: FactoryRunFailureKind; + /** + * Approved effective ceiling that was reached. + */ + value: number; + /** + * Factory run identifier. + */ + runId: string; + type: "factory_limit_reached"; + } + | { + /** + * Factory run identifier whose changed limits were declined. + */ + runId: string; + /** + * Human-readable reason the resume did not proceed. + */ + reason: string; + type: "factory_resume_declined"; + }; +/** + * Cumulative resource ceiling that stopped a factory run. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "FactoryRunFailureKind". + */ +/** @experimental */ +export type FactoryRunFailureKind = + /** The run admitted the approved maximum total number of subagents. */ + | "maxTotalSubagents" + /** The run reached the approved timeout deadline. */ + | "timeout"; /** * Current or terminal state of a factory run. * @@ -535,11 +578,11 @@ export type FactoryRunStatus = | "running" /** The run completed successfully. */ | "completed" - /** The run stopped after reaching a resource ceiling. */ + /** The run was interrupted while resource budget remained. */ | "halted" /** The run was cancelled before completion. */ | "cancelled" - /** The factory body failed. */ + /** The factory body failed or reached a cumulative resource ceiling. */ | "error"; /** * Content filtering mode to apply to all tools, or a map of tool name to content filtering mode. @@ -5108,6 +5151,27 @@ export interface FactoryLogRequest { */ lines: FactoryLogLine[]; } +/** + * Wire-only per-invocation factory resource ceiling overrides. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "FactoryRunLimits". + */ +/** @experimental */ +export interface FactoryRunLimits { + /** + * Maximum number of factory subagents that may run concurrently. + */ + maxConcurrentSubagents?: number; + /** + * Maximum total number of factory subagents that may be admitted. + */ + maxTotalSubagents?: number; + /** + * Factory active-run timeout in milliseconds. + */ + timeout?: number; +} /** * Parameters for invoking a registered factory. * @@ -5140,6 +5204,7 @@ export interface RunOptions { * Whether to return once the approved run starts instead of awaiting its terminal result. */ background?: boolean; + limits?: FactoryRunLimits; /** * Run identifier whose journal and progress should seed this resumed run. */ @@ -5168,12 +5233,13 @@ export interface FactoryRunResult { * Error message for an errored run. */ error?: string; + failure?: FactoryRunFailure; /** * Reason for a halted or cancelled run. */ reason?: string; /** - * Partial journal and progress snapshot for a halted or cancelled run. + * Partial journal and progress snapshot for a halted, cancelled, or errored run. */ snapshot?: { [k: string]: unknown | undefined; @@ -15694,6 +15760,10 @@ export interface UsageMetricsCodeChanges { export interface UsageMetricsModelMetric { requests: UsageMetricsModelMetricRequests; usage: UsageMetricsModelMetricUsage; + /** + * Latest known prompt-cache expiration for this model. A timestamp in the past indicates that the observed cache has expired. + */ + cacheExpiresAt?: string; /** * Accumulated nano-AI units cost for this model */ diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 6e5fae21d8..95cc09679e 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -368,6 +368,7 @@ export class CopilotSession { >[0]["args"], options: { background: options?.background, + limits: options?.limits, resumeFromRunId: options?.resumeFromRunId, }, }); diff --git a/nodejs/test/factory.test.ts b/nodejs/test/factory.test.ts index 1b05c88d9d..f4d4a16e99 100644 --- a/nodejs/test/factory.test.ts +++ b/nodejs/test/factory.test.ts @@ -864,7 +864,14 @@ describe("factories", () => { const sendRequest = vi.fn( async ( _method: string, - params: { name: string; options?: { background?: boolean } } + params: { + name: string; + options?: { + background?: boolean; + limits?: { maxTotalSubagents?: number }; + resumeFromRunId?: string; + }; + } ) => params.options?.background ? { runId: "run-background", status: "running" } @@ -876,7 +883,13 @@ describe("factories", () => { ); const session = new CopilotSession("session-run", { sendRequest } as never); - await expect(session.factory.run("by-name", { args: { value: 1 } })).resolves.toEqual({ + await expect( + session.factory.run("by-name", { + args: { value: 1 }, + limits: { maxTotalSubagents: 7 }, + resumeFromRunId: "run-prior", + }) + ).resolves.toEqual({ name: "by-name", }); await expect(session.factory.run(factory)).resolves.toEqual({ @@ -891,13 +904,17 @@ describe("factories", () => { sessionId: session.sessionId, name: "by-name", args: { value: 1 }, - options: { background: undefined, resumeFromRunId: undefined }, + options: { + background: undefined, + limits: { maxTotalSubagents: 7 }, + resumeFromRunId: "run-prior", + }, }); expect(sendRequest).toHaveBeenNthCalledWith(2, "session.factory.run", { sessionId: session.sessionId, name: "friendly-run", args: {}, - options: { background: undefined, resumeFromRunId: undefined }, + options: { background: undefined, limits: undefined, resumeFromRunId: undefined }, }); }); @@ -916,4 +933,29 @@ describe("factories", () => { expect(error).toBeInstanceOf(FactoryRunError); expect((error as FactoryRunError).envelope).toBe(envelope); }); + + it("preserves the typed resume-declined failure in FactoryRunError", async () => { + const envelope = { + runId: "run-declined", + status: "error" as const, + error: "Factory resume was declined", + failure: { + type: "factory_resume_declined" as const, + runId: "run-declined", + reason: "Factory execution was declined", + }, + }; + const session = new CopilotSession("session-declined", { + sendRequest: vi.fn(async () => envelope), + } as never); + + const error = await session.factory + .run("resumable", { + limits: { maxTotalSubagents: 5 }, + resumeFromRunId: "run-declined", + }) + .catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(FactoryRunError); + expect((error as FactoryRunError).envelope.failure).toEqual(envelope.failure); + }); }); From 372f7404a47327ea28d987cb221a7baca97d0b58 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Fri, 17 Jul 2026 17:57:26 -0700 Subject: [PATCH 22/22] Shift 4 Phase 4 (SDK side): remove background execution mode. Removes background from the public RunOptions, the run() overloads, and the session.factory.run request copy; regenerates the wire types. Paired with runtime Shift 4. Verification (Node 22): npm run build (0); factory tests (31). Ships-dark CI note applies. --- nodejs/src/factory.ts | 19 ++----------------- nodejs/src/generated/rpc.ts | 4 ---- nodejs/src/session.ts | 4 ---- nodejs/test/factory.test.ts | 24 +++++++----------------- 4 files changed, 9 insertions(+), 42 deletions(-) diff --git a/nodejs/src/factory.ts b/nodejs/src/factory.ts index 49e1409ded..99bba7fd41 100644 --- a/nodejs/src/factory.ts +++ b/nodejs/src/factory.ts @@ -140,8 +140,6 @@ export interface FactoryHandle { export interface RunOptions { /** Input surfaced as `context.args`. */ args?: TArgs; - /** Return once the approved run starts instead of awaiting completion. */ - background?: boolean; /** Optional per-invocation resource ceiling overrides. */ limits?: FactoryLimits; /** Prior run whose journal and progress should seed this run. */ @@ -155,24 +153,11 @@ export interface RunOptions { * change or be removed in future SDK or CLI releases. */ export interface SessionFactoryApi { - run(name: string, options: RunOptions & { background: true }): Promise; - run( - name: string, - options?: RunOptions & { background?: false } - ): Promise; - run(name: string, options?: RunOptions): Promise; - run( - factory: FactoryHandle, - options: RunOptions & { background: true } - ): Promise; - run( - factory: FactoryHandle, - options?: RunOptions & { background?: false } - ): Promise; + run(name: string, options?: RunOptions): Promise; run( factory: FactoryHandle, options?: RunOptions - ): Promise; + ): Promise; /** Read the latest durable envelope for a factory run. */ getRun(runId: string): Promise; /** Cancel a factory run and return its terminal envelope. */ diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 023cf0dd6c..82ca0ed60d 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -5200,10 +5200,6 @@ export interface FactoryRunRequest { */ /** @experimental */ export interface RunOptions { - /** - * Whether to return once the approved run starts instead of awaiting its terminal result. - */ - background?: boolean; limits?: FactoryRunLimits; /** * Run identifier whose journal and progress should seed this resumed run. diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 95cc09679e..0f346d5edd 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -367,15 +367,11 @@ export class CopilotSession { typeof this.rpc.factory.run >[0]["args"], options: { - background: options?.background, limits: options?.limits, resumeFromRunId: options?.resumeFromRunId, }, }); - if (options?.background) { - return envelope; - } if (envelope.status !== "completed") { throw new FactoryRunError(envelope); } diff --git a/nodejs/test/factory.test.ts b/nodejs/test/factory.test.ts index f4d4a16e99..686134b88d 100644 --- a/nodejs/test/factory.test.ts +++ b/nodejs/test/factory.test.ts @@ -852,7 +852,7 @@ describe("factories", () => { }); }); - it("runs factories by name or handle and unwraps only foreground results", async () => { + it("runs factories by name or handle and unwraps completed results", async () => { const factory = defineFactory({ meta: { name: "friendly-run", @@ -867,19 +867,15 @@ describe("factories", () => { params: { name: string; options?: { - background?: boolean; limits?: { maxTotalSubagents?: number }; resumeFromRunId?: string; }; } - ) => - params.options?.background - ? { runId: "run-background", status: "running" } - : { - runId: "run-foreground", - status: "completed", - result: { name: params.name }, - } + ) => ({ + runId: "run-foreground", + status: "completed", + result: { name: params.name }, + }) ); const session = new CopilotSession("session-run", { sendRequest } as never); @@ -895,17 +891,11 @@ describe("factories", () => { await expect(session.factory.run(factory)).resolves.toEqual({ name: "friendly-run", }); - await expect(session.factory.run("background", { background: true })).resolves.toEqual({ - runId: "run-background", - status: "running", - }); - expect(sendRequest).toHaveBeenNthCalledWith(1, "session.factory.run", { sessionId: session.sessionId, name: "by-name", args: { value: 1 }, options: { - background: undefined, limits: { maxTotalSubagents: 7 }, resumeFromRunId: "run-prior", }, @@ -914,7 +904,7 @@ describe("factories", () => { sessionId: session.sessionId, name: "friendly-run", args: {}, - options: { background: undefined, limits: undefined, resumeFromRunId: undefined }, + options: { limits: undefined, resumeFromRunId: undefined }, }); });