From 143b52dc3333bd11226f86c559ccd561c3b7a77f Mon Sep 17 00:00:00 2001 From: Jason Etcovitch Date: Mon, 11 May 2026 11:15:35 -0400 Subject: [PATCH 1/2] Add Node cloud session SDK API Move the Mission Control cloud task/client-control pieces into the Node SDK by adding cloud session creation, connection, task event polling, steering helpers, and public cloud session types. Require callers to pass explicit repository context or a repo-less owner instead of probing Git state in the SDK. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/README.md | 33 +++ nodejs/src/client.ts | 184 ++++++++++++ nodejs/src/cloud/cloudSession.ts | 348 +++++++++++++++++++++++ nodejs/src/cloud/missionControlClient.ts | 234 +++++++++++++++ nodejs/src/index.ts | 22 ++ nodejs/src/types.ts | 154 ++++++++++ nodejs/test/cloudSession.test.ts | 269 ++++++++++++++++++ 7 files changed, 1244 insertions(+) create mode 100644 nodejs/src/cloud/cloudSession.ts create mode 100644 nodejs/src/cloud/missionControlClient.ts create mode 100644 nodejs/test/cloudSession.test.ts diff --git a/nodejs/README.md b/nodejs/README.md index a8ada97ed..f33cd298c 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -128,6 +128,39 @@ Create a new conversation session. Resume an existing session. Returns the session with `workspacePath` populated if infinite sessions were enabled. +##### `createCloudSession(options?: CloudSessionOptions): Promise` + +Create a sandbox-backed cloud session through Mission Control and attach to it as a remote-control client. The agent runtime runs inside the provisioned sandbox; this SDK instance polls task events and sends prompts or prompt responses through Mission Control. + +```typescript +const client = new CopilotClient({ gitHubToken: process.env.GITHUB_TOKEN }); + +const session = await client.createCloudSession({ + repository: { owner: "github", name: "copilot-sdk", branch: "main" }, + onProgress: (event) => console.log(event.phase), +}); + +session.on("assistant.message", (event) => { + console.log(event.data.content); +}); + +await session.send({ prompt: "Summarize the project" }); +``` + +Cloud sessions are separate from the `remote` client option. `remote: true` exports a local runtime session to Mission Control; `createCloudSession` provisions a cloud sandbox and controls the runtime running there. + +Pass `repository` explicitly when the sandbox should be associated with a repository. For repo-less sandboxes, pass `owner` so Mission Control can bill and authorize the sandbox: + +```typescript +const session = await client.createCloudSession({ owner: "github" }); +``` + +For now, provide `gitHubToken`, `authToken`, or `COPILOT_MC_ACCESS_TOKEN` for Mission Control authentication. `missionControlBaseUrl`, `copilotApiBaseUrl`, `frontendBaseUrl`, and `pollIntervalMs` are available for enterprise hosts and tests. + +##### `connectCloudSession(taskOrSessionId: string, options?: CloudConnectOptions): Promise` + +Attach to an existing Mission Control cloud task and return the same remote-control `CloudSession` facade used by `createCloudSession`. + ##### `ping(message?: string): Promise<{ message: string; timestamp: number }>` Ping the server to check connectivity. diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 264e0a575..74808fdfa 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -31,6 +31,8 @@ import { createInternalServerRpc, registerClientSessionApiHandlers, } from "./generated/rpc.js"; +import { CloudSession } from "./cloud/cloudSession.js"; +import { MissionControlClient } from "./cloud/missionControlClient.js"; import { getSdkProtocolVersion } from "./sdkProtocolVersion.js"; import { CopilotSession, NO_RESULT_PERMISSION_V2_ERROR } from "./session.js"; import { createSessionFsAdapter } from "./sessionFsProvider.js"; @@ -38,6 +40,10 @@ import { getTraceContext } from "./telemetry.js"; import type { AutoModeSwitchRequest, AutoModeSwitchResponse, + CloudConnectOptions, + CloudRepository, + CloudSessionMetadata, + CloudSessionOptions, ConnectionState, CopilotClientOptions, ExitPlanModeRequest, @@ -45,6 +51,7 @@ import type { ForegroundSessionInfo, GetAuthStatusResponse, GetStatusResponse, + MissionControlTask, ModelInfo, ProviderConfig, ResumeSessionConfig, @@ -155,6 +162,15 @@ function getNodeExecPath(): string { return process.execPath; } +function stripTrailingSlash(value: string): string { + return value.replace(/\/+$/, ""); +} + +function normalizeToken(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + /** * Gets the path to the bundled CLI from the @github/copilot package. * Uses index.js directly rather than npm-loader.js (which spawns the native binary). @@ -436,6 +452,71 @@ export class CopilotClient { return { host, port }; } + private createMissionControlClient( + options: CloudSessionOptions | CloudConnectOptions + ): MissionControlClient { + const env = this.options.env; + const copilotApiBaseUrl = stripTrailingSlash( + options.copilotApiBaseUrl ?? + env.COPILOT_API_BASE_URL ?? + env.COPILOT_API_URL ?? + "https://api.githubcopilot.com" + ); + const baseUrl = + options.missionControlBaseUrl ?? + env.COPILOT_MC_BASE_URL ?? + `${copilotApiBaseUrl}/agents`; + const authToken = + normalizeToken(options.authToken) ?? + normalizeToken(env.COPILOT_MC_ACCESS_TOKEN) ?? + normalizeToken(this.options.gitHubToken); + const frontendBaseUrl = + options.frontendBaseUrl ?? env.COPILOT_MC_FRONTEND_URL ?? "https://github.com"; + + return new MissionControlClient({ + baseUrl, + authToken, + integrationId: options.integrationId, + frontendBaseUrl, + }); + } + + private createCloudSessionMetadata( + task: MissionControlTask, + mcClient: MissionControlClient, + repository?: CloudRepository, + owner?: string + ): CloudSessionMetadata { + return { + taskId: task.id, + missionControlSessionId: task.sessions?.at(-1)?.id, + frontendUrl: mcClient.getFrontendUrl(task.id), + owner, + repository, + createdAt: new Date(task.created_at), + updatedAt: new Date(task.updated_at), + state: task.state, + status: task.status, + }; + } + + private createFallbackCloudSessionMetadata( + taskId: string, + mcClient: MissionControlClient, + repository?: CloudRepository, + owner?: string + ): CloudSessionMetadata { + const now = new Date(); + return { + taskId, + frontendUrl: mcClient.getFrontendUrl(taskId), + owner, + repository, + createdAt: now, + updatedAt: now, + }; + } + private validateSessionFsConfig(config: SessionFsConfig): void { if (!config.initialCwd) { throw new Error("sessionFs.initialCwd is required"); @@ -1075,6 +1156,109 @@ export class CopilotClient { return result as GetAuthStatusResponse; } + /** + * Create a sandbox-backed cloud session through Mission Control and attach + * to it as a remote-control client. + * + * This does not create a local runtime session. The agent runs inside the + * provisioned cloud sandbox; this SDK instance polls Mission Control for + * events and sends user actions through the task steer API. + */ + async createCloudSession(options: CloudSessionOptions = {}): Promise { + const startedAt = Date.now(); + const mcClient = this.createMissionControlClient(options); + const owner = normalizeToken(options.owner); + const repository = options.repository; + + if (!repository && !owner) { + throw new Error("CloudSessionOptions.owner is required when repository is omitted"); + } + + options.onProgress?.({ phase: "creating_task", elapsedMs: 0 }); + options.onProgress?.({ + phase: "provisioning_sandbox", + elapsedMs: Date.now() - startedAt, + }); + const task = await mcClient.createCloudTask({ + owner, + repository: repository ? { owner: repository.owner, name: repository.name } : undefined, + }); + options.onCloudTaskCreated?.(task); + + options.onProgress?.({ + phase: "waiting_for_session", + elapsedMs: Date.now() - startedAt, + taskId: task.id, + }); + + const session = new CloudSession({ + client: mcClient, + metadata: this.createCloudSessionMetadata(task, mcClient, repository, owner), + pollIntervalMs: options.pollIntervalMs, + initialEventTimeoutMs: options.initialEventTimeoutMs, + initialEventPollIntervalMs: options.initialEventPollIntervalMs, + onEventPollError: options.onEventPollError, + }); + await session.connect(); + + options.onProgress?.({ + phase: "connected", + elapsedMs: Date.now() - startedAt, + taskId: task.id, + }); + + return session; + } + + /** + * Attach to an existing Mission Control cloud task as a remote-control client. + * + * The identifier is treated as a task ID. If Mission Control can return task + * metadata, it is used to populate the session metadata; otherwise the SDK + * still attaches by polling task events for the provided identifier. + */ + async connectCloudSession( + taskOrSessionId: string, + options: CloudConnectOptions = {} + ): Promise { + const startedAt = Date.now(); + const mcClient = this.createMissionControlClient(options); + options.onProgress?.({ + phase: "waiting_for_session", + elapsedMs: 0, + taskId: taskOrSessionId, + }); + + const task = await mcClient.getTask(taskOrSessionId); + const owner = normalizeToken(options.owner); + const metadata = task + ? this.createCloudSessionMetadata(task, mcClient, options.repository, owner) + : this.createFallbackCloudSessionMetadata( + taskOrSessionId, + mcClient, + options.repository, + owner + ); + + const session = new CloudSession({ + client: mcClient, + metadata, + pollIntervalMs: options.pollIntervalMs, + initialEventTimeoutMs: options.initialEventTimeoutMs, + initialEventPollIntervalMs: options.initialEventPollIntervalMs, + onEventPollError: options.onEventPollError, + }); + await session.connect(); + + options.onProgress?.({ + phase: "connected", + elapsedMs: Date.now() - startedAt, + taskId: metadata.taskId, + }); + + return session; + } + /** * List available models with their metadata. * diff --git a/nodejs/src/cloud/cloudSession.ts b/nodejs/src/cloud/cloudSession.ts new file mode 100644 index 000000000..2fb881fcb --- /dev/null +++ b/nodejs/src/cloud/cloudSession.ts @@ -0,0 +1,348 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import type { AssistantMessageEvent } from "../session.js"; +import type { + CloudAskUserResponsePayload, + CloudElicitationResponsePayload, + CloudModeSwitchPayload, + CloudPermissionResponsePayload, + CloudPlanApprovalResponsePayload, + CloudSessionEvent, + CloudSessionEventHandler, + CloudSessionEventPayload, + CloudSessionEventType, + CloudSessionMetadata, + ElicitationResult, + ExitPlanModeResult, + MessageOptions, + MissionControlCommandType, + TypedCloudSessionEventHandler, +} from "../types.js"; +import { MissionControlCommandType as CommandType } from "../types.js"; +import type { MissionControlClient } from "./missionControlClient.js"; + +const DEFAULT_POLL_INTERVAL_MS = 5_000; +const DEFAULT_INITIAL_EVENT_TIMEOUT_MS = 10_000; +const DEFAULT_INITIAL_EVENT_POLL_INTERVAL_MS = 500; + +export interface CloudSessionCreateOptions { + client: MissionControlClient; + metadata: CloudSessionMetadata; + pollIntervalMs?: number; + initialEventTimeoutMs?: number; + initialEventPollIntervalMs?: number; + onEventPollError?: (error: Error) => void; +} + +export class CloudSession { + private readonly client: MissionControlClient; + private readonly pollIntervalMs: number; + private readonly initialEventTimeoutMs: number; + private readonly initialEventPollIntervalMs: number; + private readonly onEventPollError?: (error: Error) => void; + private readonly eventHandlers = new Set(); + private readonly typedEventHandlers = new Map< + CloudSessionEventType, + Set<(event: CloudSessionEvent) => void> + >(); + private readonly events: CloudSessionEvent[] = []; + private seenEventIds = new Set(); + private seenEventIdsAtLastTimestamp = new Set(); + private lastSeenTimestamp?: string; + private eventPoller: ReturnType | undefined; + private isPolling = false; + private isDisconnected = false; + private remoteSteerable = true; + + readonly sessionId: string; + readonly metadata: CloudSessionMetadata; + + constructor(options: CloudSessionCreateOptions) { + this.client = options.client; + this.metadata = options.metadata; + this.sessionId = options.metadata.missionControlSessionId ?? options.metadata.taskId; + this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + this.initialEventTimeoutMs = + options.initialEventTimeoutMs ?? DEFAULT_INITIAL_EVENT_TIMEOUT_MS; + this.initialEventPollIntervalMs = + options.initialEventPollIntervalMs ?? DEFAULT_INITIAL_EVENT_POLL_INTERVAL_MS; + this.onEventPollError = options.onEventPollError; + } + + async connect(): Promise { + const initialEvents = await this.waitForInitialEvents(); + this.recordEvents(initialEvents); + this.startEventPolling(); + } + + on( + eventType: K, + handler: TypedCloudSessionEventHandler + ): () => void; + on(handler: CloudSessionEventHandler): () => void; + on( + eventTypeOrHandler: K | CloudSessionEventHandler, + handler?: TypedCloudSessionEventHandler + ): () => void { + if (typeof eventTypeOrHandler === "string" && handler) { + const eventType = eventTypeOrHandler; + if (!this.typedEventHandlers.has(eventType)) { + this.typedEventHandlers.set(eventType, new Set()); + } + const storedHandler = handler as (event: CloudSessionEvent) => void; + this.typedEventHandlers.get(eventType)!.add(storedHandler); + return () => { + this.typedEventHandlers.get(eventType)?.delete(storedHandler); + }; + } + + const wildcardHandler = eventTypeOrHandler as CloudSessionEventHandler; + this.eventHandlers.add(wildcardHandler); + return () => { + this.eventHandlers.delete(wildcardHandler); + }; + } + + async send(options: MessageOptions): Promise { + this.assertConnected(); + await this.submitRemoteCommand(CommandType.UserMessage, options.prompt); + } + + async sendAndWait( + options: MessageOptions, + timeout?: number + ): Promise { + const effectiveTimeout = timeout ?? 60_000; + let lastAssistantMessage: AssistantMessageEvent | undefined; + let timeoutId: ReturnType | undefined; + let unsubscribe: (() => void) | undefined; + + const idlePromise = new Promise((resolve, reject) => { + unsubscribe = this.on((event) => { + if (event.type === "assistant.message") { + lastAssistantMessage = event as AssistantMessageEvent; + } else if (event.type === "session.idle") { + resolve(); + } else if (event.type === "session.error") { + reject(new Error(event.data.message)); + } + }); + }); + + try { + await this.send(options); + await Promise.race([ + idlePromise, + new Promise((_, reject) => { + timeoutId = setTimeout( + () => + reject( + new Error( + `Timeout after ${effectiveTimeout}ms waiting for session.idle` + ) + ), + effectiveTimeout + ); + }), + ]); + return lastAssistantMessage; + } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + unsubscribe?.(); + } + } + + async abort(): Promise { + this.assertConnected(); + await this.submitRemoteCommand(CommandType.Abort); + } + + async submitRemoteCommand(type: MissionControlCommandType, content?: string): Promise { + this.assertConnected(); + if (!this.remoteSteerable) { + throw new Error("This session is read-only — remote steering is not enabled"); + } + await this.client.steerTask(this.metadata.taskId, { type, content }); + } + + async respondToPermission(payload: CloudPermissionResponsePayload): Promise { + await this.submitRemoteCommand(CommandType.PermissionResponse, JSON.stringify(payload)); + } + + async respondToAskUser(payload: CloudAskUserResponsePayload): Promise { + await this.submitRemoteCommand(CommandType.AskUserResponse, JSON.stringify(payload)); + } + + async respondToElicitation(payload: CloudElicitationResponsePayload): Promise { + await this.submitRemoteCommand(CommandType.ElicitationResponse, JSON.stringify(payload)); + } + + async respondToExitPlanMode(payload: CloudPlanApprovalResponsePayload): Promise { + await this.submitRemoteCommand(CommandType.PlanApprovalResponse, JSON.stringify(payload)); + } + + async switchMode(payload: CloudModeSwitchPayload): Promise { + await this.submitRemoteCommand(CommandType.ModeSwitch, JSON.stringify(payload)); + } + + async respondToElicitationResult(promptId: string, result: ElicitationResult): Promise { + await this.respondToElicitation({ promptId, ...result }); + } + + async respondToPlanApproval(promptId: string, result: ExitPlanModeResult): Promise { + await this.respondToExitPlanMode({ promptId, ...result }); + } + + getMessages(): CloudSessionEvent[] { + return [...this.events]; + } + + async disconnect(): Promise { + this.stopEventPolling(); + this.eventHandlers.clear(); + this.typedEventHandlers.clear(); + this.isDisconnected = true; + } + + async destroy(): Promise { + return this.disconnect(); + } + + async [Symbol.asyncDispose](): Promise { + return this.disconnect(); + } + + startEventPolling(): void { + if (this.eventPoller || this.isDisconnected) { + return; + } + + this.eventPoller = setInterval(() => { + this.pollEvents().catch((error) => this.reportPollError(error)); + }, this.pollIntervalMs); + this.eventPoller.unref?.(); + } + + stopEventPolling(): void { + if (this.eventPoller) { + clearInterval(this.eventPoller); + this.eventPoller = undefined; + } + } + + private async waitForInitialEvents(): Promise { + const deadline = Date.now() + this.initialEventTimeoutMs; + while (true) { + const events = await this.client.listTaskEvents(this.metadata.taskId); + if (events.length > 0) { + return CloudSession.sortEventsChronologically(events); + } + if (this.initialEventTimeoutMs <= 0 || Date.now() >= deadline) { + return []; + } + await sleep(this.initialEventPollIntervalMs); + } + } + + private async pollEvents(): Promise { + if (this.isPolling || this.isDisconnected) { + return; + } + + this.isPolling = true; + try { + const events = await this.client.listTaskEvents(this.metadata.taskId); + const newEvents = this.collectNewEvents(events); + this.recordEvents(newEvents); + } finally { + this.isPolling = false; + } + } + + private collectNewEvents(events: CloudSessionEvent[]): CloudSessionEvent[] { + const newEvents = events.filter((event) => { + if (this.seenEventIds.has(event.id)) return false; + if (!this.lastSeenTimestamp) return true; + const order = event.timestamp.localeCompare(this.lastSeenTimestamp); + if (order > 0) return true; + if (order < 0) return false; + return !this.seenEventIdsAtLastTimestamp.has(event.id); + }); + + return CloudSession.sortEventsChronologically(newEvents); + } + + private recordEvents(events: CloudSessionEvent[]): void { + for (const event of CloudSession.sortEventsChronologically(events)) { + if (this.seenEventIds.has(event.id)) continue; + this.seenEventIds.add(event.id); + this.events.push(event); + this.markEventAsSeenAtTimestamp(event); + this.updateRemoteSteerable(event); + this.dispatchEvent(event); + } + } + + private markEventAsSeenAtTimestamp(event: CloudSessionEvent): void { + if (this.lastSeenTimestamp !== event.timestamp) { + this.lastSeenTimestamp = event.timestamp; + this.seenEventIdsAtLastTimestamp = new Set(); + } + this.seenEventIdsAtLastTimestamp.add(event.id); + } + + private updateRemoteSteerable(event: CloudSessionEvent): void { + if (event.type === "session.remote_steerable_changed") { + this.remoteSteerable = event.data.remoteSteerable; + } + } + + private dispatchEvent(event: CloudSessionEvent): void { + const typedHandlers = this.typedEventHandlers.get(event.type); + if (typedHandlers) { + for (const handler of typedHandlers) { + try { + handler(event as CloudSessionEventPayload); + } catch { + // Keep one failing handler from stopping event polling. + } + } + } + + for (const handler of this.eventHandlers) { + try { + handler(event); + } catch { + // Keep one failing handler from stopping event polling. + } + } + } + + private reportPollError(error: unknown): void { + const normalized = error instanceof Error ? error : new Error(String(error)); + if (this.onEventPollError) { + this.onEventPollError(normalized); + } + } + + private assertConnected(): void { + if (this.isDisconnected) { + throw new Error("Cloud session is disconnected"); + } + } + + private static sortEventsChronologically(events: CloudSessionEvent[]): CloudSessionEvent[] { + return [...events].sort( + (left, right) => + left.timestamp.localeCompare(right.timestamp) || left.id.localeCompare(right.id) + ); + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/nodejs/src/cloud/missionControlClient.ts b/nodejs/src/cloud/missionControlClient.ts new file mode 100644 index 000000000..2655d04c6 --- /dev/null +++ b/nodejs/src/cloud/missionControlClient.ts @@ -0,0 +1,234 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import type { + CloudSessionEvent, + CloudSessionFailureReason, + MissionControlCommandType, + MissionControlTask, +} from "../types.js"; + +export const CLOUD_SANDBOX_AGENT_SLUG = "copilot-developer-sandbox"; + +const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; +const DEFAULT_CREATE_CLOUD_TASK_TIMEOUT_MS = 10 * 60 * 1000; + +export interface MissionControlClientOptions { + baseUrl: string; + authToken?: string; + integrationId?: string; + frontendBaseUrl: string; + requestTimeoutMs?: number; + createCloudTaskTimeoutMs?: number; +} + +export interface CreateCloudTaskRepository { + owner: string; + name: string; +} + +export interface CreateCloudTaskParams { + owner?: string; + repository?: CreateCloudTaskRepository; +} + +export class CloudSessionError extends Error { + constructor( + message: string, + public readonly reason: CloudSessionFailureReason, + public readonly status?: number + ) { + super(message); + this.name = "CloudSessionError"; + } +} + +export class MissionControlClient { + private readonly baseUrl: string; + private readonly authToken?: string; + private readonly integrationId: string; + private readonly frontendBaseUrl: string; + private readonly requestTimeoutMs: number; + private readonly createCloudTaskTimeoutMs: number; + + constructor(options: MissionControlClientOptions) { + this.baseUrl = options.baseUrl.replace(/\/+$/, ""); + this.authToken = options.authToken?.trim() || undefined; + this.integrationId = options.integrationId ?? "copilot-cli"; + this.frontendBaseUrl = options.frontendBaseUrl.replace(/\/+$/, ""); + this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + this.createCloudTaskTimeoutMs = + options.createCloudTaskTimeoutMs ?? DEFAULT_CREATE_CLOUD_TASK_TIMEOUT_MS; + } + + async createCloudTask(params: CreateCloudTaskParams = {}): Promise { + const body: Record = {}; + if (params.owner) { + body.owner = params.owner; + } + if (params.repository) { + body.repositories = [params.repository]; + } + + return this.requestJson( + `${this.baseUrl}/tasks`, + { + method: "POST", + headers: this.headers({ "X-Copilot-Agent-Slug": CLOUD_SANDBOX_AGENT_SLUG }), + body: JSON.stringify(body), + }, + this.createCloudTaskTimeoutMs + ); + } + + async listTaskEvents(taskId: string): Promise { + const data = await this.requestJson<{ events?: unknown[] }>( + `${this.baseUrl}/tasks/${encodeURIComponent(taskId)}/events`, + { + method: "GET", + headers: this.headers(), + }, + this.requestTimeoutMs + ); + + if (!Array.isArray(data.events)) { + throw new CloudSessionError( + `Unexpected Mission Control events response for task ${taskId}`, + "server" + ); + } + + return data.events.filter(isCloudSessionEvent); + } + + async steerTask( + taskId: string, + request: { type: MissionControlCommandType; content?: string } + ): Promise { + await this.requestOk( + `${this.baseUrl}/tasks/${encodeURIComponent(taskId)}/steer`, + { + method: "POST", + headers: this.headers(), + body: JSON.stringify(request), + }, + this.requestTimeoutMs + ); + } + + async getTask(taskId: string): Promise { + try { + return await this.requestJson( + `${this.baseUrl}/tasks/${encodeURIComponent(taskId)}`, + { + method: "GET", + headers: this.headers(), + }, + this.requestTimeoutMs + ); + } catch (error) { + if (error instanceof CloudSessionError && error.status === 404) { + return undefined; + } + throw error; + } + } + + getFrontendUrl(taskId: string): string { + return `${this.frontendBaseUrl}/copilot/tasks/${encodeURIComponent(taskId)}`; + } + + private headers(extraHeaders?: Record): Record { + const headers: Record = { + "Content-Type": "application/json", + "Copilot-Integration-Id": this.integrationId, + ...extraHeaders, + }; + if (this.authToken) { + headers.Authorization = `Bearer ${this.authToken}`; + } + return headers; + } + + private async requestJson(url: string, init: RequestInit, timeoutMs: number): Promise { + const response = await this.requestOk(url, init, timeoutMs); + const text = await response.text(); + if (!text) { + return undefined as T; + } + try { + return JSON.parse(text) as T; + } catch (error) { + throw new CloudSessionError( + `Mission Control returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`, + "server" + ); + } + } + + private async requestOk(url: string, init: RequestInit, timeoutMs: number): Promise { + try { + const response = await fetch(url, { + ...init, + signal: AbortSignal.timeout(timeoutMs), + }); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new CloudSessionError( + extractMissionControlMessage(text) || + `Mission Control request failed with HTTP ${response.status}`, + reasonForStatus(response.status), + response.status + ); + } + + return response; + } catch (error) { + if (error instanceof CloudSessionError) { + throw error; + } + if (isAbortError(error)) { + throw new CloudSessionError("Mission Control request timed out", "timeout"); + } + throw new CloudSessionError( + `Mission Control request failed: ${error instanceof Error ? error.message : String(error)}`, + "network" + ); + } + } +} + +function reasonForStatus(status: number): CloudSessionFailureReason { + if (status === 403) return "policy_blocked"; + if (status === 400 || status === 422) return "validation"; + return "server"; +} + +function extractMissionControlMessage(text: string): string | undefined { + if (!text) return undefined; + try { + const parsed = JSON.parse(text) as { message?: unknown }; + if (typeof parsed.message === "string" && parsed.message.length > 0) { + return parsed.message; + } + } catch { + // Non-JSON responses are surfaced as-is below. + } + return text; +} + +function isAbortError(error: unknown): boolean { + return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError"); +} + +function isCloudSessionEvent(value: unknown): value is CloudSessionEvent { + if (!value || typeof value !== "object") return false; + const event = value as { id?: unknown; timestamp?: unknown; type?: unknown }; + return ( + typeof event.id === "string" && + typeof event.timestamp === "string" && + typeof event.type === "string" + ); +} diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 0c6b25ecd..bffc0cc8b 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -10,14 +10,34 @@ export { CopilotClient } from "./client.js"; export { CopilotSession, type AssistantMessageEvent } from "./session.js"; +export { CloudSession } from "./cloud/cloudSession.js"; +export { CloudSessionError } from "./cloud/missionControlClient.js"; export { defineTool, approveAll, convertMcpCallToolResult, createSessionFsAdapter, SYSTEM_PROMPT_SECTIONS, + MissionControlCommandType, } from "./types.js"; export type { + CloudAskUserResponsePayload, + CloudConnectOptions, + CloudElicitationResponsePayload, + CloudModeSwitchPayload, + CloudPermissionResponsePayload, + CloudPlanApprovalResponsePayload, + CloudProgressEvent, + CloudProgressPhase, + CloudRepository, + CloudSessionEvent, + CloudSessionEventHandler, + CloudSessionEventPayload, + CloudSessionEventType, + CloudSessionFailureReason, + CloudSessionMetadata, + CloudSessionOptions, + CloudSessionRequestedEvent, CommandContext, CommandDefinition, CommandHandler, @@ -47,6 +67,8 @@ export type { MCPServerConfig, DefaultAgentConfig, MessageOptions, + MissionControlTask, + MissionControlTaskSession, ModelBilling, ModelCapabilities, ModelCapabilitiesOverride, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 7b9348df0..971969c24 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -15,6 +15,160 @@ export type { SessionFsProvider } from "./sessionFsProvider.js"; export { createSessionFsAdapter } from "./sessionFsProvider.js"; export type { SessionFsFileInfo } from "./sessionFsProvider.js"; +/** + * Repository context used when creating a cloud sandbox task. + */ +export interface CloudRepository { + owner: string; + name: string; + branch?: string; +} + +/** + * Progress phases emitted while creating or attaching to a cloud sandbox session. + */ +export type CloudProgressPhase = + | "creating_task" + | "provisioning_sandbox" + | "waiting_for_session" + | "connected"; + +export interface CloudProgressEvent { + phase: CloudProgressPhase; + elapsedMs?: number; + taskId?: string; +} + +export type CloudSessionFailureReason = + | "policy_blocked" + | "validation" + | "timeout" + | "network" + | "server"; + +export interface MissionControlTaskSession { + id: string; + task_id: string; + agent_task_id?: string; + state: string; + created_at: string; + updated_at: string; + name?: string; + owner_id: number; + repo_id?: number | null; +} + +export interface MissionControlTask { + id: string; + name: string; + state: string; + status: string; + creator_id: number; + owner_id: number; + repo_id?: number | null; + session_count: number; + created_at: string; + updated_at: string; + sessions?: MissionControlTaskSession[]; +} + +export interface CloudSessionMetadata { + taskId: string; + missionControlSessionId?: string; + frontendUrl: string; + owner?: string; + repository?: CloudRepository; + createdAt: Date; + updatedAt: Date; + state?: string; + status?: string; +} + +export interface CloudSessionRequestedEvent { + data?: Record; + ephemeral?: boolean; + id: string; + parentId: string | null; + timestamp: string; + type: "session.requested"; +} + +export type CloudSessionEvent = SessionEvent | CloudSessionRequestedEvent; +export type CloudSessionEventType = CloudSessionEvent["type"]; +export type CloudSessionEventPayload = Extract< + CloudSessionEvent, + { type: T } +>; +export type TypedCloudSessionEventHandler = ( + event: CloudSessionEventPayload +) => void; +export type CloudSessionEventHandler = (event: CloudSessionEvent) => void; + +export enum MissionControlCommandType { + UserMessage = "user_message", + AskUserResponse = "ask_user_response", + PlanApprovalResponse = "plan_approval_response", + PermissionResponse = "permission_response", + ElicitationResponse = "elicitation_response", + Abort = "abort", + ModeSwitch = "mode_switch", +} + +export interface CloudAskUserResponsePayload { + promptId: string; + answer: string; + wasFreeform: boolean; + dismissed?: boolean; +} + +export interface CloudPlanApprovalResponsePayload { + promptId: string; + approved: boolean; + selectedAction?: string; + autoApproveEdits?: boolean; + feedback?: string; +} + +export interface CloudPermissionResponsePayload { + promptId: string; + approved: boolean; + scope: "once" | "session"; +} + +export interface CloudElicitationResponsePayload { + promptId: string; + action: "accept" | "decline" | "cancel"; + content?: Record; +} + +export interface CloudModeSwitchPayload { + mode: "interactive" | "plan" | "autopilot"; +} + +export interface CloudSessionOptions { + /** + * Billing/authorization owner for repo-less cloud sandboxes. + * Required when repository is omitted. + */ + owner?: string; + repository?: CloudRepository; + missionControlBaseUrl?: string; + copilotApiBaseUrl?: string; + frontendBaseUrl?: string; + authToken?: string; + integrationId?: string; + pollIntervalMs?: number; + initialEventTimeoutMs?: number; + initialEventPollIntervalMs?: number; + onProgress?: (event: CloudProgressEvent) => void; + onCloudTaskCreated?: (task: MissionControlTask) => void; + onEventPollError?: (error: Error) => void; +} + +export interface CloudConnectOptions extends Omit { + repository?: CloudRepository; +} + /** * Options for creating a CopilotClient */ diff --git a/nodejs/test/cloudSession.test.ts b/nodejs/test/cloudSession.test.ts new file mode 100644 index 000000000..17f9677d9 --- /dev/null +++ b/nodejs/test/cloudSession.test.ts @@ -0,0 +1,269 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + CloudSessionError, + CopilotClient, + MissionControlCommandType, + type CloudSessionEvent, + type MissionControlTask, +} from "../src/index.js"; + +const task: MissionControlTask = { + id: "task-1", + name: "Cloud task", + state: "running", + status: "ready", + creator_id: 1, + owner_id: 2, + repo_id: 3, + session_count: 1, + created_at: "2026-05-11T10:00:00.000Z", + updated_at: "2026-05-11T10:01:00.000Z", + sessions: [ + { + id: "mc-session-1", + task_id: "task-1", + state: "running", + created_at: "2026-05-11T10:00:30.000Z", + updated_at: "2026-05-11T10:00:30.000Z", + owner_id: 2, + repo_id: 3, + }, + ], +}; + +const requestedEvent: CloudSessionEvent = { + id: "event-1", + parentId: null, + timestamp: "2026-05-11T10:00:00.000Z", + type: "session.requested", +}; + +const idleEvent: CloudSessionEvent = { + id: "event-2", + parentId: "event-1", + timestamp: "2026-05-11T10:00:01.000Z", + type: "session.idle", + data: {}, +}; + +describe("Cloud sessions", () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("creates a Mission Control cloud task and attaches to task events", async () => { + const fetchMock = mockFetch([ + jsonResponse(task), + jsonResponse({ events: [requestedEvent] }), + ]); + const progress: string[] = []; + const client = new CopilotClient({ + autoStart: false, + gitHubToken: "token-1", + env: { + COPILOT_MC_BASE_URL: "https://mc.test/agents", + COPILOT_MC_FRONTEND_URL: "https://github.test", + }, + }); + + const session = await client.createCloudSession({ + repository: { owner: "github", name: "copilot-sdk", branch: "main" }, + initialEventTimeoutMs: 0, + onProgress: (event) => progress.push(event.phase), + }); + + expect(session.metadata).toMatchObject({ + taskId: "task-1", + missionControlSessionId: "mc-session-1", + frontendUrl: "https://github.test/copilot/tasks/task-1", + repository: { owner: "github", name: "copilot-sdk", branch: "main" }, + state: "running", + status: "ready", + }); + expect(session.getMessages()).toEqual([requestedEvent]); + expect(progress).toEqual([ + "creating_task", + "provisioning_sandbox", + "waiting_for_session", + "connected", + ]); + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "https://mc.test/agents/tasks", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + Authorization: "Bearer token-1", + "X-Copilot-Agent-Slug": "copilot-developer-sandbox", + }), + body: JSON.stringify({ repositories: [{ owner: "github", name: "copilot-sdk" }] }), + }) + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "https://mc.test/agents/tasks/task-1/events", + expect.objectContaining({ method: "GET" }) + ); + + await session.disconnect(); + }); + + it("creates a repo-less cloud task when owner is provided", async () => { + const fetchMock = mockFetch([jsonResponse(task), jsonResponse({ events: [] })]); + const client = new CopilotClient({ + autoStart: false, + env: { COPILOT_MC_BASE_URL: "https://mc.test/agents" }, + }); + + const session = await client.createCloudSession({ + owner: "github", + initialEventTimeoutMs: 0, + }); + + expect(session.metadata.owner).toBe("github"); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "https://mc.test/agents/tasks", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ owner: "github" }), + }) + ); + + await session.disconnect(); + }); + + it("requires an owner when creating a repo-less cloud task", async () => { + const fetchMock = mockFetch([]); + const client = new CopilotClient({ + autoStart: false, + env: { COPILOT_MC_BASE_URL: "https://mc.test/agents" }, + }); + + await expect(client.createCloudSession({ initialEventTimeoutMs: 0 })).rejects.toThrow( + "CloudSessionOptions.owner is required when repository is omitted" + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("sends cloud session user messages through the Mission Control steer API", async () => { + const fetchMock = mockFetch([ + textResponse("", { status: 404 }), + jsonResponse({ events: [] }), + textResponse("", { status: 202 }), + ]); + const client = new CopilotClient({ + autoStart: false, + env: { COPILOT_MC_BASE_URL: "https://mc.test/agents" }, + }); + + const session = await client.connectCloudSession("task-1", { + initialEventTimeoutMs: 0, + }); + await session.send({ prompt: "hello cloud" }); + + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + "https://mc.test/agents/tasks/task-1/steer", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + type: MissionControlCommandType.UserMessage, + content: "hello cloud", + }), + }) + ); + + await session.disconnect(); + }); + + it("sorts replayed events and deduplicates events observed during polling", async () => { + vi.useFakeTimers(); + const polledEvent: CloudSessionEvent = { + id: "event-3", + parentId: "event-2", + timestamp: "2026-05-11T10:00:02.000Z", + type: "session.idle", + data: {}, + }; + mockFetch([ + jsonResponse(task), + jsonResponse({ events: [idleEvent, requestedEvent] }), + jsonResponse({ events: [idleEvent, requestedEvent, polledEvent] }), + ]); + const client = new CopilotClient({ + autoStart: false, + env: { COPILOT_MC_BASE_URL: "https://mc.test/agents" }, + }); + + const session = await client.connectCloudSession("task-1", { + initialEventTimeoutMs: 0, + pollIntervalMs: 10, + }); + const seen: string[] = []; + session.on((event) => seen.push(event.id)); + + expect(session.getMessages().map((event) => event.id)).toEqual(["event-1", "event-2"]); + + await vi.advanceTimersByTimeAsync(10); + + expect(seen).toEqual(["event-3"]); + expect(session.getMessages().map((event) => event.id)).toEqual([ + "event-1", + "event-2", + "event-3", + ]); + + await session.disconnect(); + }); + + it("surfaces Mission Control error responses as typed cloud session errors", async () => { + mockFetch([textResponse(JSON.stringify({ message: "blocked" }), { status: 403 })]); + const client = new CopilotClient({ + autoStart: false, + env: { COPILOT_MC_BASE_URL: "https://mc.test/agents" }, + }); + + await expect( + client.createCloudSession({ + repository: { owner: "github", name: "copilot-sdk" }, + initialEventTimeoutMs: 0, + }) + ).rejects.toMatchObject({ + name: "CloudSessionError", + message: "blocked", + reason: "policy_blocked", + status: 403, + } satisfies Partial); + }); +}); + +function mockFetch(responses: Response[]): ReturnType { + const fetchMock = vi.fn(async () => { + const response = responses.shift(); + if (!response) { + throw new Error("Unexpected fetch call"); + } + return response; + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +function jsonResponse(value: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(value), { + status: 200, + headers: { "Content-Type": "application/json" }, + ...init, + }); +} + +function textResponse(value: string, init?: ResponseInit): Response { + return new Response(value, { + status: 200, + ...init, + }); +} From cc5d173424165d00adea50ddcacb951537774f28 Mon Sep 17 00:00:00 2001 From: Jason Etcovitch Date: Mon, 11 May 2026 11:45:04 -0400 Subject: [PATCH 2/2] Address cloud session PR feedback Harden Mission Control event validation, clarify cloud repository branch semantics, align CloudSession API naming/deprecation, and replace trailing slash regex normalization with a linear helper. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/README.md | 6 +- nodejs/src/client.ts | 20 +--- nodejs/src/cloud/cloudSession.ts | 29 ++++- nodejs/src/cloud/missionControlClient.ts | 92 ++++++++++++-- nodejs/src/types.ts | 9 ++ nodejs/src/url.ts | 11 ++ nodejs/test/cloudSession.test.ts | 146 ++++++++++++++++++++++- 7 files changed, 281 insertions(+), 32 deletions(-) create mode 100644 nodejs/src/url.ts diff --git a/nodejs/README.md b/nodejs/README.md index f33cd298c..2b2b5b316 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -136,7 +136,7 @@ Create a sandbox-backed cloud session through Mission Control and attach to it a const client = new CopilotClient({ gitHubToken: process.env.GITHUB_TOKEN }); const session = await client.createCloudSession({ - repository: { owner: "github", name: "copilot-sdk", branch: "main" }, + repository: { owner: "github", name: "copilot-sdk" }, onProgress: (event) => console.log(event.phase), }); @@ -149,7 +149,7 @@ await session.send({ prompt: "Summarize the project" }); Cloud sessions are separate from the `remote` client option. `remote: true` exports a local runtime session to Mission Control; `createCloudSession` provisions a cloud sandbox and controls the runtime running there. -Pass `repository` explicitly when the sandbox should be associated with a repository. For repo-less sandboxes, pass `owner` so Mission Control can bill and authorize the sandbox: +Pass `repository` explicitly when the sandbox should be associated with a repository. Mission Control currently uses only repository owner/name for sandbox provisioning; `repository.branch`, when provided, is retained as SDK metadata only. For repo-less sandboxes, pass `owner` so Mission Control can bill and authorize the sandbox: ```typescript const session = await client.createCloudSession({ owner: "github" }); @@ -157,7 +157,7 @@ const session = await client.createCloudSession({ owner: "github" }); For now, provide `gitHubToken`, `authToken`, or `COPILOT_MC_ACCESS_TOKEN` for Mission Control authentication. `missionControlBaseUrl`, `copilotApiBaseUrl`, `frontendBaseUrl`, and `pollIntervalMs` are available for enterprise hosts and tests. -##### `connectCloudSession(taskOrSessionId: string, options?: CloudConnectOptions): Promise` +##### `connectCloudSession(taskId: string, options?: CloudConnectOptions): Promise` Attach to an existing Mission Control cloud task and return the same remote-control `CloudSession` facade used by `createCloudSession`. diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 74808fdfa..e47c02cd4 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -37,6 +37,7 @@ import { getSdkProtocolVersion } from "./sdkProtocolVersion.js"; import { CopilotSession, NO_RESULT_PERMISSION_V2_ERROR } from "./session.js"; import { createSessionFsAdapter } from "./sessionFsProvider.js"; import { getTraceContext } from "./telemetry.js"; +import { stripTrailingSlash } from "./url.js"; import type { AutoModeSwitchRequest, AutoModeSwitchResponse, @@ -162,10 +163,6 @@ function getNodeExecPath(): string { return process.execPath; } -function stripTrailingSlash(value: string): string { - return value.replace(/\/+$/, ""); -} - function normalizeToken(value: string | undefined): string | undefined { const trimmed = value?.trim(); return trimmed ? trimmed : undefined; @@ -1215,10 +1212,10 @@ export class CopilotClient { * * The identifier is treated as a task ID. If Mission Control can return task * metadata, it is used to populate the session metadata; otherwise the SDK - * still attaches by polling task events for the provided identifier. + * still attaches by polling task events for the provided task ID. */ async connectCloudSession( - taskOrSessionId: string, + taskId: string, options: CloudConnectOptions = {} ): Promise { const startedAt = Date.now(); @@ -1226,19 +1223,14 @@ export class CopilotClient { options.onProgress?.({ phase: "waiting_for_session", elapsedMs: 0, - taskId: taskOrSessionId, + taskId, }); - const task = await mcClient.getTask(taskOrSessionId); + const task = await mcClient.getTask(taskId); const owner = normalizeToken(options.owner); const metadata = task ? this.createCloudSessionMetadata(task, mcClient, options.repository, owner) - : this.createFallbackCloudSessionMetadata( - taskOrSessionId, - mcClient, - options.repository, - owner - ); + : this.createFallbackCloudSessionMetadata(taskId, mcClient, options.repository, owner); const session = new CloudSession({ client: mcClient, diff --git a/nodejs/src/cloud/cloudSession.ts b/nodejs/src/cloud/cloudSession.ts index 2fb881fcb..8ca2d3a05 100644 --- a/nodejs/src/cloud/cloudSession.ts +++ b/nodejs/src/cloud/cloudSession.ts @@ -126,7 +126,7 @@ export class CloudSession { } else if (event.type === "session.idle") { resolve(); } else if (event.type === "session.error") { - reject(new Error(event.data.message)); + reject(sessionErrorFromEvent(event)); } }); }); @@ -208,6 +208,9 @@ export class CloudSession { this.isDisconnected = true; } + /** + * @deprecated Use {@link disconnect} instead. This method will be removed in a future release. + */ async destroy(): Promise { return this.disconnect(); } @@ -297,7 +300,10 @@ export class CloudSession { private updateRemoteSteerable(event: CloudSessionEvent): void { if (event.type === "session.remote_steerable_changed") { - this.remoteSteerable = event.data.remoteSteerable; + const data: unknown = event.data; + if (isRecord(data) && typeof data.remoteSteerable === "boolean") { + this.remoteSteerable = data.remoteSteerable; + } } } @@ -346,3 +352,22 @@ export class CloudSession { function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } + +function sessionErrorFromEvent( + event: Extract +): Error { + const data: unknown = event.data; + const message = + isRecord(data) && typeof data.message === "string" + ? data.message + : "Cloud session reported an error"; + const error = new Error(message); + if (isRecord(data) && typeof data.stack === "string") { + error.stack = data.stack; + } + return error; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/nodejs/src/cloud/missionControlClient.ts b/nodejs/src/cloud/missionControlClient.ts index 2655d04c6..31b3b7877 100644 --- a/nodejs/src/cloud/missionControlClient.ts +++ b/nodejs/src/cloud/missionControlClient.ts @@ -8,6 +8,7 @@ import type { MissionControlCommandType, MissionControlTask, } from "../types.js"; +import { stripTrailingSlash } from "../url.js"; export const CLOUD_SANDBOX_AGENT_SLUG = "copilot-developer-sandbox"; @@ -53,10 +54,10 @@ export class MissionControlClient { private readonly createCloudTaskTimeoutMs: number; constructor(options: MissionControlClientOptions) { - this.baseUrl = options.baseUrl.replace(/\/+$/, ""); + this.baseUrl = stripTrailingSlash(options.baseUrl); this.authToken = options.authToken?.trim() || undefined; this.integrationId = options.integrationId ?? "copilot-cli"; - this.frontendBaseUrl = options.frontendBaseUrl.replace(/\/+$/, ""); + this.frontendBaseUrl = stripTrailingSlash(options.frontendBaseUrl); this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; this.createCloudTaskTimeoutMs = options.createCloudTaskTimeoutMs ?? DEFAULT_CREATE_CLOUD_TASK_TIMEOUT_MS; @@ -99,7 +100,7 @@ export class MissionControlClient { ); } - return data.events.filter(isCloudSessionEvent); + return data.events.map((event, index) => parseCloudSessionEvent(event, taskId, index)); } async steerTask( @@ -223,12 +224,81 @@ function isAbortError(error: unknown): boolean { return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError"); } -function isCloudSessionEvent(value: unknown): value is CloudSessionEvent { - if (!value || typeof value !== "object") return false; - const event = value as { id?: unknown; timestamp?: unknown; type?: unknown }; - return ( - typeof event.id === "string" && - typeof event.timestamp === "string" && - typeof event.type === "string" - ); +function parseCloudSessionEvent(value: unknown, taskId: string, index: number): CloudSessionEvent { + const label = `Mission Control event ${index} for task ${taskId}`; + if (!isRecord(value)) { + throw invalidEventShape(label, "expected an object"); + } + + if (typeof value.id !== "string") { + throw invalidEventShape(label, "expected string id"); + } + if (typeof value.timestamp !== "string") { + throw invalidEventShape(label, "expected string timestamp"); + } + if (typeof value.type !== "string") { + throw invalidEventShape(label, "expected string type"); + } + if (value.parentId !== null && typeof value.parentId !== "string") { + throw invalidEventShape(label, "expected parentId to be a string or null"); + } + if (value.ephemeral !== undefined && typeof value.ephemeral !== "boolean") { + throw invalidEventShape(label, "expected ephemeral to be a boolean"); + } + if (value.agentId !== undefined && typeof value.agentId !== "string") { + throw invalidEventShape(label, "expected agentId to be a string"); + } + + validateKnownEventShape(value, label); + return value as unknown as CloudSessionEvent; +} + +function validateKnownEventShape(event: Record, label: string): void { + if (event.type === "session.requested") { + if (event.data !== undefined && !isRecord(event.data)) { + throw invalidEventShape(label, "expected session.requested data to be an object"); + } + return; + } + + const data = + typeof event.type === "string" && event.type.startsWith("session.") + ? requireDataObject(event, label) + : event.data; + + if (event.type === "session.remote_steerable_changed") { + if (!isRecord(data) || typeof data.remoteSteerable !== "boolean") { + throw invalidEventShape( + label, + "expected session.remote_steerable_changed data.remoteSteerable to be a boolean" + ); + } + } else if (event.type === "session.error") { + if (!isRecord(data) || typeof data.message !== "string") { + throw invalidEventShape(label, "expected session.error data.message to be a string"); + } + } else if (event.type === "assistant.message") { + const messageData = requireDataObject(event, label); + if (typeof messageData.content !== "string" || typeof messageData.messageId !== "string") { + throw invalidEventShape( + label, + "expected assistant.message data.content and data.messageId to be strings" + ); + } + } +} + +function requireDataObject(event: Record, label: string): Record { + if (!isRecord(event.data)) { + throw invalidEventShape(label, `expected ${String(event.type)} data to be an object`); + } + return event.data; +} + +function invalidEventShape(label: string, detail: string): CloudSessionError { + return new CloudSessionError(`Unexpected ${label}: ${detail}`, "server"); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 971969c24..d1c2d8e18 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -21,6 +21,10 @@ export type { SessionFsFileInfo } from "./sessionFsProvider.js"; export interface CloudRepository { owner: string; name: string; + /** + * Optional metadata for callers that want to remember the intended branch. + * Mission Control task creation currently uses only owner/name for sandbox provisioning. + */ branch?: string; } @@ -151,6 +155,11 @@ export interface CloudSessionOptions { * Required when repository is omitted. */ owner?: string; + /** + * Repository to associate with the sandbox. Mission Control task creation + * currently uses only repository owner/name; branch is stored in session + * metadata when provided. + */ repository?: CloudRepository; missionControlBaseUrl?: string; copilotApiBaseUrl?: string; diff --git a/nodejs/src/url.ts b/nodejs/src/url.ts new file mode 100644 index 000000000..a16892ac8 --- /dev/null +++ b/nodejs/src/url.ts @@ -0,0 +1,11 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +export function stripTrailingSlash(value: string): string { + let end = value.length; + while (end > 0 && value.charCodeAt(end - 1) === 47) { + end--; + } + return end === value.length ? value : value.slice(0, end); +} diff --git a/nodejs/test/cloudSession.test.ts b/nodejs/test/cloudSession.test.ts index 17f9677d9..4b32974fe 100644 --- a/nodejs/test/cloudSession.test.ts +++ b/nodejs/test/cloudSession.test.ts @@ -46,6 +46,28 @@ const idleEvent: CloudSessionEvent = { data: {}, }; +const assistantMessageEvent: CloudSessionEvent = { + id: "event-assistant", + parentId: "event-1", + timestamp: "2026-05-11T10:00:01.000Z", + type: "assistant.message", + data: { + content: "hello from cloud", + messageId: "message-1", + }, +}; + +const sessionErrorEvent: CloudSessionEvent = { + id: "event-error", + parentId: "event-1", + timestamp: "2026-05-11T10:00:01.000Z", + type: "session.error", + data: { + errorType: "query", + message: "cloud failed", + }, +}; + describe("Cloud sessions", () => { afterEach(() => { vi.useRealTimers(); @@ -63,8 +85,8 @@ describe("Cloud sessions", () => { autoStart: false, gitHubToken: "token-1", env: { - COPILOT_MC_BASE_URL: "https://mc.test/agents", - COPILOT_MC_FRONTEND_URL: "https://github.test", + COPILOT_MC_BASE_URL: "https://mc.test/agents/", + COPILOT_MC_FRONTEND_URL: "https://github.test/", }, }); @@ -180,6 +202,121 @@ describe("Cloud sessions", () => { await session.disconnect(); }); + it("returns the last assistant message from sendAndWait when the cloud session becomes idle", async () => { + vi.useFakeTimers(); + const fetchMock = mockFetch([ + textResponse("", { status: 404 }), + jsonResponse({ events: [] }), + textResponse("", { status: 202 }), + jsonResponse({ events: [assistantMessageEvent, idleEvent] }), + ]); + const client = new CopilotClient({ + autoStart: false, + env: { COPILOT_MC_BASE_URL: "https://mc.test/agents" }, + }); + + const session = await client.connectCloudSession("task-1", { + initialEventTimeoutMs: 0, + pollIntervalMs: 10, + }); + const result = session.sendAndWait({ prompt: "hello cloud" }, 1_000); + + await flushPromises(); + expect(fetchMock).toHaveBeenCalledTimes(3); + await vi.advanceTimersByTimeAsync(10); + + await expect(result).resolves.toEqual(assistantMessageEvent); + await session.disconnect(); + }); + + it("rejects sendAndWait when the cloud session reports an error", async () => { + vi.useFakeTimers(); + mockFetch([ + textResponse("", { status: 404 }), + jsonResponse({ events: [] }), + textResponse("", { status: 202 }), + jsonResponse({ events: [sessionErrorEvent] }), + ]); + const client = new CopilotClient({ + autoStart: false, + env: { COPILOT_MC_BASE_URL: "https://mc.test/agents" }, + }); + + const session = await client.connectCloudSession("task-1", { + initialEventTimeoutMs: 0, + pollIntervalMs: 10, + }); + const result = session.sendAndWait({ prompt: "hello cloud" }, 1_000); + const expectedRejection = expect(result).rejects.toThrow("cloud failed"); + + await flushPromises(); + await vi.advanceTimersByTimeAsync(10); + + await expectedRejection; + await session.disconnect(); + }); + + it("rejects sendAndWait when the cloud session does not become idle before timeout", async () => { + vi.useFakeTimers(); + const fetchMock = mockFetch([ + textResponse("", { status: 404 }), + jsonResponse({ events: [] }), + textResponse("", { status: 202 }), + ]); + const client = new CopilotClient({ + autoStart: false, + env: { COPILOT_MC_BASE_URL: "https://mc.test/agents" }, + }); + + const session = await client.connectCloudSession("task-1", { + initialEventTimeoutMs: 0, + pollIntervalMs: 1_000, + }); + const result = session.sendAndWait({ prompt: "hello cloud" }, 25); + const expectedRejection = expect(result).rejects.toThrow( + "Timeout after 25ms waiting for session.idle" + ); + + await flushPromises(); + expect(fetchMock).toHaveBeenCalledTimes(3); + await vi.advanceTimersByTimeAsync(25); + + await expectedRejection; + await session.disconnect(); + }); + + it("fails fast when Mission Control returns malformed event payloads", async () => { + mockFetch([ + textResponse("", { status: 404 }), + jsonResponse({ + events: [ + { + id: "event-bad", + parentId: null, + timestamp: "2026-05-11T10:00:00.000Z", + type: "session.remote_steerable_changed", + }, + ], + }), + ]); + const client = new CopilotClient({ + autoStart: false, + env: { COPILOT_MC_BASE_URL: "https://mc.test/agents" }, + }); + + await expect( + client.connectCloudSession("task-1", { + initialEventTimeoutMs: 0, + }) + ).rejects.toMatchObject({ + name: "CloudSessionError", + reason: "server", + message: expect.stringContaining( + "expected session.remote_steerable_changed data to be an object" + ), + } satisfies Partial); + }); + it("sorts replayed events and deduplicates events observed during polling", async () => { vi.useFakeTimers(); const polledEvent: CloudSessionEvent = { @@ -267,3 +404,8 @@ function textResponse(value: string, init?: ResponseInit): Response { ...init, }); } + +async function flushPromises(): Promise { + await Promise.resolve(); + await Promise.resolve(); +}