diff --git a/ts/package.json b/ts/package.json index 3b0ea74f55..f9bc898051 100644 --- a/ts/package.json +++ b/ts/package.json @@ -38,6 +38,7 @@ "code-lint": "npx tsx tools/scripts/code/lintReport.ts", "copilot": "node packages/copilot-plugin/scripts/launch.mjs", "copilot:dev": "node packages/copilot-plugin/scripts/launch-dev.mjs", + "copilot:discovery": "node packages/copilot-plugin/scripts/discovery-e2e.mjs", "devtunnel:setup": "node tools/scripts/setup-devtunnel.mjs", "devtunnel:status": "node tools/scripts/list-tunnels.mjs", "docs:action-browser": "node tools/actionBrowser/dist/cli.js", diff --git a/ts/packages/agentServer/client/src/index.ts b/ts/packages/agentServer/client/src/index.ts index 4bc4037d7a..242d5c7598 100644 --- a/ts/packages/agentServer/client/src/index.ts +++ b/ts/packages/agentServer/client/src/index.ts @@ -1,6 +1,16 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +export { + StructuredActionClient, + StructuredActionClientError, +} from "./structuredActionClient.js"; +export type { + StructuredActionClientOptions, + StructuredActionBinding, + StructuredActionClientErrorReason, +} from "./structuredActionClient.js"; + export { connectAgentServer, createAgentServerConnection, diff --git a/ts/packages/agentServer/client/src/structuredActionClient.ts b/ts/packages/agentServer/client/src/structuredActionClient.ts new file mode 100644 index 0000000000..058f01f740 --- /dev/null +++ b/ts/packages/agentServer/client/src/structuredActionClient.ts @@ -0,0 +1,345 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { randomUUID } from "node:crypto"; +import { + connectAgentServer, + type AgentServerConnection, +} from "./agentServerClient.js"; +import { AGENT_SERVER_DEFAULT_URL } from "@typeagent/agent-server-protocol"; +import type { + ClientIO, + Dispatcher, + ActionSearchRequest, + ExecuteActionRequest, + ContinueActionRequest, + CancelActionRequest, +} from "@typeagent/dispatcher-rpc/types"; +import { findOrCreateNamedConversation } from "./conversation/lifecycle.js"; + +export interface StructuredActionClientOptions { + url?: string; + conversationId?: string; + clientIO?: ClientIO; + /** Called once when this client needs its own named conversation. */ + createConversationName?: () => string; + /** Optional connection factory for embedded transports and offline tests. */ + connect?: (onDisconnect: () => void) => Promise; +} + +/** Public metadata only. Never contains the private resume capability. */ +export interface StructuredActionBinding { + conversationId?: string; + connected: boolean; +} + +export type StructuredActionClientErrorReason = + | "connection_failed" + | "binding_unavailable" + | "resume_rejected" + | "resume_failed" + | "conversation_not_found" + | "client_closed" + | "caller_cancelled" + | "delivery_uncertain"; + +const errorMessages: Record = { + connection_failed: + "The structured request was not dispatched. Unable to establish the TypeAgent connection.", + binding_unavailable: + "The structured request was not dispatched. The server did not provide a usable binding, or the initial binding reply was lost. No replacement owner was created.", + resume_rejected: + "The server rejected resuming the structured binding. The capability may be invalid, belong to another conversation, have expired, or have been lost after a session or host restart. No replacement owner was created. Do not replay interrupted work.", + resume_failed: + "Unable to resume the existing structured binding. No replacement owner was created. Prior delivery may be uncertain; do not replay interrupted work.", + conversation_not_found: + "The requested structured conversation no longer exists. No replacement conversation or owner was created. Do not replay interrupted work.", + client_closed: + "The structured client is closed; this request was not dispatched. Closing does not imply cancellation or rollback of prior work.", + caller_cancelled: + "The caller cancelled this structured request. Cancellation does not establish rollback or completion; do not replay a dispatched call.", + delivery_uncertain: + "No authoritative structured result was received. Delivery is uncertain; do not replay the call.", +}; + +/** No raw transport exception is exposed, since it may contain join arguments. */ +export class StructuredActionClientError extends Error { + constructor( + readonly dispatched: boolean, + readonly reason: StructuredActionClientErrorReason = dispatched + ? "delivery_uncertain" + : "connection_failed", + ) { + super(errorMessages[reason]); + this.name = "StructuredActionClientError"; + } +} + +function joinFailureReason( + error: unknown, + resuming: boolean, +): StructuredActionClientErrorReason { + // The RPC protocol currently flattens server errors to messages. Match only + // known protocol rejections; never return or interpolate server text. + const message = error instanceof Error ? error.message : undefined; + if (message?.startsWith("Conversation not found:")) { + return "conversation_not_found"; + } + if (resuming) { + if ( + message === "Invalid structured action resume capability" || + message === + "Structured action resume state is unavailable; do not replay an interrupted action" || + message === "Structured action binding is closed" + ) { + return "resume_rejected"; + } + return "resume_failed"; + } + return "binding_unavailable"; +} + +function defaultClientIO(): ClientIO { + const unsupported = async (): Promise => { + throw new Error( + "Structured interactions require an explicit user response through continueAction.", + ); + }; + return { + clear() {}, + exit() {}, + setUserRequest() {}, + setDisplayInfo() {}, + setDisplay() {}, + appendDisplay() {}, + appendDiagnosticData() {}, + setDynamicDisplay() {}, + notify() {}, + takeAction() {}, + shutdown() {}, + async openLocalView() {}, + async closeLocalView() {}, + question: unsupported, + askForm: unsupported, + proposeAction: unsupported, + requestChoice() {}, + requestForm() {}, + requestInteraction() {}, + interactionResolved() {}, + interactionCancelled() {}, + }; +} + +/** + * One explicit server binding per long-lived caller. The resume capability + * never leaves this object. A new process cannot adopt another process's + * pending work merely by using the same public conversation id. + */ +export class StructuredActionClient { + #resumeToken: string | undefined; + #conversationId: string | undefined; + #connection: AgentServerConnection | undefined; + #dispatcher: Dispatcher | undefined; + #connecting: Promise | undefined; + #closed = false; + #joinAttempted = false; + #generation = 0; + #name: string | undefined; + readonly #createConversationName: () => string; + readonly #clientIO: ClientIO; + readonly #connect: NonNullable; + + constructor(options: StructuredActionClientOptions = {}) { + const configured = options.conversationId; + if ( + configured !== undefined && + (typeof configured !== "string" || !configured.trim()) + ) { + throw new Error("TypeAgent conversationId must not be empty."); + } + this.#conversationId = configured; + this.#clientIO = options.clientIO ?? defaultClientIO(); + this.#createConversationName = + options.createConversationName ?? + (() => `Structured actions ${randomUUID()}`); + this.#connect = + options.connect ?? + ((onDisconnect) => + connectAgentServer( + options.url ?? AGENT_SERVER_DEFAULT_URL, + onDisconnect, + )); + } + + get binding(): StructuredActionBinding { + return { + ...(this.#conversationId === undefined + ? {} + : { conversationId: this.#conversationId }), + connected: this.#dispatcher !== undefined && !this.#closed, + }; + } + + searchActions(request: ActionSearchRequest, signal?: AbortSignal) { + return this.invoke( + (dispatcher) => dispatcher.searchActions(request), + signal, + ); + } + + executeAction(request: ExecuteActionRequest, signal?: AbortSignal) { + return this.invoke( + (dispatcher) => dispatcher.executeAction(request), + signal, + ); + } + + continueAction(request: ContinueActionRequest, signal?: AbortSignal) { + return this.invoke( + (dispatcher) => dispatcher.continueAction(request), + signal, + ); + } + + cancelAction(request: CancelActionRequest, signal?: AbortSignal) { + return this.invoke( + (dispatcher) => dispatcher.cancelAction(request), + signal, + ); + } + + private async invoke( + operation: (dispatcher: Dispatcher) => Promise, + signal?: AbortSignal, + ): Promise { + let dispatched = false; + let onAbort: (() => void) | undefined; + const work = async () => { + if (signal?.aborted) + throw new StructuredActionClientError( + false, + "caller_cancelled", + ); + const dispatcher = await this.dispatcher(); + if (signal?.aborted) + throw new StructuredActionClientError( + false, + "caller_cancelled", + ); + dispatched = true; + return operation(dispatcher); + }; + try { + const aborted = new Promise((_, reject) => { + onAbort = () => + reject( + new StructuredActionClientError( + dispatched, + "caller_cancelled", + ), + ); + signal?.addEventListener("abort", onAbort, { once: true }); + }); + return await Promise.race([work(), aborted]); + } catch (error) { + if (error instanceof StructuredActionClientError) throw error; + throw new StructuredActionClientError(dispatched); + } finally { + if (onAbort) signal?.removeEventListener("abort", onAbort); + } + } + + private async dispatcher(): Promise { + if (this.#closed) + throw new StructuredActionClientError(false, "client_closed"); + if (this.#dispatcher) return this.#dispatcher; + if (!this.#connecting) { + this.#connecting = this.connect().finally(() => { + this.#connecting = undefined; + }); + } + return this.#connecting; + } + + private async connect(): Promise { + // A failed join may have created an owner but lost its reply. Without + // its capability there is no safe way to recover that owner. + if (this.#joinAttempted && this.#resumeToken === undefined) { + throw new StructuredActionClientError(false, "binding_unavailable"); + } + const generation = ++this.#generation; + let connected = true; + let joining = false; + const resuming = this.#resumeToken !== undefined; + let connection: AgentServerConnection | undefined; + try { + connection = await this.#connect(() => { + connected = false; + if (this.#generation === generation) { + this.#dispatcher = undefined; + this.#connection = undefined; + } + }); + if (this.#conversationId === undefined) { + this.#name ??= this.#createConversationName(); + const conversation = await findOrCreateNamedConversation( + connection, + this.#name, + ); + this.#conversationId = conversation.conversationId; + } + if (this.#closed) + throw new StructuredActionClientError(false, "client_closed"); + this.#joinAttempted = true; + joining = true; + const joined = await connection.joinConversation(this.#clientIO, { + conversationId: this.#conversationId, + structuredActions: + this.#resumeToken === undefined + ? {} + : { resumeToken: this.#resumeToken }, + }); + if ( + joined.structuredActions === undefined || + joined.conversationId !== this.#conversationId + ) { + throw new StructuredActionClientError( + false, + resuming ? "resume_failed" : "binding_unavailable", + ); + } + this.#resumeToken = joined.structuredActions.resumeToken; + if (this.#closed) + throw new StructuredActionClientError(false, "client_closed"); + if (!connected) + throw new StructuredActionClientError( + false, + "connection_failed", + ); + this.#connection = connection; + this.#dispatcher = joined.dispatcher; + return joined.dispatcher; + } catch (error) { + // Transport errors may include serialized join arguments. Never + // expose their text (in particular the private resume capability). + await connection?.close().catch(() => {}); + if (error instanceof StructuredActionClientError) throw error; + throw new StructuredActionClientError( + false, + joining + ? joinFailureReason(error, resuming) + : "connection_failed", + ); + } + } + + /** Disconnect without claiming pending work was cancelled or rolled back. */ + async close(): Promise { + this.#closed = true; + await this.#connecting?.catch(() => {}); + const connection = this.#connection; + this.#connection = undefined; + this.#dispatcher = undefined; + await connection?.close().catch(() => {}); + } +} diff --git a/ts/packages/agentServer/client/test/structuredActionClient.spec.ts b/ts/packages/agentServer/client/test/structuredActionClient.spec.ts new file mode 100644 index 0000000000..357e133076 --- /dev/null +++ b/ts/packages/agentServer/client/test/structuredActionClient.spec.ts @@ -0,0 +1,387 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { jest } from "@jest/globals"; +import { + StructuredActionClient, + StructuredActionClientError, + type AgentServerConnection, + type ConversationDispatcher, +} from "../src/index.js"; + +function fakeConnection() { + const dispatcher = { + searchActions: async () => ({ + protocolVersion: 1, + scopeId: "scope", + actions: [], + }), + } as unknown as ConversationDispatcher["dispatcher"]; + const joinConversation = jest.fn( + async (_io, options) => ({ + conversationId: options!.conversationId!, + name: "Test", + connectionId: "connection", + structuredActions: { resumeToken: "private-test-capability" }, + dispatcher, + }), + ); + const createConversation = jest.fn< + AgentServerConnection["createConversation"] + >( + async (name) => + ({ conversationId: name, name }) as Awaited< + ReturnType + >, + ); + const close = jest.fn(async () => {}); + const connection = { + joinConversation, + createConversation, + close, + listConversations: async () => [], + } as unknown as AgentServerConnection; + return { + connection, + dispatcher, + joinConversation, + createConversation, + close, + }; +} + +describe("private structured connector binding lifecycle", () => { + it("uses a supplied name once and never defaults an out-of-band question", async () => { + const fake = fakeConnection(); + const createConversationName = jest.fn( + () => "Dedicated embedded caller", + ); + const client = new StructuredActionClient({ + connect: async () => fake.connection, + createConversationName, + }); + try { + await client.searchActions({ query: "read" }); + await client.searchActions({ query: "read" }); + expect(createConversationName).toHaveBeenCalledTimes(1); + expect(fake.createConversation).toHaveBeenCalledWith( + "Dedicated embedded caller", + ); + const io = fake.joinConversation.mock.calls[0][0]; + await expect( + io.question(undefined, "Allow?", ["yes", "no"], 0), + ).rejects.toThrow("explicit user response"); + } finally { + await client.close(); + } + }); + it("forwards all four operations unchanged and never calls the NL command path", async () => { + const fake = fakeConnection(); + const result = { + protocolVersion: 1 as const, + scopeId: "scope", + operationId: "operation", + status: "completed" as const, + output: [], + results: [], + }; + const search = jest.fn(fake.dispatcher.searchActions); + const execute = jest.fn< + ConversationDispatcher["dispatcher"]["executeAction"] + >(async () => result); + const continuation = jest.fn< + ConversationDispatcher["dispatcher"]["continueAction"] + >(async () => result); + const cancellation = jest.fn< + ConversationDispatcher["dispatcher"]["cancelAction"] + >(async () => result); + Object.assign(fake.dispatcher, { + searchActions: search, + executeAction: execute, + continueAction: continuation, + cancelAction: cancellation, + submitCommand: () => { + throw new Error("NL must never be called"); + }, + }); + const client = new StructuredActionClient({ + connect: async () => fake.connection, + }); + const identity = { + schemaName: "exact.schema", + actionName: "exactAction", + }; + const envelope = { protocolVersion: 1 as const, scopeId: "scope" }; + const request = { + ...identity, + ...envelope, + parameters: { + ids: ["007", '東京\n"quoted"'], + nested: { value: [null, true] }, + }, + }; + const response = { + ...envelope, + operationId: "operation", + interactionId: "interaction", + response: { type: "confirmation" as const, approved: true }, + }; + try { + await client.searchActions({ query: "exact" }); + expect(await client.executeAction(request)).toBe(result); + expect(await client.continueAction(response)).toBe(result); + expect(await client.cancelAction(response)).toBe(result); + expect(search).toHaveBeenCalledWith({ query: "exact" }); + expect(execute).toHaveBeenCalledWith(request); + expect(continuation).toHaveBeenCalledWith(response); + expect(cancellation).toHaveBeenCalledWith(response); + expect(fake.joinConversation).toHaveBeenCalledTimes(1); + expect(client.binding.connected).toBe(true); + } finally { + await client.close(); + } + }); + + it("retains the capability privately on same-id reconnect and rejects resume failure without fallback", async () => { + const fake = fakeConnection(); + let disconnect: (() => void) | undefined; + const client = new StructuredActionClient({ + conversationId: "public-id", + connect: async (callback) => { + disconnect = callback; + return fake.connection; + }, + }); + try { + await client.searchActions({ query: "read" }); + disconnect!(); + expect(client.binding).toEqual({ + conversationId: "public-id", + connected: false, + }); + await client.searchActions({ query: "read" }); + expect(fake.joinConversation.mock.calls[1][1]).toEqual({ + conversationId: "public-id", + structuredActions: { resumeToken: "private-test-capability" }, + }); + expect(JSON.stringify(client.binding)).not.toContain( + "private-test-capability", + ); + disconnect!(); + fake.joinConversation.mockRejectedValue( + new Error("Bad capability private-test-capability"), + ); + const error: unknown = await client + .searchActions({ query: "read" }) + .catch((failure: unknown) => failure); + expect(error).toBeInstanceOf(StructuredActionClientError); + expect((error as StructuredActionClientError).dispatched).toBe( + false, + ); + expect((error as StructuredActionClientError).reason).toBe( + "resume_failed", + ); + expect(String(error)).not.toContain("private-test-capability"); + expect(fake.createConversation).not.toHaveBeenCalled(); + expect( + fake.joinConversation.mock.calls[2][1]?.structuredActions, + ).toEqual({ + resumeToken: "private-test-capability", + }); + } finally { + await client.close(); + } + }); + + it.each([ + ["invalid", "Invalid structured action resume capability"], + [ + "wrong-conversation", + "Structured action resume state is unavailable; do not replay an interrupted action", + ], + [ + "expired", + "Structured action resume state is unavailable; do not replay an interrupted action", + ], + [ + "restarted-host", + "Structured action resume state is unavailable; do not replay an interrupted action", + ], + ])( + "preserves a safe explicit reason for %s resume rejection", + async (_kind, serverMessage) => { + const fake = fakeConnection(); + let disconnect: (() => void) | undefined; + const client = new StructuredActionClient({ + conversationId: "public-id", + connect: async (callback) => { + disconnect = callback; + return fake.connection; + }, + }); + try { + await client.searchActions({ query: "read" }); + disconnect!(); + fake.joinConversation.mockRejectedValue( + new Error(serverMessage), + ); + const outcome = await client + .searchActions({ query: "read" }) + .catch((error: unknown) => error); + expect(outcome).toMatchObject({ + dispatched: false, + reason: "resume_rejected", + }); + expect(String(outcome)).toContain( + "No replacement owner was created", + ); + expect(String(outcome)).not.toContain( + "private-test-capability", + ); + expect(fake.createConversation).not.toHaveBeenCalled(); + expect( + fake.joinConversation.mock.calls[1][1]?.structuredActions, + ).toEqual({ + resumeToken: "private-test-capability", + }); + } finally { + await client.close(); + } + }, + ); + + it("does not connect or dispatch for an already-aborted call", async () => { + const connect = jest.fn(async () => fakeConnection().connection); + const client = new StructuredActionClient({ connect }); + const abort = new AbortController(); + abort.abort(); + await expect( + client.searchActions({ query: "read" }, abort.signal), + ).rejects.toMatchObject({ dispatched: false }); + expect(connect).not.toHaveBeenCalled(); + await client.close(); + }); + + it("reports uncertain delivery on cancellation after dispatch without retrying", async () => { + const fake = fakeConnection(); + let entered: (() => void) | undefined; + let resolve: (() => void) | undefined; + const started = new Promise((done) => { + entered = done; + }); + const execute = jest.fn< + ConversationDispatcher["dispatcher"]["executeAction"] + >(async () => { + entered!(); + await new Promise((done) => { + resolve = done; + }); + throw new Error("Lost result containing private-test-capability"); + }); + fake.dispatcher.executeAction = execute; + const client = new StructuredActionClient({ + connect: async () => fake.connection, + }); + const abort = new AbortController(); + const pending = client.executeAction( + { + protocolVersion: 1, + scopeId: "scope", + schemaName: "schema", + actionName: "action", + }, + abort.signal, + ); + const outcome = pending.catch((error: unknown) => error); + await started; + abort.abort(); + expect(await outcome).toMatchObject({ dispatched: true }); + resolve!(); + await new Promise((done) => setImmediate(done)); + expect(execute).toHaveBeenCalledTimes(1); + expect(fake.joinConversation).toHaveBeenCalledTimes(1); + await client.close(); + }); + + it("singleflights concurrent connects and gives new processes different explicit named conversations", async () => { + const fake = fakeConnection(); + const connect = jest.fn(async () => fake.connection); + const first = new StructuredActionClient({ connect }); + const second = new StructuredActionClient({ connect }); + try { + await Promise.all([ + first.searchActions({ query: "read" }), + first.searchActions({ query: "read" }), + first.searchActions({ query: "read" }), + ]); + expect(connect).toHaveBeenCalledTimes(1); + expect(fake.joinConversation).toHaveBeenCalledTimes(1); + await second.searchActions({ query: "read" }); + const firstOptions = fake.joinConversation.mock.calls[0][1]!; + const secondOptions = fake.joinConversation.mock.calls[1][1]!; + expect(firstOptions.conversationId).not.toBe( + secondOptions.conversationId, + ); + expect(firstOptions.structuredActions).toEqual({}); + expect(secondOptions.structuredActions).toEqual({}); + expect(JSON.stringify(first)).not.toContain( + "private-test-capability", + ); + } finally { + await first.close(); + await second.close(); + } + }); + + it("never creates a replacement owner after losing the initial join reply", async () => { + const fake = fakeConnection(); + fake.joinConversation.mockRejectedValue(new Error("Reply lost")); + const connect = jest.fn(async () => fake.connection); + const client = new StructuredActionClient({ + connect, + conversationId: "explicit", + }); + await expect(client.searchActions({ query: "read" })).rejects.toThrow( + "not dispatched", + ); + await expect(client.searchActions({ query: "read" })).rejects.toThrow( + "No replacement owner was created", + ); + expect(connect).toHaveBeenCalledTimes(1); + expect(fake.createConversation).not.toHaveBeenCalled(); + await client.close(); + }); + + it("does not fallback an explicit id on missing conversation or transport errors", async () => { + const fake = fakeConnection(); + fake.joinConversation.mockRejectedValue( + new Error("Conversation not found: configured"), + ); + const client = new StructuredActionClient({ + connect: async () => fake.connection, + conversationId: "configured", + }); + await expect( + client.searchActions({ query: "read" }), + ).rejects.toMatchObject({ + dispatched: false, + reason: "conversation_not_found", + }); + expect(fake.createConversation).not.toHaveBeenCalled(); + await client.close(); + }); + + it("closes once and refuses new requests without asserting cancellation", async () => { + const fake = fakeConnection(); + const client = new StructuredActionClient({ + connect: async () => fake.connection, + }); + await client.searchActions({ query: "read" }); + await client.close(); + await client.close(); + await expect( + client.searchActions({ query: "read" }), + ).rejects.toBeInstanceOf(StructuredActionClientError); + expect(fake.close).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ts/packages/commandExecutor/README.md b/ts/packages/commandExecutor/README.md index c5b164c78f..0dfdccb6b9 100644 --- a/ts/packages/commandExecutor/README.md +++ b/ts/packages/commandExecutor/README.md @@ -27,6 +27,17 @@ This MCP server acts as a bridge between Claude Code (or other MCP clients) and The server can be configured via environment variables or constructor parameters: - **AGENT_SERVER_URL**: WebSocket URL of the TypeAgent dispatcher (default: `ws://localhost:8999`) +- **TYPEAGENT_CONVERSATION_ID**: Optional existing conversation for structured + actions. When omitted, this process creates a dedicated conversation and + keeps its private resume capability in memory so pending interactions can + continue across reconnects. + +`connection_status` exposes the separate `structuredActions` binding metadata, +never its private capability. This does not change the legacy natural-language +connection selected by `AGENT_SERVER_CONVERSATION` (or its existing default). +An independent process cannot reclaim another process's operation using a +public conversation ID. Resume rejection is not permission to create a new +owner and replay work. You can set this in the `.env` file at the root of the TypeAgent repository. @@ -84,12 +95,25 @@ The server is configured in `.mcp.json`: ### Available Tools -The MCP server provides four main tool categories: +The MCP server exposes the existing natural-language `execute_command` path and +a separate structured-action path: -1. **Natural Language Execution** - Execute commands via natural language (`execute_command`) -2. **Schema Discovery** - Discover available TypeAgent capabilities (`discover_schemas`) -3. **Dynamic Loading** - Load new schemas at runtime (`load_schema`) -4. **Direct Action Invocation** - Execute structured actions directly (`typeagent_action`) +See the [canonical structured-action design](../../docs/plans/copilot-direct-actions/director-actions.md). + +1. `discover_agents` requires one nonempty free-text query and returns complete + candidate contracts plus the conversation scope. The service uses semantic + top-five ranking when available, otherwise literal matching. +2. `execute_action` accepts the exact protocol version, scope, action identity, + and structured parameters. Execution resolves the current contract directly + and does not depend on the action appearing in search results. +3. `continue_action` sends the user's exact response to a pending interaction. +4. `cancel_action` cancels a pending structured operation. + +Structured calls return the complete service result in both readable JSON text +and `structuredContent`. `requires_interaction` is pending, not a tool error. +Callers must show the complete prompt or form to the user and must not choose +defaults or approvals for them. A timeout or disconnect can produce +`execution_uncertain`; do not automatically replay it. #### execute_command @@ -129,69 +153,29 @@ Execute user commands including music playback, list management, calendar operat - "open integrated terminal" - "show output panel" -#### discover_schemas - -Check if TypeAgent has capabilities for a user request that isn't covered by existing tools. Use this BEFORE telling the user a capability isn't available. - -**Parameters:** - -- `query` (string): Natural language description of what the user wants (e.g., "weather", "send email", "analyze code") -- `includeActions` (boolean, optional): If true, return detailed action schemas and TypeScript source. If false, just return agent names and descriptions (default: false) - -**Examples:** - -- User asks "What's the weather?" → Call `discover_schemas({query: "weather"})` -- Explore weather actions → Call `discover_schemas({query: "weather", includeActions: true})` - -**Mock Implementation:** - -Currently includes a mock weather agent with 3 actions: - -- `getCurrentConditions`: Get current weather for a location -- `getForecast`: Get multi-day forecast -- `getAlerts`: Get weather alerts - -#### load_schema - -Load a TypeAgent schema dynamically and register its actions as tools. After loading, the agent's actions become available for direct invocation in this session. - -**Parameters:** - -- `schemaName` (string): The schema/agent name returned by discover_schemas (e.g., "weather", "email") -- `exposeAs` (string, optional): How to expose actions - "individual" or "composite" (default: "composite") - - `individual`: Creates one tool per action (e.g., `weather_getCurrentConditions`, `weather_getForecast`) - - `composite`: Creates one tool (e.g., `weather_action`) with action as a parameter - -**Examples:** - -- Load weather schema: `load_schema({schemaName: "weather"})` -- Load with individual tools: `load_schema({schemaName: "weather", exposeAs: "individual"})` - -**Note:** Currently mock implementation - prints interactions but doesn't register real tools yet. - -#### typeagent_action - -Generic execution tool for any TypeAgent action not available as a direct tool. Use this as a fallback when: - -1. An action exists but isn't exposed as an individual tool -2. You want to invoke an action from a newly discovered schema before loading it -3. The action is rarely used and doesn't warrant a dedicated tool - -**Parameters:** - -- `agent` (string): The agent/schema name (e.g., "player", "list", "calendar", "weather") -- `action` (string): The action name (e.g., "playTrack", "addItem", "getCurrentConditions") -- `parameters` (object, optional): Action-specific parameters -- `naturalLanguage` (string, optional): Natural language description for cache population - -**Examples:** - -- Get weather: `typeagent_action({agent: "weather", action: "getCurrentConditions", parameters: {location: "Seattle"}})` -- With cache population: `typeagent_action({agent: "weather", action: "getCurrentConditions", parameters: {location: "Seattle"}, naturalLanguage: "what's the weather in Seattle"})` - -**Mock Implementation:** - -Returns mock weather data and prints interaction details to logs. In production, this will call the real TypeAgent dispatcher with structured actions. +The generic structured path does not translate natural language, populate the +natural-language cache, remap aliases, infer a scope, or retry calls. A caller +with a current contract and scope may execute its exact identity directly without +repeating discovery. Known editor/workspace convenience tools use search to +establish scope, then execute their fixed identity even if it is absent from +the returned candidates. + +`system.config.toggleAgent` and +`system.config.enterAgentPriorityMode` are intentionally reported as +unsupported by structured discovery and rejected before execution because their +legacy command bridge can enter interactive agent setup with unsafe unquoted +arguments. Other deterministic internal command bridges remain supported. Use +`execute_command` for the two unsupported setup operations; the ordinary +natural-language setup and choice flow remains available. Raw flow script steps +are also unavailable through structured execution until they have a +discoverable action contract; use their existing natural-language or command +path instead. + +`get_user_context` and `run_workspace_command` also use this service internally. +The workspace convenience tool adds its familiar command result fields only +when a completed action returns a valid workspace result. Pending and failed +calls retain the full structured-action status and error instead of fabricating +a zero-duration failed command. #### ping (debug mode) diff --git a/ts/packages/commandExecutor/src/commandServer.ts b/ts/packages/commandExecutor/src/commandServer.ts index 771cf9232f..c360c2b2db 100644 --- a/ts/packages/commandExecutor/src/commandServer.ts +++ b/ts/packages/commandExecutor/src/commandServer.ts @@ -10,13 +10,15 @@ import { connectAgentServer, AgentServerConnection, AGENT_SERVER_DEFAULT_URL, + StructuredActionClient, } from "@typeagent/agent-server-client"; import { discoverPort } from "@typeagent/agent-server-client/discovery"; import type { - AgentSchemaInfo, + ActionSearchResult, ClientIO, IAgentMessage, RequestId, + StructuredActionExecutionResult, TemplateEditConfig, } from "@typeagent/dispatcher-types"; import type { Dispatcher } from "@typeagent/dispatcher-types"; @@ -39,18 +41,14 @@ import { WorkspaceCommandInput, WorkspaceCommandInputSchema, WorkspaceCommandResultSchema, + WorkspaceCommandToolResultSchema, } from "./workspaceCommandMcpSchema.js"; - -// ── Agent filter ────────────────────────────────────────────────────────────── - -/** - * Agents skipped for MCP exposure — not useful via Claude Code. - * browser: use the Claude browser extension instead - * settings: dead stub, real settings are in desktop sub-schemas - * montage: requires the shell embedded browser - * markdown: not applicable for MCP use - */ -const SKIP_AGENTS = new Set(["browser", "settings", "montage", "markdown"]); +import { + invokeStructuredAction, + registerStructuredActionTools, + structuredToolResult, + type StructuredActionClient as StructuredActionToolClient, +} from "./structuredActionTools.js"; // ── Zod schemas ─────────────────────────────────────────────────────────────── @@ -64,48 +62,6 @@ function executeCommandRequestSchema() { const ExecuteCommandRequestSchema = z.object(executeCommandRequestSchema()); export type ExecuteCommandRequest = z.infer; -function discoverAgentsRequestSchema() { - return { - agentName: z - .string() - .optional() - .describe( - "If omitted, returns a list of all available agents. If provided, returns sub-schema groups with action names and descriptions for that agent.", - ), - actionName: z - .string() - .optional() - .describe( - "If provided along with agentName, returns the full TypeScript schema source for that specific action.", - ), - }; -} - -function executeActionRequestSchema() { - return { - schemaName: z.string().describe("The agent name (e.g. 'player')"), - actionName: z - .string() - .describe("The action name (e.g. 'createPlaylist')"), - parameters: z - .record(z.string(), z.any()) - .optional() - .describe("Action-specific parameters"), - naturalLanguage: z - .string() - .optional() - .describe( - "The original natural language request from the user. When provided, the dispatcher stores this as a cache entry mapping the phrase to this action+parameters, so future identical or similar requests can be handled without LLM translation.", - ), - }; -} -type ExecuteActionRequest = { - schemaName: string; - actionName: string; - parameters?: Record | undefined; - naturalLanguage?: string | undefined; -}; - // ── Utilities ───────────────────────────────────────────────────────────────── function toolResult(result: string, rawData?: unknown): CallToolResult { @@ -119,13 +75,6 @@ function toolResult(result: string, rawData?: unknown): CallToolResult { return out; } -function resultText(result: CallToolResult): string { - return result.content - .map((content) => (content.type === "text" ? content.text : "")) - .filter((text) => text.length > 0) - .join("\n"); -} - // One shape for every result where the command never actually ran, so the // failure and pre-dispatch-cancellation paths cannot drift apart. function unexecutedWorkspaceCommandResult( @@ -199,93 +148,6 @@ async function processHtmlContent(content: string): Promise { return htmlToPlainText(content); } -function remapWebflowAction(request: ExecuteActionRequest): { - schemaName: string; - actionName: string; - parameters: Record | undefined; -} { - if ( - request.schemaName !== "webflow" || - !["run_draft", "list", "execute"].includes(request.actionName) - ) { - return { - schemaName: request.schemaName, - actionName: request.actionName, - parameters: request.parameters, - }; - } - - const parameters = request.parameters; - if (request.actionName === "run_draft") { - const p = parameters as - | { - script?: unknown; - params?: unknown; - parameters?: unknown; - timeout?: unknown; - } - | undefined; - const mappedParameters: Record = { - script: p?.script, - }; - if (p?.params !== undefined) { - mappedParameters.params = - typeof p.params === "string" - ? p.params - : JSON.stringify(p.params); - } - if (p?.parameters !== undefined) { - mappedParameters.params = - typeof p.parameters === "string" - ? p.parameters - : JSON.stringify(p.parameters); - } - if (p?.timeout !== undefined) { - mappedParameters.timeout = p.timeout; - } - return { - schemaName: "browser", - actionName: "executeAdHocScript", - parameters: mappedParameters, - }; - } - if (request.actionName === "list") { - const domain = (parameters as { domain?: unknown } | undefined)?.domain; - return domain - ? { - schemaName: "browser", - actionName: "getWebFlowsForDomain", - parameters: { domain }, - } - : { - schemaName: "browser", - actionName: "getAllWebFlows", - parameters: {}, - }; - } - - const p = parameters as - | { flowName?: unknown; parameters?: unknown } - | undefined; - let flowParams = p?.parameters; - if (typeof flowParams === "string") { - try { - flowParams = JSON.parse(flowParams); - } catch { - flowParams = {}; - } - } - return { - schemaName: "browser.webFlows", - actionName: - typeof p?.flowName === "string" ? p.flowName : request.actionName, - parameters: - flowParams && typeof flowParams === "object" - ? (flowParams as Record) - : {}, - }; -} - // ── Logger ──────────────────────────────────────────────────────────────────── class Logger { @@ -481,9 +343,11 @@ function createMcpClientIO( * MCP server that exposes TypeAgent capabilities to Claude Code. * * Tools: - * execute_command — natural-language pass-through to dispatcher - * discover_agents — list agents or fetch a specific agent's schema - * execute_action — call any agent action directly by schema/action name + * execute_command - natural-language pass-through to dispatcher + * discover_agents - search complete structured action contracts + * execute_action - execute an exact action identity + * continue_action - answer a pending interaction + * cancel_action - cancel a pending operation * * Lifecycle: spawned fresh per Claude Code session; connects to the persistent * TypeAgent agentServer via WebSocket. @@ -513,8 +377,12 @@ export class CommandServer { private dispatcherRequestInFlight = false; private workspaceCommandInFlight = false; private config: ResolvedAgentServerConfig; + private readonly structuredActionClient: StructuredActionToolClient; - constructor(agentServerUrl?: string) { + constructor( + agentServerUrl?: string, + structuredActionClient?: StructuredActionToolClient, + ) { this.logger = new Logger(); const configResult = loadConfig(); @@ -536,6 +404,15 @@ export class CommandServer { agentServerUrl ?? process.env.AGENT_SERVER_URL ?? AGENT_SERVER_DEFAULT_URL; + const structuredConversationId = process.env.TYPEAGENT_CONVERSATION_ID; + this.structuredActionClient = + structuredActionClient ?? + new StructuredActionClient({ + url: this.agentServerUrl, + ...(structuredConversationId === undefined + ? {} + : { conversationId: structuredConversationId }), + }); // When set (e.g. by the reasoning subagent manager), this instance runs // in its own dedicated conversation instead of the shared default one, @@ -678,6 +555,7 @@ export class CommandServer { public async close(): Promise { this.stopReconnectionMonitoring(); + await this.structuredActionClient.close(); if (this.connection) { // Isolated-conversation path: delete our dedicated conversation and // tear down the whole connection. @@ -723,11 +601,11 @@ export class CommandServer { "- 'what's the weather in Berkeley'\n" + "- 'show seconds in the clock' / 'left align the taskbar'\n" + "- 'add milk to my shopping list'\n\n" + - "DO NOT use this for multi-step tasks. Instead, use discover_agents + execute_action directly:\n" + + "For actions already selected during orchestration, use discover_agents + execute_action:\n" + "- Tasks requiring web search + an agent action (e.g. 'find top jazz songs and make a playlist')\n" + "- Tasks requiring multiple sequential agent actions\n" + "- Tasks where you need to reason about parameters before calling\n" + - "For those, call discover_agents to find the right action, gather any external info yourself (web search etc.), then call execute_action with the resolved parameters.\n\n" + + "Search for an action, select its exact identity and complete contract, gather concrete inputs, then call execute_action with that identity and scope. Known exact identities may execute directly after establishing scope; execution does not depend on appearing in search results. Keep unresolved references on this natural-language path or clarify them first. Preserve learn:, dev:, record:, and dev: learn: prefixes exactly.\n\n" + "Parameters:\n" + "- request: The command to execute\n" + "- cacheCheck: (optional) Check cache before executing\n" + @@ -742,67 +620,15 @@ export class CommandServer { this.executeCommand(request), ); - // 2. Agent discovery — list all agents or fetch a specific agent's schema - this.server.registerTool( - "discover_agents", - { - inputSchema: discoverAgentsRequestSchema(), - description: - "Discover available TypeAgent capabilities.\n\n" + - "- Called WITHOUT agentName: returns a list of all agents with name, emoji, and description.\n" + - "- Called WITH agentName only: returns sub-schema groups with schemaName, description, and action names+descriptions. Use the schemaName shown in each group as the exact value for execute_action.\n" + - "- Called WITH agentName AND actionName: returns the full TypeScript schema source for that specific action.\n\n" + - "Use this BEFORE telling the user a capability isn't available. Call without agentName first to find the right agent, then with agentName to see its actions.\n\n" + - "PREFERRED PATTERN for multi-step tasks: use discover_agents to find actions, do any external reasoning yourself (web search, calculations, etc.), then call execute_action with fully resolved parameters. Do NOT delegate multi-step reasoning to execute_command.\n\n" + - "Example — 'find top jazz songs and make a playlist':\n" + - " 1. WebSearch for current top jazz songs\n" + - " 2. discover_agents({ agentName: 'player' }) → find createPlaylist, addSongsToPlaylist\n" + - " 3. execute_action({ schemaName: 'player', actionName: 'createPlaylist', parameters: { name: 'Top Jazz Feb 2026' } })\n" + - " 4. execute_action({ schemaName: 'player', actionName: 'addSongsToPlaylist', parameters: { playlist: '...', songs: [...] } })\n\n" + - "Available agents include (but are not limited to):\n" + - "- player: music playback (Spotify/media)\n" + - "- calendar: schedule and view events\n" + - "- list: shopping lists, todo lists\n" + - "- desktop: Windows desktop control, taskbar, VSCode editor automation\n" + - "- email: read and send email\n" + - "- chat: messaging\n" + - "- photo: photo library\n" + - "- image: image generation\n" + - "- video: video playback\n" + - "- code: code generation tasks", - }, - async (request: { - agentName?: string | undefined; - actionName?: string | undefined; - }) => this.discoverAgents(request), - ); - - // 3. Direct action execution - this.server.registerTool( - "execute_action", - { - inputSchema: executeActionRequestSchema(), - description: - "Execute a TypeAgent action directly by specifying the agent, action name, and parameters.\n\n" + - "Use discover_agents to find the correct schemaName and actionName before calling this.\n\n" + - "Parameters:\n" + - "- schemaName: The agent name (e.g. 'player', 'calendar', 'list')\n" + - "- actionName: The action to execute (e.g. 'createPlaylist', 'addEvent')\n" + - "- parameters: Action-specific parameters object (optional)\n" + - "- naturalLanguage: The original natural language request from the user (e.g. 'play shake it off'). ALWAYS provide this when you have the user's original request — the dispatcher uses it to populate its NL cache so future identical or similar requests can be handled without LLM translation.\n\n" + - "The action is dispatched directly to the agent, bypassing the LLM translation step for maximum speed.", - }, - async (request: ExecuteActionRequest, extra) => - this.executeAction(request, false, extra.signal), - ); + registerStructuredActionTools(this.server, this.structuredActionClient); this.server.registerTool( "run_workspace_command", { inputSchema: WorkspaceCommandInputSchema.shape, - outputSchema: WorkspaceCommandResultSchema.shape, + outputSchema: WorkspaceCommandToolResultSchema, description: - "Run one explicitly requested build, test, lint, or diagnostic command in the open VS Code workspace through Coda. This is a direct TypeAgent action: it does not use natural-language translation or a terminal UI. Returns structured stdout, stderr, exitCode, durationMs, success, timedOut, cancelled, and truncation metadata. Example: { command: 'pnpm test -- --runInBand', workingDirectory: 'ts/packages/coda', executionId: 'coda-tests-1' }. Coda rejects shell composition and restricts commands to an allowlist of focused tools, with path arguments confined to the workspace root. This tool holds the Command Executor for the whole run, so execute_command and execute_action are unavailable until it finishes; use a separate MCP connection for concurrent work. cancel_workspace_command still works while it runs.", + "Run one explicitly requested build, test, lint, or diagnostic command in the open VS Code workspace through Coda. This uses the structured action service, not natural-language translation or a terminal UI. A completed result includes the full service envelope plus structured stdout, stderr, exitCode, durationMs, success, timedOut, cancelled, and truncation metadata. Pending and failed calls retain their complete service status, prompt, and root error. Example: { command: 'pnpm test -- --runInBand', workingDirectory: 'ts/packages/coda', executionId: 'coda-tests-1' }. Coda rejects shell composition and restricts commands to an allowlist of focused tools, with path arguments confined to the workspace root. execute_command remains unavailable while this tool runs; cancel_workspace_command still works.", }, async (request: WorkspaceCommandInput, extra) => this.runWorkspaceCommand(request, extra.signal), @@ -833,7 +659,7 @@ export class CommandServer { "Served by the TypeAgent `code` agent (VS Code CODA extension); returns data only when VS Code with the code agent is connected to this agent server, otherwise reports no editor context.\n\n" + "For actual file/selection text, use execute_action with the code agent's read actions (getSelection, getFileContent, getDiagnostics).", }, - async () => this.getUserContext(), + async (_request, extra) => this.getUserContext(extra.signal), ); } @@ -855,13 +681,14 @@ export class CommandServer { { inputSchema: {}, description: - "Report whether this command-executor is currently connected to the TypeAgent agent server. Returns structured { connected, url, conversationId }.", + "Report connection metadata. The legacy natural-language connection and the separate structuredActions conversation binding are shown explicitly. No resume capability is exposed.", }, async () => toolResult(this.dispatcher ? "connected" : "disconnected", { connected: this.dispatcher !== null, url: this.agentServerUrl, conversationId: this.ownedConversationId, + structuredActions: this.structuredActionClient.binding, }), ); @@ -1037,140 +864,60 @@ export class CommandServer { } } - /** Resolve AgentSchemaInfo list — live from dispatcher. Returns empty if disconnected. */ - private async resolveAgentSchemas( - agentName?: string, - ): Promise { - if (!this.dispatcher) { - return []; - } - try { - const schemas = await this.dispatcher.getAgentSchemas(agentName); - return schemas.filter((a) => !SKIP_AGENTS.has(a.name)); - } catch (error) { - if ( - error instanceof Error && - error.message.includes("Agent channel disconnected") - ) { - this.logger.log( - "Agent channel disconnected during getAgentSchemas, clearing dispatcher", - ); - this.dispatcher = null; - } - return []; - } + private async getUserContext( + signal?: AbortSignal, + ): Promise { + return this.executeKnownStructuredAction( + "code", + "getActiveEditor", + {}, + signal, + ); } - private async discoverAgents(request: { - agentName?: string | undefined; - actionName?: string | undefined; - }): Promise { - if (!request.agentName) { - // Level 1 — list agents, filtered to active ones when dispatcher is available - const agents = await this.resolveAgentSchemas(); - if (agents.length === 0) { - return toolResult( - "No agents available. Ensure TypeAgent server is running.", - ); - } - - // Filter to active agents when connected - let visible = agents; - if (this.dispatcher) { - try { - const status = await this.dispatcher.getStatus(); - const activeNames = new Set( - status.agents - .filter((a) => a.active) - .map((a) => a.name.toLowerCase()), - ); - const filtered = agents.filter((a) => - activeNames.has(a.name.toLowerCase()), - ); - if (filtered.length > 0) visible = filtered; - } catch { - // Use unfiltered list - } - } - - const lines = visible.map( - (a) => `${a.emoji} **${a.name}** — ${a.description}`, - ); - return toolResult( - `Available TypeAgent agents (${visible.length}):\n\n` + - lines.join("\n") + - "\n\nCall discover_agents({ agentName: '' }) to see actions for a specific agent.", - ); - } - - const schemas = await this.resolveAgentSchemas(request.agentName); - const agent = schemas[0]; - if (!agent) { - return toolResult( - `Agent '${request.agentName}' not found or not available.`, - ); - } - - if (request.actionName) { - // Level 3 — full TypeScript source for one specific action - const needle = request.actionName.toLowerCase(); - const subSchema = agent.subSchemas.find((s) => - s.actions.some((a) => a.name.toLowerCase() === needle), - ); - if (!subSchema) { - const allActions = agent.subSchemas - .flatMap((s) => s.actions.map((a) => a.name)) - .join(", "); - return toolResult( - `Action '${request.actionName}' not found in agent '${agent.name}'.\n\nAvailable actions: ${allActions}`, - ); - } - if (!subSchema.schemaText) { - return toolResult( - `TypeScript schema not available for action '${request.actionName}'.`, - ); - } - return toolResult( - `TypeScript schema for **${subSchema.schemaName}** (action: ${request.actionName}):\n\n` + - `\`\`\`typescript\n${subSchema.schemaText}\n\`\`\``, - ); - } - - // Level 2 — sub-schema groups with schemaName + action names+descriptions - const sections = agent.subSchemas - .map((sub) => { - const actionLines = sub.actions - .map((a) => ` • **${a.name}** — ${a.description}`) - .join("\n"); - return ` 📂 **${sub.schemaName}** — ${sub.description}\n${actionLines}`; - }) - .join("\n\n"); - - const totalActions = agent.subSchemas.reduce( - (n, s) => n + s.actions.length, - 0, + private async executeKnownStructuredAction( + schemaName: string, + actionName: string, + parameters: Record, + signal?: AbortSignal, + ): Promise { + const searchResult = await invokeStructuredAction( + this.structuredActionClient, + (client, requestSignal) => + client.searchActions( + { query: `${schemaName}.${actionName}` }, + requestSignal, + ), + false, + signal, ); - return toolResult( - `${agent.emoji} **${agent.name}** — ${agent.description}\n\n` + - sections + - `\n\n(${totalActions} total actions across ${agent.subSchemas.length} schema${agent.subSchemas.length > 1 ? "s" : ""})\n\n` + - `To get TypeScript for an action: discover_agents({ agentName: '${agent.name}', actionName: '' })\n` + - `To execute: execute_action({ schemaName: '', actionName: '', parameters: {...} })`, + const search = searchResult.structuredContent as + | ActionSearchResult + | undefined; + if ( + search?.protocolVersion === undefined || + search.scopeId === undefined + ) { + return searchResult; + } + return invokeStructuredAction( + this.structuredActionClient, + (client, requestSignal) => + client.executeAction( + { + protocolVersion: search.protocolVersion, + scopeId: search.scopeId, + schemaName, + actionName, + parameters, + }, + requestSignal, + ), + true, + signal, ); } - private async getUserContext(): Promise { - // The command-executor is headless; the live editor state lives in the - // VS Code CODA extension, reachable through the code agent's read - // action. executeAction returns a clear error when the code agent is - // not enabled / VS Code is not connected. - return this.executeAction({ - schemaName: "code", - actionName: "getActiveEditor", - parameters: {}, - }); - } - private async runWorkspaceCommand( request: WorkspaceCommandInput, signal?: AbortSignal, @@ -1203,22 +950,37 @@ export class CommandServer { } this.dispatcherRequestInFlight = true; acquiredDispatcherLock = true; - const result = await this.executeActionUnlocked( - { - schemaName: "code.code-workbench", - actionName: "runWorkspaceCommand", - parameters: { ...request, executionId }, - }, - true, + const result = await this.executeKnownStructuredAction( + "code.code-workbench", + "runWorkspaceCommand", + { ...request, executionId }, + signal, ); - if ( - result.structuredContent !== undefined && - WorkspaceCommandResultSchema.safeParse(result.structuredContent) - .success - ) { - return result; + const serviceResult = result.structuredContent; + if (serviceResult?.status === "completed") { + const executionResult = + serviceResult as StructuredActionExecutionResult; + for (const action of executionResult.results) { + if ( + action.action.schemaName !== "code.code-workbench" || + action.action.actionName !== "runWorkspaceCommand" + ) { + continue; + } + const parsed = WorkspaceCommandResultSchema.safeParse( + "resultValue" in action.result + ? action.result.resultValue + : undefined, + ); + if (parsed.success) { + return structuredToolResult({ + ...serviceResult, + ...parsed.data, + }); + } + } } - return workspaceCommandFailure(resultText(result), executionId); + return result; } finally { if (acquiredDispatcherLock) { this.dispatcherRequestInFlight = false; @@ -1228,13 +990,9 @@ export class CommandServer { } } - // Cancellation deliberately bypasses the dispatcher and talks to the Code - // Agent websocket directly. It has to: a running run_workspace_command - // holds dispatcherRequestInFlight for its whole duration, so a cancel - // routed through executeAction would queue behind the very command it is - // meant to stop. The lock itself is load-bearing, since responseCollector - // is a single buffer shared by every dispatcher request, so the second - // transport is the consequence of that and not an alternative to it. + // Keep Coda's executionId-based process control separate from cancellation + // of the structured operation: stopping its dispatcher wait is not proof + // that the underlying workspace process has stopped. // // Known limitation: the target is resolved by discovering the "code" agent // independently of where the run was dispatched. With more than one @@ -1335,112 +1093,4 @@ export class CommandServer { }); }); } - - private async executeAction( - request: ExecuteActionRequest, - preserveDisplayText = false, - signal?: AbortSignal, - ): Promise { - if ( - request.schemaName === "code.code-workbench" && - request.actionName === "runWorkspaceCommand" - ) { - const parsed = WorkspaceCommandInputSchema.safeParse( - request.parameters, - ); - return parsed.success - ? this.runWorkspaceCommand(parsed.data, signal) - : toolResult( - `Action parameters are invalid: ${parsed.error.message}`, - ); - } - if ( - request.schemaName === "code.code-workbench" && - request.actionName === "cancelWorkspaceCommand" - ) { - const parsed = CancelWorkspaceCommandInputSchema.safeParse( - request.parameters, - ); - return parsed.success - ? this.cancelWorkspaceCommand(parsed.data) - : toolResult( - `Action parameters are invalid: ${parsed.error.message}`, - ); - } - if (this.dispatcherRequestInFlight) { - return toolResult( - "Another request is already using this Command Executor. Wait for it to complete before sending another command.", - ); - } - this.dispatcherRequestInFlight = true; - try { - return await this.executeActionUnlocked( - request, - preserveDisplayText, - ); - } finally { - this.dispatcherRequestInFlight = false; - } - } - - private async executeActionUnlocked( - request: ExecuteActionRequest, - preserveDisplayText = false, - ): Promise { - this.logger.log( - `execute_action: ${request.schemaName}.${request.actionName} params=${JSON.stringify(request.parameters ?? {})}`, - ); - - if (!this.dispatcher && !this.isConnecting) { - await this.connectToDispatcher(); - } - - if (!this.dispatcher) { - return toolResult( - `Cannot execute action: not connected to TypeAgent dispatcher at ${this.agentServerUrl}.`, - ); - } - - const { schemaName, actionName, parameters } = - remapWebflowAction(request); - - const paramStr = - parameters && Object.keys(parameters).length > 0 - ? `--parameters '${JSON.stringify(parameters).replaceAll("'", "\\u0027")}'` - : ""; - - const nlStr = request.naturalLanguage - ? `--naturalLanguage '${request.naturalLanguage.replaceAll("'", "\\u0027")}'` - : ""; - - const actionCommand = - `@action ${schemaName} ${actionName} ${paramStr} ${nlStr}`.trim(); - - this.logger.log(`Dispatching: ${actionCommand}`); - this.responseCollector.messages = []; - this.responseCollector.rawData = undefined; - - try { - const result = await awaitCommand(this.dispatcher, actionCommand); - if (result?.lastError) { - return toolResult(`Action error: ${result.lastError}`); - } - if (this.responseCollector.messages.length > 0) { - const response = this.responseCollector.messages.join("\n\n"); - return toolResult( - preserveDisplayText - ? response - : await processHtmlContent(response), - this.responseCollector.rawData, - ); - } - return toolResult( - `✓ Action ${request.actionName} executed successfully`, - ); - } catch (error) { - return toolResult( - `Action execution failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } } diff --git a/ts/packages/commandExecutor/src/structuredActionTools.ts b/ts/packages/commandExecutor/src/structuredActionTools.ts new file mode 100644 index 0000000000..ca5093f780 --- /dev/null +++ b/ts/packages/commandExecutor/src/structuredActionTools.ts @@ -0,0 +1,278 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { + StructuredActionClientError, + type StructuredActionClient as SharedStructuredActionClient, +} from "@typeagent/agent-server-client"; +import type { + ActionSearchResult, + CancelActionRequest, + ContinueActionRequest, + ExecuteActionRequest, + StructuredActionExecutionResult, +} from "@typeagent/dispatcher-types"; +import { z } from "zod/v4"; + +export type StructuredActionClient = Pick< + SharedStructuredActionClient, + | "binding" + | "searchActions" + | "executeAction" + | "continueAction" + | "cancelAction" + | "close" +>; + +type StructuredActionResult = + | ActionSearchResult + | StructuredActionExecutionResult; + +type StructuredOperation = ( + client: StructuredActionClient, + signal?: AbortSignal, +) => Promise; + +const identity = { + schemaName: z.string(), + actionName: z.string(), +}; + +const envelope = { + protocolVersion: z.literal(1), + scopeId: z.string(), +}; + +const fieldAnswer = z.discriminatedUnion("kind", [ + z + .object({ + kind: z.literal("pick"), + selected: z.number().int(), + text: z.string().optional(), + }) + .strict(), + z + .object({ + kind: z.literal("multiChoice"), + selected: z.array(z.number().int()), + text: z.string().optional(), + }) + .strict(), + z.object({ kind: z.literal("yesNo"), value: z.boolean() }).strict(), +]); + +const response = z.discriminatedUnion("type", [ + z + .object({ type: z.literal("confirmation"), approved: z.boolean() }) + .strict(), + z + .object({ type: z.literal("question"), selected: z.number().int() }) + .strict(), + z.object({ type: z.literal("yesNo"), value: z.boolean() }).strict(), + z + .object({ + type: z.literal("multiChoice"), + selected: z.array(z.number().int()), + }) + .strict(), + z + .object({ + type: z.literal("pickRemember"), + selected: z.number().int(), + remember: z.boolean(), + }) + .strict(), + z + .object({ + type: z.literal("form"), + value: z + .object({ + answers: z.record(z.string(), fieldAnswer), + cancelled: z.boolean().optional(), + }) + .strict(), + }) + .strict(), + z + .object({ + type: z.literal("proposal"), + accepted: z.boolean(), + data: z.unknown().optional(), + }) + .strict(), +]); + +export function structuredToolResult( + result: StructuredActionResult | Record, + isError = hasErrorStatus(result), +): CallToolResult { + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + structuredContent: { ...result }, + ...(isError ? { isError: true } : {}), + }; +} + +function hasErrorStatus(result: Record): boolean { + if (!("status" in result)) { + return false; + } + return !["completed", "requires_interaction"].includes( + String(result.status), + ); +} + +export async function invokeStructuredAction( + client: StructuredActionClient, + operation: StructuredOperation, + effect: boolean, + signal?: AbortSignal, +): Promise { + let submitted = false; + try { + if (signal?.aborted) { + throw new Error("Cancelled before dispatch."); + } + const action = operation(client, signal); + submitted = true; + const result = await action; + return structuredToolResult(result); + } catch (error) { + // A missing RPC response cannot prove whether an effect happened. + const dispatched = + error instanceof StructuredActionClientError + ? error.dispatched + : submitted; + const status = + effect && dispatched ? "execution_uncertain" : "unavailable"; + return structuredToolResult( + { + status, + error: { + code: + error instanceof StructuredActionClientError + ? error.reason + : "transport_error", + message: + error instanceof StructuredActionClientError + ? error.message + : status === "execution_uncertain" + ? "No authoritative result was received. Effects may have occurred. Do not replay this call." + : "No authoritative result was received. Check the TypeAgent connection and binding; no call was retried.", + }, + source: "command-executor-transport", + ...(client.binding.conversationId === undefined + ? {} + : { conversationId: client.binding.conversationId }), + }, + true, + ); + } +} + +export function registerStructuredActionTools( + server: McpServer, + client: StructuredActionClient, +): void { + server.registerTool( + "discover_agents", + { + inputSchema: z + .object({ + query: z.string().trim().min(1), + }) + .strict(), + description: + "Search TypeAgent actions with a required nonempty free-text query and return complete candidate contracts. The service uses semantic ranking when available or literal matching otherwise. Select an exact schemaName/actionName from the results when discovering an action. Discovery does not enable agents or authorize execution.", + }, + (request, extra) => + invokeStructuredAction( + client, + (structuredClient, signal) => + structuredClient.searchActions(request, signal), + false, + extra.signal, + ), + ); + + server.registerTool( + "execute_action", + { + inputSchema: z + .object({ + ...envelope, + ...identity, + parameters: z.record(z.string(), z.unknown()).optional(), + }) + .strict(), + description: + "Execute one known action with its exact schemaName/actionName identity, scope, and concrete structured parameters. No natural-language translation, cache training, alias remapping, default bindings, or replay. The current contract is resolved at execution and execution does not depend on search ranking. Copilot selecting an action is not user consent. On requires_interaction show the full prompt and ask the USER, then continue_action with their exact response or cancel_action. Never auto-answer. Do not replay after timeout, disconnect, or execution_uncertain. Returns the complete service status and ActionResult data.", + }, + (request, extra) => + invokeStructuredAction( + client, + (structuredClient, signal) => + structuredClient.executeAction( + request as ExecuteActionRequest, + signal, + ), + true, + extra.signal, + ), + ); + + server.registerTool( + "continue_action", + { + inputSchema: z + .object({ + ...envelope, + operationId: z.string(), + interactionId: z.string(), + response, + }) + .strict(), + description: + "Submit the actual USER response to the full pending prompt in this binding. Preserve scopeId, operationId, and interactionId exactly. Never invent approval, accept a default, or replay execute_action. A further prompt requires another user response.", + }, + (request, extra) => + invokeStructuredAction( + client, + (structuredClient, signal) => + structuredClient.continueAction( + request as ContinueActionRequest, + signal, + ), + true, + extra.signal, + ), + ); + + server.registerTool( + "cancel_action", + { + inputSchema: z + .object({ + ...envelope, + operationId: z.string(), + interactionId: z.string().optional(), + }) + .strict(), + description: + "Cancel a structured operation at the USER's request. Cancellation is not rollback; execution_uncertain means effects may have occurred. Never replay automatically.", + }, + (request, extra) => + invokeStructuredAction( + client, + (structuredClient, signal) => + structuredClient.cancelAction( + request as CancelActionRequest, + signal, + ), + true, + extra.signal, + ), + ); +} diff --git a/ts/packages/commandExecutor/src/workspaceCommandMcpSchema.ts b/ts/packages/commandExecutor/src/workspaceCommandMcpSchema.ts index c0f9c81351..7cb97842a7 100644 --- a/ts/packages/commandExecutor/src/workspaceCommandMcpSchema.ts +++ b/ts/packages/commandExecutor/src/workspaceCommandMcpSchema.ts @@ -81,6 +81,38 @@ export type WorkspaceCommandResult = z.infer< typeof WorkspaceCommandResultSchema >; +// Completed calls include the workspace result fields alongside the full +// structured-action envelope. Other service statuses intentionally remain +// service-shaped so pending prompts and root errors are not converted into a +// fabricated command failure. +export const WorkspaceCommandToolResultSchema = + WorkspaceCommandResultSchema.partial() + .extend({ + error: z + .union([ + z.string(), + z.object({ code: z.string(), message: z.string() }), + ]) + .optional(), + status: z + .enum([ + "requires_interaction", + "completed", + "failed", + "cancelled", + "unavailable", + "execution_uncertain", + ]) + .optional(), + }) + .passthrough() + .refine( + (value) => + value.status !== undefined || + WorkspaceCommandResultSchema.safeParse(value).success, + "Expected a workspace result or a structured action status", + ); + export const CancelWorkspaceCommandInputSchema = z.object({ executionId: z .string() diff --git a/ts/packages/commandExecutor/test/structuredActionTools.spec.ts b/ts/packages/commandExecutor/test/structuredActionTools.spec.ts new file mode 100644 index 0000000000..f0715d80f4 --- /dev/null +++ b/ts/packages/commandExecutor/test/structuredActionTools.spec.ts @@ -0,0 +1,767 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { jest } from "@jest/globals"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { StructuredActionClientError } from "@typeagent/agent-server-client"; +import { CommandServer } from "../src/commandServer.js"; +import { + registerStructuredActionTools, + structuredToolResult, + type StructuredActionClient, +} from "../src/structuredActionTools.js"; + +function createCaller( + implementations: Partial, +): StructuredActionClient { + const missing = (method: keyof StructuredActionClient) => async () => { + throw new Error(`Unexpected ${method} call`); + }; + return { + binding: { conversationId: "conversation-1", connected: true }, + searchActions: + implementations.searchActions ?? missing("searchActions"), + executeAction: + implementations.executeAction ?? missing("executeAction"), + continueAction: + implementations.continueAction ?? missing("continueAction"), + cancelAction: implementations.cancelAction ?? missing("cancelAction"), + close: implementations.close ?? (async () => {}), + }; +} + +async function createHarness(caller: StructuredActionClient) { + const server = new McpServer({ + name: "structured-action-test", + version: "1.0.0", + }); + registerStructuredActionTools(server, caller); + const client = new Client({ name: "test-client", version: "1.0.0" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await Promise.all([ + server.connect(serverTransport), + client.connect(clientTransport), + ]); + return { + client, + async close() { + await client.close(); + await server.close(); + }, + }; +} + +function asToolResult(result: Awaited>) { + return result as CallToolResult; +} + +function actionContract(schemaName: string, actionName: string) { + return { + schemaName, + actionName, + description: `Contract for ${schemaName}.${actionName}`, + input: { + format: "typescript" as const, + typeName: `${actionName}Action`, + schemaText: `type ${actionName}Action = {};`, + }, + policy: { + effects: "read-only" as const, + confirmation: "not-required" as const, + }, + output: { + envelope: "ActionResult" as const, + optional: true as const, + resultValue: { + type: "unknown" as const, + optional: true as const, + }, + resultEntity: { + type: "Entity" as const, + optional: true as const, + }, + entities: { + type: "Entity[]" as const, + optional: true as const, + }, + }, + interactions: { + mode: "may-require-interaction" as const, + kinds: [], + }, + }; +} + +describe("structured action MCP tools", () => { + test.each([ + ["completed", false], + ["requires_interaction", false], + ["failed", true], + ["cancelled", true], + ["unavailable", true], + ["execution_uncertain", true], + ])("maps service status %s to isError=%s", (status, expectedError) => { + const result = structuredToolResult({ status }); + expect(result.isError === true).toBe(expectedError); + expect(result.structuredContent).toEqual({ status }); + }); + + test("CommandServer executes the known get_user_context identity even when search returns no candidates", async () => { + const searchActions = jest.fn(async () => ({ + protocolVersion: 1 as const, + scopeId: "scope-context", + actions: [], + })); + const executionResult = { + protocolVersion: 1 as const, + scopeId: "scope-context", + operationId: "context-operation", + status: "completed" as const, + output: ["Active editor: commandServer.ts"], + results: [], + }; + const executeAction = jest.fn(async () => executionResult); + const structuredClient = createCaller({ + searchActions, + executeAction, + }); + const commandServer = new CommandServer( + "ws://unused.invalid", + structuredClient, + ); + const client = new Client({ name: "test-client", version: "1.0.0" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await Promise.all([ + commandServer.server.connect(serverTransport), + client.connect(clientTransport), + ]); + try { + const tools = await client.listTools(); + expect(tools.tools.map((tool) => tool.name)).toEqual( + expect.arrayContaining([ + "discover_agents", + "execute_action", + "continue_action", + "cancel_action", + ]), + ); + expect( + tools.tools.find( + (tool) => tool.name === "run_workspace_command", + )?.outputSchema, + ).toMatchObject({ type: "object" }); + const binding = asToolResult( + await client.callTool({ + name: "connection_status", + arguments: {}, + }), + ); + expect(binding.structuredContent).toMatchObject({ + structuredActions: structuredClient.binding, + }); + const result = asToolResult( + await client.callTool({ + name: "get_user_context", + arguments: {}, + }), + ); + expect(searchActions).toHaveBeenCalledWith( + { query: "code.getActiveEditor" }, + expect.anything(), + ); + expect(executeAction).toHaveBeenCalledWith( + { + protocolVersion: 1, + scopeId: "scope-context", + schemaName: "code", + actionName: "getActiveEditor", + parameters: {}, + }, + expect.anything(), + ); + expect(result.structuredContent).toEqual(executionResult); + } finally { + await client.close(); + await commandServer.server.close(); + await commandServer.close(); + } + }); + + test("run_workspace_command adds completed payload without dropping the service envelope", async () => { + const workspaceResult = { + success: true, + exitCode: 0, + durationMs: 125, + command: "pnpm test", + cwd: "C:\\repo", + stdout: { + text: "PASS", + truncated: false, + totalBytes: 4, + }, + stderr: { + text: "", + truncated: false, + totalBytes: 0, + }, + timedOut: false, + cancelled: false, + executionId: "workspace-1", + }; + const executionResult = { + protocolVersion: 1 as const, + scopeId: "scope-workspace", + operationId: "workspace-operation", + status: "completed" as const, + output: ["PASS"], + results: [ + { + action: { + schemaName: "code.code-workbench", + actionName: "runWorkspaceCommand", + parameters: { + command: "pnpm test", + executionId: "workspace-1", + }, + }, + result: { + entities: [], + resultValue: workspaceResult, + }, + }, + ], + }; + const executeAction = jest.fn(async () => executionResult); + const searchActions = jest.fn(async () => ({ + protocolVersion: 1 as const, + scopeId: "scope-workspace", + actions: [], + })); + const commandServer = new CommandServer( + "ws://unused.invalid", + createCaller({ + searchActions, + executeAction, + }), + ); + const client = new Client({ name: "test-client", version: "1.0.0" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await Promise.all([ + commandServer.server.connect(serverTransport), + client.connect(clientTransport), + ]); + try { + const result = asToolResult( + await client.callTool({ + name: "run_workspace_command", + arguments: { + command: "pnpm test", + executionId: "workspace-1", + }, + }), + ); + expect(executeAction).toHaveBeenCalledWith( + { + protocolVersion: 1, + scopeId: "scope-workspace", + schemaName: "code.code-workbench", + actionName: "runWorkspaceCommand", + parameters: { + command: "pnpm test", + executionId: "workspace-1", + }, + }, + expect.anything(), + ); + expect(result.structuredContent).toEqual({ + ...executionResult, + ...workspaceResult, + }); + expect(result.isError).toBeUndefined(); + } finally { + await client.close(); + await commandServer.server.close(); + await commandServer.close(); + } + }); + + test("run_workspace_command returns an authoritative confirmation prompt unchanged", async () => { + const pendingResult = { + protocolVersion: 1 as const, + scopeId: "scope-workspace", + operationId: "workspace-operation", + status: "requires_interaction" as const, + interactionId: "workspace-confirmation", + expiresAt: 42, + output: [], + results: [], + prompt: { + type: "confirmation" as const, + action: { + protocolVersion: 1 as const, + scopeId: "scope-workspace", + schemaName: "code.code-workbench", + actionName: "runWorkspaceCommand", + parameters: { + command: "pnpm test", + executionId: "workspace-2", + }, + }, + contract: actionContract( + "code.code-workbench", + "runWorkspaceCommand", + ), + }, + }; + const continueAction = + jest.fn(); + const commandServer = new CommandServer( + "ws://unused.invalid", + createCaller({ + searchActions: async () => ({ + protocolVersion: 1, + scopeId: "scope-workspace", + actions: [], + }), + executeAction: async () => pendingResult, + continueAction, + }), + ); + const client = new Client({ name: "test-client", version: "1.0.0" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await Promise.all([ + commandServer.server.connect(serverTransport), + client.connect(clientTransport), + ]); + try { + const result = asToolResult( + await client.callTool({ + name: "run_workspace_command", + arguments: { + command: "pnpm test", + executionId: "workspace-2", + }, + }), + ); + expect(result.structuredContent).toEqual(pendingResult); + expect(result.structuredContent).not.toHaveProperty("success"); + expect(result.isError).toBeUndefined(); + expect(continueAction).not.toHaveBeenCalled(); + } finally { + await client.close(); + await commandServer.server.close(); + await commandServer.close(); + } + }); + + test("registers the native structured action tool names", async () => { + const harness = await createHarness(createCaller({})); + try { + const tools = await harness.client.listTools(); + expect(tools.tools.map((tool) => tool.name)).toEqual([ + "discover_agents", + "execute_action", + "continue_action", + "cancel_action", + ]); + const discover = tools.tools.find( + (tool) => tool.name === "discover_agents", + ); + expect(discover?.inputSchema).toMatchObject({ + required: ["query"], + properties: { query: { type: "string" } }, + }); + const execute = tools.tools.find( + (tool) => tool.name === "execute_action", + ); + expect(execute?.inputSchema).not.toHaveProperty( + "properties.fingerprint", + ); + } finally { + await harness.close(); + } + }); + + test("preserves complete search contracts and pending execution results", async () => { + const searchResult = { + protocolVersion: 1 as const, + scopeId: "scope-1", + actions: [actionContract("email", "send")], + }; + const pendingResult = { + protocolVersion: 1 as const, + scopeId: "scope-1", + operationId: "operation-1", + status: "requires_interaction" as const, + interactionId: "interaction-1", + expiresAt: 42, + output: ["Review every field"], + results: [ + { + action: { + schemaName: "email", + actionName: "send", + parameters: { recipients: ["person@example.com"] }, + }, + result: { + error: "Confirmation is still pending.", + errorCode: "confirmation_required", + }, + }, + ], + prompt: { + type: "form" as const, + message: "Confirm the message", + fields: [ + { + id: "recipient", + kind: "pick" as const, + prompt: "Recipient", + choices: ["person@example.com"], + allowFreeText: true, + }, + ], + }, + }; + const executeAction = jest.fn(async () => pendingResult); + const continueAction = + jest.fn(); + const harness = await createHarness( + createCaller({ + searchActions: async () => searchResult, + executeAction, + continueAction, + }), + ); + try { + const search = asToolResult( + await harness.client.callTool({ + name: "discover_agents", + arguments: { query: "send email" }, + }), + ); + expect(search.structuredContent).toEqual(searchResult); + expect(search.isError).toBeUndefined(); + + const request = { + protocolVersion: 1, + scopeId: "scope-1", + schemaName: "email", + actionName: "send", + parameters: { + recipients: ["person@example.com"], + metadata: { + $result: "previous", + opaque: [null, true, 7], + }, + }, + }; + const pending = asToolResult( + await harness.client.callTool({ + name: "execute_action", + arguments: request, + }), + ); + expect(executeAction).toHaveBeenCalledWith( + request, + expect.anything(), + ); + expect(pending.structuredContent).toEqual(pendingResult); + expect(pending.isError).toBeUndefined(); + expect(pending.content).toEqual([ + { + type: "text", + text: JSON.stringify(pendingResult, null, 2), + }, + ]); + expect(continueAction).not.toHaveBeenCalled(); + } finally { + await harness.close(); + } + }); + + test.each([ + {}, + { query: "" }, + { query: " " }, + { query: "read", limit: 1 }, + ])( + "rejects invalid discovery arguments without dispatch: %j", + async (arguments_) => { + const searchActions = + jest.fn(); + const harness = await createHarness( + createCaller({ searchActions }), + ); + try { + const result = asToolResult( + await harness.client.callTool({ + name: "discover_agents", + arguments: arguments_, + }), + ); + expect(result.isError).toBe(true); + expect(searchActions).not.toHaveBeenCalled(); + } finally { + await harness.close(); + } + }, + ); + + test("executes an exact identity without discovery and preserves nested failure data", async () => { + const failedResult = { + protocolVersion: 1 as const, + scopeId: "scope-1", + operationId: "operation-1", + status: "failed" as const, + output: ["The action reported a detailed failure."], + results: [ + { + action: { + schemaName: "calendar", + actionName: "addEvent", + parameters: { + event: { + title: "Review", + attendees: ["person@example.com"], + }, + }, + }, + result: { + error: "The calendar rejected the event.", + errorCode: "calendar_rejected", + resultValue: { + provider: { + code: "invalid_attendee", + retryable: false, + }, + }, + }, + }, + ], + error: { + code: "execution_failed" as const, + message: "The selected action failed.", + }, + }; + const executeAction = jest.fn(async () => failedResult); + const harness = await createHarness(createCaller({ executeAction })); + const request = { + protocolVersion: 1, + scopeId: "scope-1", + schemaName: "calendar", + actionName: "addEvent", + parameters: { + event: { + title: "Review", + attendees: ["person@example.com"], + }, + }, + }; + try { + const result = asToolResult( + await harness.client.callTool({ + name: "execute_action", + arguments: request, + }), + ); + expect(executeAction).toHaveBeenCalledWith( + request, + expect.anything(), + ); + expect(result.structuredContent).toEqual(failedResult); + expect(result.content).toEqual([ + { + type: "text", + text: JSON.stringify(failedResult, null, 2), + }, + ]); + expect(result.isError).toBe(true); + } finally { + await harness.close(); + } + }); + + test("passes the exact form response including cancellation", async () => { + const cancelledResult = { + protocolVersion: 1 as const, + scopeId: "scope-1", + operationId: "operation-1", + status: "cancelled" as const, + output: [], + results: [], + error: { + code: "cancelled" as const, + message: "The user dismissed the form.", + }, + }; + const continueAction = jest.fn(async () => cancelledResult); + const harness = await createHarness(createCaller({ continueAction })); + const request = { + protocolVersion: 1, + scopeId: "scope-1", + operationId: "operation-1", + interactionId: "interaction-1", + response: { + type: "form", + value: { + answers: { + destination: { + kind: "pick", + selected: -1, + text: "Literal user entry", + }, + flags: { + kind: "multiChoice", + selected: [2, 0], + text: "Other", + }, + }, + cancelled: true, + }, + }, + }; + try { + const result = asToolResult( + await harness.client.callTool({ + name: "continue_action", + arguments: request, + }), + ); + expect(continueAction).toHaveBeenCalledWith( + request, + expect.anything(), + ); + expect(result.structuredContent).toEqual(cancelledResult); + expect(result.isError).toBe(true); + } finally { + await harness.close(); + } + }); + + test("rejects the unsupported text form-answer variant", async () => { + const continueAction = + jest.fn(); + const harness = await createHarness(createCaller({ continueAction })); + try { + const result = asToolResult( + await harness.client.callTool({ + name: "continue_action", + arguments: { + protocolVersion: 1, + scopeId: "scope-1", + operationId: "operation-1", + interactionId: "interaction-1", + response: { + type: "form", + value: { + answers: { + unsupported: { + kind: "text", + value: "not in QuestionFormResponse", + }, + }, + }, + }, + }, + }), + ); + expect(result.isError).toBe(true); + expect(continueAction).not.toHaveBeenCalled(); + } finally { + await harness.close(); + } + }); + + test("does not dispatch an incomplete generic action request", async () => { + const executeAction = + jest.fn(); + const harness = await createHarness(createCaller({ executeAction })); + try { + const result = asToolResult( + await harness.client.callTool({ + name: "execute_action", + arguments: { + schemaName: "code.code-workbench", + actionName: "runWorkspaceCommand", + parameters: { command: "pnpm test" }, + }, + }), + ); + expect(result.isError).toBe(true); + expect(executeAction).not.toHaveBeenCalled(); + } finally { + await harness.close(); + } + }); + + test("preserves a safe explicit resume rejection without exposing capabilities", async () => { + const harness = await createHarness( + createCaller({ + searchActions: async () => { + throw new StructuredActionClientError( + false, + "resume_rejected", + ); + }, + }), + ); + try { + const result = asToolResult( + await harness.client.callTool({ + name: "discover_agents", + arguments: { query: "resume search" }, + }), + ); + expect(result.isError).toBe(true); + expect(result.structuredContent).toMatchObject({ + status: "unavailable", + error: { code: "resume_rejected" }, + source: "command-executor-transport", + }); + } finally { + await harness.close(); + } + }); + + test("reports transport uncertainty without faking success", async () => { + const harness = await createHarness( + createCaller({ + executeAction: async () => { + throw new Error("secret transport details"); + }, + }), + ); + try { + const result = asToolResult( + await harness.client.callTool({ + name: "execute_action", + arguments: { + protocolVersion: 1, + scopeId: "scope-1", + schemaName: "list", + actionName: "addItems", + parameters: { items: ["milk"] }, + }, + }), + ); + expect(result.isError).toBe(true); + expect(result.structuredContent).toMatchObject({ + status: "execution_uncertain", + error: { code: "transport_error" }, + }); + expect(JSON.stringify(result)).not.toContain( + "secret transport details", + ); + } finally { + await harness.close(); + } + }); +}); diff --git a/ts/packages/commandExecutor/test/workspaceCommandMcpSchema.spec.ts b/ts/packages/commandExecutor/test/workspaceCommandMcpSchema.spec.ts index 8c62d25915..b13001fd86 100644 --- a/ts/packages/commandExecutor/test/workspaceCommandMcpSchema.spec.ts +++ b/ts/packages/commandExecutor/test/workspaceCommandMcpSchema.spec.ts @@ -6,6 +6,7 @@ import { CancelWorkspaceCommandResultSchema, WorkspaceCommandInputSchema, WorkspaceCommandResultSchema, + WorkspaceCommandToolResultSchema, } from "../src/workspaceCommandMcpSchema.js"; describe("workspace command MCP schemas", () => { @@ -27,6 +28,36 @@ describe("workspace command MCP schemas", () => { }); }); + test("accepts pending structured-action results without fabricating command output", () => { + expect( + WorkspaceCommandToolResultSchema.parse({ + protocolVersion: 1, + scopeId: "scope-1", + operationId: "operation-1", + status: "requires_interaction", + interactionId: "interaction-1", + expiresAt: 42, + prompt: { + type: "confirmation", + }, + output: [], + results: [], + }), + ).toMatchObject({ + status: "requires_interaction", + interactionId: "interaction-1", + }); + }); + + test.each(["contract_stale", "not-found"])( + "rejects obsolete structured-action status %s", + (status) => { + expect(() => + WorkspaceCommandToolResultSchema.parse({ status }), + ).toThrow(); + }, + ); + test("rejects an invalid timeout, oversized UTF-8 command, and empty execution ID", () => { expect(() => WorkspaceCommandInputSchema.parse({ diff --git a/ts/packages/copilot-plugin/README.md b/ts/packages/copilot-plugin/README.md index 410d5bf852..965b1c4180 100644 --- a/ts/packages/copilot-plugin/README.md +++ b/ts/packages/copilot-plugin/README.md @@ -27,6 +27,278 @@ Registered alongside routing (calls are disabled in bypass mode): The hook output fields `handled`, `responseContent`, and `handledBy` are supported in current Copilot CLI behavior, allowing the hook to skip the agentic loop entirely when TypeAgent handles a request. For local runtime debugging against the runtime repo, use `pnpm copilot:dev`. +## Structured actions in Direct and MCP modes + +### One-command discovery E2E session (Windows) + +From the repository root in PowerShell: + +```powershell +Set-Location .\ts +pnpm copilot:discovery +``` + +Run the commands below from `ts`. Changing directory first lets Corepack find +the pinned pnpm version; `pnpm -C ts` from the repository root can instead try +to fetch a default version before pnpm processes `-C`. + +The launcher incrementally builds the plugin, agent server, and their transitive +dependencies using Fluid Build's `--dep` option, without selecting unrelated +workspace packages. It stages a session-local plugin +snapshot, starts a disposable server on port 9024, checks the real MCP connection, +then opens interactive Copilot in a new Windows console, leaving the printed +prompt visible in the original PowerShell window. Paste that prompt into Copilot +to discover the list-inventory action and execute it. Answer any required confirmation yourself. +Afterward, ask Copilot to repeat the action without rediscovery. Exit Copilot to +stop the owned server process tree; the original window waits for Copilot and +propagates failures. Ctrl+C in the original window also stops the owned Copilot +window/process tree and server. Use `--same-window` to keep Copilot in the original +console. The launcher currently supports Windows only. + +This is a **controlled discovery session**: the normal initial-prompt routing +hook uses bypass mode, while a separate `typeagent-e2e` MCP process uses MCP mode. +Existing TypeAgent MCP registrations are disabled only for this CLI invocation. +No global mode settings change. Normal MCP-mode user prompts still use +`processCommand`; this launcher is not a routing optimization or benchmark. + +**How discovery is triggered:** in interactive mode, the startup check only lists +MCP tools and calls `typeagent-getStatus`. It does not search actions or submit +a prompt for you. After you paste the printed prompt, Copilot is asked to call +`typeagent-searchActions` through `typeagent-e2e`, choose an action from the +returned contracts, and call `typeagent-executeAction` with its scope, exact +identity, and parameters. The query and tool calls are chosen by Copilot, not +hardcoded by the launcher. Confirmation still requires your actual answer. +The server uses the same TypeAgent action discovery implementation as other +structured callers; no separate catalog or ranking logic is added here. + +With `--smoke-test`, the script instead calls +`typeagent-searchActions({ query: "listLists" })` directly through the MCP SDK +and checks that `list.listLists` is returned. That verifies the real +MCP-to-TypeAgent discovery connection, **not Copilot's reasoning or selection**. +It never launches Copilot, opens another console, or executes an action. + +Prerequisites: Windows, Node 22+ with npm, pnpm, a native Copilot CLI executable on +PATH (already signed in), and existing TypeAgent model/embedding configuration. +The launcher does not obtain credentials, change Azure accounts, or copy files +from another checkout. Build/install requires a provisioned `ts\.npmrc`. + +```powershell +# First checkout: explicitly restore dependencies before building. +pnpm copilot:discovery --install-dependencies + +# Repeat with existing builds and an already-provisioned config directory. +pnpm copilot:discovery ` + --skip-build --config-dir 'C:\TypeAgent config' --port 9025 + +# Noninteractive check: real MCP catalog + discovery, NO action execution. +pnpm copilot:discovery --skip-build --smoke-test + +# Optional: also update the global plugin using the existing registrar. +pnpm copilot:discovery --install-plugin +``` + +`--model ` selects the Copilot model; otherwise the CLI uses its normal +default. `--startup-timeout ` controls the server listener wait +(default 120); MCP connection/probe calls have separate bounded timeouts. +`--config-dir` sets `TYPEAGENT_CONFIG_DIR` for child processes; without it, +existing configuration resolution applies. + +The worktree must have its own model configuration; building the code does not +provision it. If startup reports `Missing ApiSetting: AZURE_OPENAI_ENDPOINT`, +pass `--config-dir` for an existing configuration directory, or provision +`ts\config.local.yaml`. This can mean no configuration was loaded, not that you +need to add an Azure endpoint when using a different provider. The launcher +does not automatically use another checkout's configuration or fetch keys. + +Each invocation prints a fresh temporary run directory containing `mcp.json`, +`prompt.txt`, `probe.json`, server stdout/stderr logs, `copilot-logs`, the staged plugin, and +disposable user data. These are retained for inspection, not deleted on exit. +New-window launches also record console handles, owned process IDs, and the +actual child exit or startup error in `console-status.json`. If no window appears, +the launcher reports missing child completion rather than assuming success. +`probe.json` contains catalog/discovery evidence, not a Copilot transcript; +use Copilot's `/share` command to save the interactive tool timeline. +The smoke-test binding is closed and must not be reused by another session. + +An occupied port causes an error rather than reusing or stopping its owner. +For startup/configuration failures, inspect the printed server logs. If optional +global registration fails with Windows `EPERM`, close other Copilot sessions +holding the installed directory and retry; the default local snapshot needs no +global registration. Only the launcher's child processes are stopped. + +The bundled manifests now classify 25 audited actions across eight agents as +read-only, including `list.listLists` and `list.getList`. These skip the +structured dispatcher's outer effect-confirmation prompt unless confirmation +is explicitly required. Unknown/state-changing policies still require +confirmation. Authorization, readiness, validation, and handler questions still +apply; read-only does not mean every interaction is bypassed. +Restart the agent server after rebuilding so it loads the updated manifests; +an already-running discovery session retains its previously loaded policy. + +Run launcher regression checks with +`npm run test:e2e-launcher` from `ts\packages\copilot-plugin`. + +### Routing and tool contracts + +There are two intentional entry paths: + +- **User-originated natural language:** ordinary Direct prompts still go through + the hook and TypeAgent intent resolution. In MCP mode the hook sends the user's + exact request to `typeagent-processCommand`. Preserve `learn:`, `dev:`, + `record:`, and `dev: learn:` exactly. Do not replace them with typed calls. +- **Copilot-selected actions with concrete inputs:** fixed MCP tools call the + real shared Dispatcher structured-action interface. They do not build command + strings, parse contracts, hash schemas, determine effect policy, or translate + natural language locally. + +The normal sequence is **search complete action contracts -> execute**. +Search requires one free-text `query` and returns `protocolVersion`, `scopeId` +and `actions`: complete contracts with exact identities, closed TypeScript input +schemas including referenced types, policy, outputs and interactions. The shared +service uses its semantic top-five ranking when available, or literal matching +when ranking is unavailable. The adapter does not rank or truncate results. +A current contract can be reused within its binding without another search. +`getStatus` and `listAgents` remain available but are not prerequisite stages. +If an identity or input remains unresolved ("it", "that one"), clarify with the +user or use the natural-language path rather than guessing. + +| Tool | Input / behavior | +| -------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `typeagent-searchActions` | Required nonempty `query`; complete candidate contracts and the binding scope | +| `typeagent-executeAction` | `protocolVersion`, `scopeId`, exact `schemaName` and `actionName`, optional typed `parameters` object | +| `typeagent-continueAction` | `protocolVersion`, `scopeId`, `operationId`, `interactionId`, and the actual user's typed `response` | +| `typeagent-cancelAction` | `protocolVersion`, `scopeId`, `operationId`, and optional exact `interactionId`; cancel at the user's request | + +Lists, IDs, paths, Unicode, quotes and newlines remain JSON values, not command +arguments or prose. The shared service owns contract generation, exact-match +validation, enabled/readiness checks, permission scope, effect confirmation, +execution and single-use interaction state. Unknown and state-changing effect +policy requires user confirmation; only explicitly read-only policy can be +exempt (agents can still ask questions). Choosing an action is not user consent. +Discovery neither enables an action nor authorizes execution. +Execution resolves the exact identity internally, independently of the latest +search ranking, and rechecks current schema, parameters, scope, visibility, +active state, readiness, authorization and confirmation policy before effects. +There is no public single-contract lookup, fingerprint, or stale-contract +status. Removed actions return `unavailable`; invalid current parameters return +a validation failure. A prior search result is not execution approval. + +### A reachable Direct structured bridge + +Direct's `userPromptSubmitted` hook is a **one-shot natural-language process**, +not a structured protocol endpoint. The existing long-lived `typeagent` MCP +server therefore exposes the four structured tools in **both Direct and MCP +modes**, and calls the same transport-neutral `StructuredActionClient` / +Dispatcher interface. This is the Direct structured caller; it is not a claim +that Copilot can inject structured requests into the one-shot prompt hook. +Except for explicit cancellation, tool calls are rejected in dev/bypass modes +before connecting. Mode is checked per call because the MCP catalog remains +registered when a mode changes. +Workspace and macro server registrations and their mode behavior are unchanged. + +`StructuredActionClient` is a public export of +`@typeagent/agent-server-client`, shared with other consumers such as command +executor. The plugin wrapper only supplies its URL, public conversation ID, +ClientIO and unique conversation name. The shared client exposes the four +Dispatcher-shaped methods (with an optional `AbortSignal`), `close()`, and +public `binding` metadata. A `StructuredActionClientError.dispatched` flag +distinguishes a pre-dispatch failure from uncertain delivery; raw transport +exceptions and private resume capabilities are never exposed. The exported +`StructuredActionClientErrorReason` supplies a safe `reason`, preserved in tool +errors instead of flattening resume rejection into `transport_error`. +`resume_rejected` means the host rejected the capability; the host intentionally +does not distinguish invalid/wrong-conversation, expired, or restarted/lost +state. `resume_failed` reports an unclassified failure to resume the same owner. +Neither result permits a replacement owner or automatic replay. + +### Results and actual user interaction + +Every shared-service result is returned intact as MCP `structuredContent`, +with readable, untruncated JSON in `content`. Actual nested `ActionResult` +values, `resultEntity`, `entities`, IDs, display content, collected output and +child results are retained. Text output is not treated as the action's data. + +Execution has six distinct statuses: `completed`, `failed`, `cancelled`, +`requires_interaction`, `unavailable`, `execution_uncertain`. +Pending interactions are not MCP tool errors: `completed`, `requires_interaction` +and successful searches omit `isError`; unsuccessful terminal results set +`isError: true` while preserving the complete service envelope. +Responses also include public `binding` metadata (conversation ID and connection +state), never the private resume capability. +Connection/caller failures use a separately marked `source: copilot-transport` +error result rather than fabricating a service operation ID. Once a call has +been dispatched, lost delivery is `execution_uncertain`; no effect is replayed. + +For `requires_interaction`, display the full `prompt` (all choices, form fields +and field IDs), keep `operationId`, `interactionId`, `expiresAt` and `scopeId`, +then **ask the USER and wait**. Submit only their answer to `continueAction`. +Supported response types are `confirmation`, `question`, `yesNo`, `multiChoice`, +`pickRemember`, `form` and `proposal`. Form answers are keyed by the exact field +ID. A new prompt requires a new user answer. Never use a displayed default, +invent form answers, autoapprove, or direct the user to an inaccessible Shell. +Use `cancelAction` with the returned IDs if the user wants to stop. +Cancellation remains available after switching to Dev or Bypass mode; new +execution and continuation remain disabled there. Switching mode never supplies +an answer or implies that pending work was cancelled. + +On a validation or availability failure, reassess the current action and inputs +before constructing a new request; **no automatic replay**. On timeout, +disconnect or uncertain execution, effects may already have happened. Surface +that uncertainty and do not rerun the effect call. The service supports typed +flows through its guarded executor; **raw PowerShell flow steps are unsupported** +on this structured path. Do not present an unsupported flow as completed. + +Two legacy setup-capable actions are also unsupported on the structured path: +`system.config.toggleAgent` and `system.config.enterAgentPriorityMode`. Their +unquoted argument bridges can enter agent setup, so their candidate descriptions +explain this limitation and execution rejects them before handler entry. Other +deterministic internal command bridges remain supported. A runtime guard also +rejects unsupported nested setup before invoking agent setup hooks. Ordinary +natural-language routing, including legacy setup choices, is unchanged. A +guarded failure, including one crossing agent RPC, retains the authoritative +service status such as `failed` or `unavailable`; do not reinterpret it +as completion or retry it through a command string. + +The legacy natural-language ClientIO cannot continue its prompts through these +structured tools. It no longer supplies default answers, and reports collected +pending prompts/unsupported interaction rather than pretending completion. + +### Explicit binding, reconnect, and trust + +Stdio provides no intrinsic Copilot session identity. Each structured MCP +process finds/creates a dedicated named conversation with a random process-local +name, then explicitly joins its **concrete conversation ID** with +`structuredActions: {}`. All four operations share that one owner and concurrent +connection attempts are singleflight. This does not implicitly share context +with the ordinary Direct NL hook's conversation. + +To intentionally use a known conversation, set `TYPEAGENT_CONVERSATION_ID`, or +set public `conversationId` in the plugin `config.json`. Environment wins over +config. The ID must exist: an explicit failed join does not silently fall back to +another conversation. An explicit ID selects context, **not** a prior owner's +authority. Two fresh processes using the same public ID get isolated owners. + +The server's structured resume token is retained only in private volatile +connector memory. It is never logged, printed, persisted, put in config, or sent +to Copilot. On reconnect the connector reuses the **same conversation ID and +token**, preserving scope and pending service operations. It never creates a +replacement owner if resume fails. If the initial join reply is lost, it fails +closed because it cannot recover a capability it never received. Transport +exceptions are not echoed since they could contain join arguments. + +A server restart, expired/lost state, deleted conversation or new MCP process +can make continuation unavailable. Shutdown disconnects; it does not assert +cancellation, rollback or completion of pending work. A lost operation reply +without an operation ID cannot be safely continued by guessing one. No automatic +effect retry is provided. + +Public conversation IDs, operation/interaction IDs and `scopeId` are binding +metadata, not credentials. The server retains its existing **unauthenticated +loopback host trust model**, not a multi-user ACL or a remote-authentication +boundary. Do not expose this endpoint to untrusted network clients. + +See the [canonical structured-action design](../../docs/plans/copilot-direct-actions/director-actions.md). + --- ## Prerequisites @@ -336,6 +608,9 @@ different MCP tool catalog. The hook connects directly to TypeAgent over WebSocket. When TypeAgent recognizes and handles the request, the hook returns `{ handled: true, responseContent: "..." }` — Copilot skips the LLM entirely. +Copilot-selected typed calls use the persistent MCP structured bridge described +above; this does not reinterpret or alter the user prompt hook. + - **Pros:** Fast (~1-3s), no LLM tokens consumed - **Cons:** No streaming output, response is returned all at once @@ -426,13 +701,14 @@ The plugin stores config at `%USERPROFILE%\.typeagent-copilot\config.json` (Wind **Environment variable overrides** (take precedence over config file): -| Variable | Default | Description | -| --------------------------- | --------------------------------- | -------------------------------------------------------------------------------- | -| `TYPEAGENT_MODE` | `direct` | `direct`, `mcp`, `dev`, or `bypass` | -| `TYPEAGENT_HOST` | `localhost` | TypeAgent server host | -| `TYPEAGENT_PORT` | `8999` | TypeAgent server port | -| `TYPEAGENT_PLUGIN_DATA` | `~/.typeagent-copilot` | Config directory | -| `TYPEAGENT_WORKSPACE_ROOTS` | Copilot process working directory | Approved roots for workspace MCP tools, separated by the platform path delimiter | +| Variable | Default | Description | +| --------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------ | +| `TYPEAGENT_MODE` | `direct` | `direct`, `mcp`, `dev`, or `bypass` | +| `TYPEAGENT_HOST` | `localhost` | TypeAgent server host | +| `TYPEAGENT_PORT` | `8999` | TypeAgent server port | +| `TYPEAGENT_CONVERSATION_ID` | Dedicated per-process conversation | Optional existing public conversation ID for structured tools; overrides config `conversationId` | +| `TYPEAGENT_PLUGIN_DATA` | `~/.typeagent-copilot` | Config directory | +| `TYPEAGENT_WORKSPACE_ROOTS` | Copilot process working directory | Approved roots for workspace MCP tools, separated by the platform path delimiter | --- @@ -456,18 +732,19 @@ macro traces and TypeAgent history, injects PowerShell guidance with an The plugin starts three logical MCP servers from the same bundled entry point and single-file release executable: -| Server | Tool | Description | -| --------------------- | -------------------------- | --------------------------------------------------------------------------------------- | -| `typeagent` | `typeagent-processCommand` | Send a command to the TypeAgent agent-server | -| `typeagent` | `typeagent-listAgents` | List available TypeAgent agents | -| `typeagent` | `typeagent-getStatus` | Get TypeAgent server status | -| `typeagent-workspace` | `read` | Read bounded text under approved workspace roots | -| `typeagent-workspace` | `glob` | Find bounded, deterministically ordered workspace files | -| `typeagent-workspace` | `grep` | Search bounded workspace text | -| `typeagent-workspace` | `fetch` | Fetch bounded public HTTP(S) text without ambient credentials or private-network access | -| `typeagent-macros` | `list_macros` | List and search reusable captured procedures | -| `typeagent-macros` | `run_macro` | Replay an approved macro or return an agent-runner handoff | -| `typeagent-macros` | lifecycle tools | Capture-derived draft validation, approval, disablement, and candidate submission | +| Server | Tool | Description | +| --------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------- | +| `typeagent` | `typeagent-processCommand` | Send a command to the TypeAgent agent-server | +| `typeagent` | `typeagent-listAgents` | List available TypeAgent agents | +| `typeagent` | `typeagent-getStatus` | Get TypeAgent server status | +| `typeagent` | four structured-action tools | Search complete contracts, execute, continue, and cancel through Dispatcher in Direct/MCP modes | +| `typeagent-workspace` | `read` | Read bounded text under approved workspace roots | +| `typeagent-workspace` | `glob` | Find bounded, deterministically ordered workspace files | +| `typeagent-workspace` | `grep` | Search bounded workspace text | +| `typeagent-workspace` | `fetch` | Fetch bounded public HTTP(S) text without ambient credentials or private-network access | +| `typeagent-macros` | `list_macros` | List and search reusable captured procedures | +| `typeagent-macros` | `run_macro` | Replay an approved macro or return an agent-runner handoff | +| `typeagent-macros` | lifecycle tools | Capture-derived draft validation, approval, disablement, and candidate submission | Workspace tools are available in direct, MCP, and dev modes. In bypass mode they remain discoverable because Copilot fixes the MCP catalog when the session diff --git a/ts/packages/copilot-plugin/agents/typeagent.agent.md b/ts/packages/copilot-plugin/agents/typeagent.agent.md index 54c958482d..e06984098a 100644 --- a/ts/packages/copilot-plugin/agents/typeagent.agent.md +++ b/ts/packages/copilot-plugin/agents/typeagent.agent.md @@ -5,6 +5,10 @@ tools: - typeagent-processCommand - typeagent-listAgents - typeagent-getStatus + - typeagent-searchActions + - typeagent-executeAction + - typeagent-continueAction + - typeagent-cancelAction infer: true userInvocable: true --- @@ -16,5 +20,57 @@ use the typeagent-processCommand tool to delegate the request. Do not attempt to handle action requests yourself. Always delegate to TypeAgent. If TypeAgent returns an error or unknown action, inform the user clearly. -For multi-step tasks, use typeagent-listAgents first to discover available agents -and their capabilities, then use typeagent-processCommand for each step. +Preserve user-originated requests as natural language, including exact `learn:`, +`dev:`, `record:`, and `dev: learn:` prefixes. Keep unresolved references such as +"it" or "that one" on this path, or ask the user to clarify. + +When YOU select an action during orchestration and already have concrete inputs, +use `typeagent-searchActions` -> `typeagent-executeAction`. Search takes one +required free-text query and returns complete candidate contracts. Reuse a +current contract in the same binding without rediscovery; there is no mandatory +single-contract, status or schema-list stage. +Supply separate exact `schemaName` and `actionName`, the returned `protocolVersion`, +`scopeId`, and typed `parameters`. Keep lists, IDs, paths, +Unicode, quotes and newlines as data, never command strings or rewritten prose. + +Execution resolves the exact identity independently of the search candidate +ranking and validates current schema, parameters and policy before effects. +There is no fingerprint or stale-contract protocol. + +Show the full authoritative result. Preserve all six states: `completed`, +`failed`, `cancelled`, `requires_interaction`, `unavailable`, +and `execution_uncertain`. `results[].result` contains actual ActionResult data, +including nested values and stable entity IDs; display text is not a substitute. +An empty output or pending interaction is not success. + +For `requires_interaction`, present the complete prompt, choices or form fields +to the USER. Wait for their actual response before `typeagent-continueAction`, +using the returned operation/interaction IDs and scope. Never select defaults, +invent responses, or treat your choice of action as consent. Unknown and +state-changing effects require confirmation; only explicitly read-only policy +can be exempt. Use `typeagent-cancelAction` at the user's request. Cancellation +or disconnect does not prove effects were rolled back. + +On validation or availability failure, reassess current contracts, inputs and +consent before constructing a new request; do not automatically replay. +After timeout, disconnect or `execution_uncertain`, do not retry the effect call. +Surface unavailable/unsupported actions honestly. Typed flows are supported by +the shared service; raw PowerShell flow steps are not supported on this path. +The exact actions `system.config.toggleAgent` and +`system.config.enterAgentPriorityMode` are also unsupported for structured +invocation because their legacy argument bridges can enter agent setup. +Their candidate descriptions explain this and execution rejects them before handler entry; nested +setup is guarded before setup hooks. Other deterministic internal command +bridges remain supported. Do not bypass the restriction by constructing command +strings. Ordinary natural language, including legacy setup choices, is unchanged. + +The fixed MCP tools are available in both Direct and MCP modes. Direct's +ordinary user-prompt hook remains natural-language; the persistent MCP process +is its structured bridge. Binding is process-local and explicitly joined by +conversation ID. Public IDs/scope metadata are not secrets or credentials; +resume capability is private volatile connector state, never something to ask +for, print, save, or include in model context. A fresh process cannot resume +another owner's interactions even on the same conversation. + +See the [canonical structured-action design](../../../docs/plans/copilot-direct-actions/director-actions.md) +and the plugin README for transport and lifecycle limitations. diff --git a/ts/packages/copilot-plugin/package.json b/ts/packages/copilot-plugin/package.json index 2a88844775..0e7f382fe3 100644 --- a/ts/packages/copilot-plugin/package.json +++ b/ts/packages/copilot-plugin/package.json @@ -23,7 +23,8 @@ "register": "node scripts/install-plugin.mjs", "test": "npm run test:local", "test:direct": "node -e \"console.log(JSON.stringify({sessionId:'test',timestamp:1234,cwd:'.',prompt:'list the playlists'}))\" | cross-env TYPEAGENT_MODE=direct node dist/hooks/hook-router.js", - "test:local": "pnpm run jest-esm --testPathPattern=\".*[.]spec[.]js\"", + "test:e2e-launcher": "node --test scripts/test/discovery-e2e.spec.mjs", + "test:local": "pnpm run jest-esm --testPathPattern=\".*[.]spec[.]js\" && npm run test:e2e-launcher", "test:mcp-redirect": "node -e \"console.log(JSON.stringify({sessionId:'test',timestamp:1234,cwd:'.',prompt:'list the playlists'}))\" | cross-env TYPEAGENT_MODE=mcp node dist/hooks/hook-router.js", "tsc": "tsc -b", "uninstall:global": "copilot plugin uninstall typeagent && copilot plugin marketplace remove typeagent-local", @@ -45,6 +46,7 @@ "@types/html-to-text": "^9.0.4", "@types/jest": "^29.5.7", "@types/node": "^20.10.0", + "agent-dispatcher": "workspace:*", "esbuild": "^0.28.2", "jest": "^29.7.0", "postject": "1.0.0-alpha.6", diff --git a/ts/packages/copilot-plugin/scripts/discovery-e2e.mjs b/ts/packages/copilot-plugin/scripts/discovery-e2e.mjs new file mode 100644 index 0000000000..65f0a015c5 --- /dev/null +++ b/ts/packages/copilot-plugin/scripts/discovery-e2e.mjs @@ -0,0 +1,697 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { spawn, spawnSync } from "node:child_process"; +import { + existsSync, + statSync, + mkdirSync, + mkdtempSync, + openSync, + closeSync, + writeFileSync, + readFileSync, +} from "node:fs"; +import { createServer, createConnection } from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { setTimeout as delay } from "node:timers/promises"; +import { stageCopilotPlugin } from "../../../tools/scripts/stageCopilotPlugin.mjs"; + +const pluginRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); +const tsRoot = path.resolve(pluginRoot, "..", ".."); +const help = `Usage (from ts): pnpm copilot:discovery [options] +Change to ts first so Corepack can resolve the pinned pnpm version. + --skip-build Reuse existing plugin/server builds + --install-dependencies Explicitly run pnpm install --frozen-lockfile + --install-plugin Also register the plugin globally (opt-in) + --config-dir Use existing TypeAgent model/embedding configuration + --port <1-65535> Isolated server port (default 9024) + --startup-timeout Readiness timeout (default 120) + --model Optional Copilot model + --same-window Keep interactive Copilot in this console + --smoke-test Verify MCP catalog and discovery; do not execute actions + --help Show this help + +Requires Node 22+, Copilot CLI, and provisioned TypeAgent configuration. +By default uses a fresh session-local plugin snapshot, not the global install. +Interactive Copilot opens in a new console; keep this window open until it exits. +Logs and disposable data are retained in the printed temporary run directory.`; + +export const testPrompt = + "Use the typeagent-e2e tools to discover an action that shows which lists exist. " + + "Inspect the returned contract, then execute the matching inventory action with concrete parameters. " + + "Do not use processCommand, shell commands, or create or modify any lists. " + + "If TypeAgent requires confirmation, show me the full prompt and wait for my answer before continuing. " + + "Show the final structured result."; + +export function makeBuildCommand(root) { + return { + command: process.execPath, + args: [ + path.join( + root, + "node_modules", + "@fluidframework", + "build-tools", + "bin", + "fluid-build", + ), + "^(@typeagent/copilot-plugin|agent-server)$", + "-t", + "build", + "--dep", + ], + }; +} + +export function parseArgs(argv) { + const options = { port: 9024, startupTimeout: 120 }; + const flags = { + "--skip-build": "skipBuild", + "--install-dependencies": "installDependencies", + "--install-plugin": "installPlugin", + "--smoke-test": "smokeTest", + "--same-window": "sameWindow", + "--help": "help", + }; + const values = { + "--port": "port", + "--startup-timeout": "startupTimeout", + "--config-dir": "configDir", + "--model": "model", + }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (Object.hasOwn(flags, arg)) { + options[flags[arg]] = true; + } else if (Object.hasOwn(values, arg)) { + const value = argv[++i]; + if (!value || value.startsWith("--")) + throw new Error(`Missing value for ${arg}`); + options[values[arg]] = value; + } else { + throw new Error(`Unknown option: ${arg}`); + } + } + for (const [key, max] of [ + ["port", 65535], + ["startupTimeout", 1800], + ]) { + if ( + !/^\d+$/.test(String(options[key])) || + Number(options[key]) < 1 || + Number(options[key]) > max + ) { + throw new Error(`${key} must be an integer between 1 and ${max}`); + } + options[key] = Number(options[key]); + } + if (options.configDir) options.configDir = path.resolve(options.configDir); + return options; +} + +export function makeConfiguration(runDir, port, inheritedEnv, configDir) { + const pluginData = path.join(runDir, "plugin-data"); + const env = { + ...inheritedEnv, + TYPEAGENT_MODE: "bypass", + TYPEAGENT_HOST: "127.0.0.1", + TYPEAGENT_PORT: String(port), + TYPEAGENT_USER_DATA_DIR: path.join(runDir, "data"), + TYPEAGENT_PLUGIN_DATA: pluginData, + CLAUDE_PLUGIN_DATA: pluginData, + INSTANCE_NAME: "discovery-e2e", + }; + delete env.TYPEAGENT_CONVERSATION_ID; + if (configDir) env.TYPEAGENT_CONFIG_DIR = configDir; + const mcp = { + mcpServers: { + "typeagent-e2e": { + type: "stdio", + command: process.execPath, + args: [path.join(runDir, "plugin", "dist", "mcp", "server.js")], + env: { + TYPEAGENT_MODE: "mcp", + TYPEAGENT_HOST: "127.0.0.1", + TYPEAGENT_PORT: String(port), + TYPEAGENT_PLUGIN_DATA: pluginData, + CLAUDE_PLUGIN_DATA: pluginData, + }, + tools: ["*"], + }, + }, + }; + return { env, mcp }; +} + +export function checkPort(port) { + return new Promise((resolve, reject) => { + const probe = createServer(); + probe.once("error", (error) => + reject( + new Error( + `Port ${port} is unavailable: ${error.message}. Choose --port; no existing server will be stopped.`, + ), + ), + ); + probe.listen({ host: "127.0.0.1", port, exclusive: true }, () => + probe.close(resolve), + ); + }); +} + +function findExecutable(name) { + const result = spawnSync( + process.platform === "win32" ? "where.exe" : "which", + [name], + { encoding: "utf8" }, + ); + const paths = (result.stdout ?? "").trim().split(/\r?\n/).filter(Boolean); + const executable = + process.platform === "win32" + ? paths.find((entry) => entry.toLowerCase().endsWith(".exe")) + : paths[0]; + if (!executable) + throw new Error( + `${name} executable not found on PATH${process.platform === "win32" ? " (a native .exe is required)" : ""}.`, + ); + return executable; +} + +function packageManager(name) { + if (process.platform !== "win32") return { command: name, prefix: [] }; + const result = spawnSync("where.exe", [name], { encoding: "utf8" }); + const paths = (result.stdout ?? "").trim().split(/\r?\n/).filter(Boolean); + const executable = paths.find((entry) => + entry.toLowerCase().endsWith(".exe"), + ); + if (executable) return { command: executable, prefix: [] }; + for (const entry of paths) { + for (const relative of [ + "node_modules/pnpm/bin/pnpm.cjs", + "node_modules/corepack/dist/pnpm.js", + ]) { + const cli = path.resolve(path.dirname(entry), relative); + if (existsSync(cli)) + return { command: process.execPath, prefix: [cli] }; + } + } + throw new Error( + "pnpm executable or npm/Corepack-installed pnpm entry point not found on PATH.", + ); +} + +export function startProcess(command, args, options) { + const child = spawn(command, args, { shell: false, ...options }); + const completion = new Promise((resolve) => { + child.once("error", (error) => resolve({ error })); + child.once("exit", (code, signal) => resolve({ code, signal })); + }); + return { child, completion }; +} + +function requireSuccess(result, label) { + if (result.error) throw new Error(`${label}: ${result.error.message}`); + if (result.code !== 0) + throw new Error(`${label} exited with ${result.code ?? result.signal}`); +} + +export async function stopProcess(processInfo) { + if ( + !processInfo?.child.pid || + processInfo.child.exitCode !== null || + processInfo.child.signalCode !== null + ) + return; + if (process.platform === "win32") { + const result = await startProcess( + "taskkill.exe", + ["/PID", String(processInfo.child.pid), "/T", "/F"], + { stdio: "ignore" }, + ).completion; + if ( + processInfo.child.exitCode === null && + processInfo.child.signalCode === null + ) + requireSuccess(result, "Stopping owned process tree"); + } else { + processInfo.child.kill("SIGTERM"); + } + const stopped = await Promise.race([ + processInfo.completion.then(() => true), + delay(5000, undefined, { ref: false }).then(() => false), + ]); + if (!stopped) + throw new Error( + `Owned process ${processInfo.child.pid} did not exit; inspect its log.`, + ); +} + +export async function runCommand( + command, + args, + env, + signal, + processOptions = {}, +) { + signal.throwIfAborted(); + const running = startProcess(command, args, { + cwd: tsRoot, + env, + stdio: "inherit", + ...processOptions, + }); + running.child.stdout?.pipe(process.stdout); + running.child.stderr?.pipe(process.stderr); + const abort = () => { + void stopProcess(running).catch((error) => + console.error(error.message), + ); + }; + signal.addEventListener("abort", abort, { once: true }); + try { + const result = await running.completion; + signal.throwIfAborted(); + requireSuccess(result, command); + } finally { + signal.removeEventListener("abort", abort); + await stopProcess(running); + } +} + +export async function runInNewWindow(command, args, env, signal, runDir) { + signal.throwIfAborted(); + // A Node bridge forwards argv without Start-Process's lossy array joining. + const bridge = path.join(runDir, "console-command.mjs"); + const statusPath = path.join(runDir, "console-status.json"); + writeFileSync(statusPath, JSON.stringify({ phase: "pending" })); + writeFileSync( + bridge, + `import { spawn } from "node:child_process"; +import { writeFileSync, renameSync } from "node:fs"; +const { command, args, cwd } = ${JSON.stringify({ command, args, cwd: tsRoot })}; +const statusPath = ${JSON.stringify(statusPath)}; +const status = { phase: "starting", bridgePid: process.pid, tty: [process.stdin.isTTY, process.stdout.isTTY, process.stderr.isTTY].map(Boolean) }; +const save = (update) => { + writeFileSync(statusPath + ".tmp", JSON.stringify(Object.assign(status, update))); + renameSync(statusPath + ".tmp", statusPath); +}; +save({}); +if (!status.tty.every(Boolean)) { + save({ phase: "failed", error: "New console has no interactive input/output handles" }); + console.error(status.error); + process.exitCode = 1; +} else { +const child = spawn(command, args, { cwd, env: process.env, stdio: "inherit", shell: false }); +child.once("spawn", () => save({ phase: "running", childPid: child.pid })); +child.once("error", (error) => { save({ phase: "failed", error: error.message }); console.error(error.message); process.exitCode = 1; }); +child.once("exit", (code, signal) => { save({ phase: "exited", code, signal }); process.exitCode = code ?? 1; }); +} +`, + ); + const literal = (value) => "'" + value.replaceAll("'", "''") + "'"; + // Only one quoted file path crosses Start-Process's command-line boundary. + const script = `$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +try { + Add-Type -TypeDefinition 'using System.Runtime.InteropServices; public static class DiscoveryConsole { [DllImport("kernel32.dll")] public static extern bool FreeConsole(); }' + [DiscoveryConsole]::FreeConsole() | Out-Null + $child = Start-Process -FilePath ${literal(process.execPath)} -ArgumentList ${literal(`"${bridge}"`)} -WorkingDirectory ${literal(tsRoot)} -PassThru -Wait + $child.Refresh() + exit $child.ExitCode +} catch { + [Console]::Error.WriteLine($_.Exception.Message) + exit 1 +}`; + try { + await runCommand( + path.join( + process.env.SystemRoot ?? "C:\\Windows", + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ), + [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-OutputFormat", + "Text", + "-EncodedCommand", + Buffer.from(script, "utf16le").toString("base64"), + ], + env, + signal, + // The wrapper leaves the parent's console group before launching. + // Pipes preserve its diagnostics after FreeConsole. + { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }, + ); + } catch (error) { + signal.throwIfAborted(); + const status = JSON.parse(readFileSync(statusPath, "utf8")); + throw new Error( + `New console: ${status.error ?? error.message}. Launch status: ${statusPath}`, + { cause: error }, + ); + } + const status = JSON.parse(readFileSync(statusPath, "utf8")); + if (status.phase !== "exited" || status.code !== 0) + throw new Error( + `New console ended without a successful child exit (phase: ${status.phase}). Launch status: ${statusPath}`, + ); +} + +export function startupFailure(result, stderrPath) { + const reason = result.error?.message ?? result.code ?? result.signal; + const log = + stderrPath && existsSync(stderrPath) + ? readFileSync(stderrPath, "utf8") + : ""; + // Only surface the setting name, not log lines that may contain credentials. + const missing = /Missing ApiSetting: ([A-Z][A-Z0-9_]*)\b/.exec(log)?.[1]; + const details = missing + ? `\nMissing model configuration: ${missing}. Run with --config-dir pointing to an existing TypeAgent configuration directory containing config.local.yaml (or provision this worktree's ts\\config.local.yaml). No credentials were fetched or copied.` + : ""; + return new Error( + `Agent server exited before readiness: ${reason}${details}${stderrPath ? `\nServer error log: ${stderrPath}` : ""}`, + ); +} + +export async function waitForServer(server, port, timeout, signal, stderrPath) { + const deadline = Date.now() + timeout * 1000; + while (Date.now() < deadline) { + signal.throwIfAborted(); + const exited = await Promise.race([ + server.completion, + Promise.resolve(undefined), + ]); + if (exited) throw startupFailure(exited, stderrPath); + const open = await new Promise((resolve) => { + const socket = createConnection({ host: "127.0.0.1", port }); + const finish = (value) => { + socket.destroy(); + resolve(value); + }; + socket.once("connect", () => finish(true)); + socket.once("error", () => finish(false)); + socket.setTimeout(500, () => finish(false)); + }); + if (open) return; + await delay(250, undefined, { signal }); + } + throw new Error(`Agent server readiness timed out after ${timeout}s.`); +} + +async function probeMcp(config, env, smokeTest, signal) { + const { Client } = await import( + "@modelcontextprotocol/sdk/client/index.js" + ); + const { StdioClientTransport } = await import( + "@modelcontextprotocol/sdk/client/stdio.js" + ); + const transport = new StdioClientTransport({ + command: config.command, + args: config.args, + env: { ...env, ...config.env }, + stderr: "inherit", + }); + const client = new Client({ + name: "typeagent-discovery-e2e", + version: "1.0.0", + }); + try { + await client.connect(transport, { signal, timeout: 30000 }); + const catalog = await client.listTools({}, { signal, timeout: 30000 }); + const names = catalog.tools.map((tool) => tool.name); + for (const name of [ + "searchActions", + "executeAction", + "continueAction", + "cancelAction", + ]) { + if (!names.includes(`typeagent-${name}`)) + throw new Error(`Missing structured tool: ${name}`); + } + const status = await client.callTool( + { + name: "typeagent-getStatus", + arguments: {}, + }, + undefined, + { signal, timeout: 60000 }, + ); + if (status.isError) + throw new Error( + `MCP readiness probe failed: ${JSON.stringify(status.content)}`, + ); + // getStatus historically reports errors as text; require its JSON response. + const text = status.content + .filter((item) => item.type === "text") + .map((item) => item.text) + .join("\n"); + try { + if (JSON.parse(text) === null) throw new Error("Empty status"); + } catch { + throw new Error(`Server did not return a valid status: ${text}`); + } + if (smokeTest) { + const result = await client.callTool( + { + name: "typeagent-searchActions", + arguments: { query: "listLists" }, + }, + undefined, + { signal, timeout: 60000 }, + ); + if (result.isError) + throw new Error( + `MCP discovery failed: ${JSON.stringify(result.content)}`, + ); + const data = result.structuredContent; + if ( + !data?.actions?.some( + (action) => + action.schemaName === "list" && + action.actionName === "listLists", + ) + ) { + throw new Error( + "Discovery did not return list.listLists. Check model/embedding configuration and server logs.", + ); + } + return { tools: names, discovery: data }; + } + return { tools: names }; + } finally { + await client.close(); + } +} + +export async function main(argv = process.argv.slice(2)) { + const options = parseArgs(argv); + if (options.help) { + process.stdout.write(`${help}\n`); + return; + } + if (process.platform !== "win32") + throw new Error( + "This E2E launcher currently supports Windows only (owned supervisor/worker tree cleanup).", + ); + if (Number(process.versions.node.split(".")[0]) < 22) + throw new Error("Node 22 or later is required."); + if ( + options.configDir && + (!existsSync(options.configDir) || + !statSync(options.configDir).isDirectory()) + ) + throw new Error( + `Configuration directory does not exist: ${options.configDir}`, + ); + if (!options.skipBuild || options.installDependencies) { + if (!existsSync(path.join(tsRoot, ".npmrc"))) + throw new Error( + "Provision ts\\.npmrc for the package feed first. This launcher does not copy credentials or run getKeys.", + ); + } + const copilot = + options.smokeTest && !options.installPlugin + ? undefined + : findExecutable("copilot"); + await checkPort(options.port); + const runDir = mkdtempSync(path.join(tmpdir(), "typeagent-discovery-e2e-")); + const { env, mcp } = makeConfiguration( + runDir, + options.port, + process.env, + options.configDir, + ); + mkdirSync(env.TYPEAGENT_PLUGIN_DATA); + const controller = new AbortController(); + const abort = () => controller.abort(new Error("E2E session interrupted")); + process.once("SIGINT", abort); + process.once("SIGTERM", abort); + let server; + process.stdout.write(`E2E logs and disposable data: ${runDir}\n`); + try { + if (options.installDependencies) { + const pm = packageManager("pnpm"); + await runCommand( + pm.command, + [...pm.prefix, "install", "--frozen-lockfile"], + process.env, + controller.signal, + ); + } + if (!options.skipBuild) { + // The root build script includes ".", which selects unrelated packages. + // --dep includes dependency tasks, not just their graph nodes. + const build = makeBuildCommand(tsRoot); + if (!existsSync(build.args[0])) + throw new Error( + "Build dependencies are missing. Retry with --install-dependencies.", + ); + await runCommand( + build.command, + build.args, + process.env, + controller.signal, + ); + } + controller.signal.throwIfAborted(); + const entry = path.join( + tsRoot, + "packages", + "agentServer", + "server", + "dist", + "server.js", + ); + if (!existsSync(entry)) + throw new Error( + "Agent server is not built. Retry without --skip-build.", + ); + stageCopilotPlugin(path.join(runDir, "plugin")); + if (options.installPlugin) { + await runCommand( + process.execPath, + [path.join(pluginRoot, "scripts", "install-plugin.mjs")], + process.env, + controller.signal, + ); + } + const configPath = path.join(runDir, "mcp.json"); + writeFileSync(configPath, JSON.stringify(mcp, null, 2) + "\n"); + writeFileSync( + path.join(runDir, "prompt.txt"), + testPrompt + + "\n\nThen ask: Repeat the same action with the known contract and scope, without rediscovery. Ask again if confirmation is required.\n", + ); + await checkPort(options.port); + const stdout = openSync(path.join(runDir, "server.stdout.log"), "a"); + const stderr = openSync(path.join(runDir, "server.stderr.log"), "a"); + try { + server = startProcess( + process.execPath, + [ + entry, + "--port", + String(options.port), + "--config", + "test", + "--idle-timeout", + "1800", + ], + { cwd: tsRoot, env, stdio: ["ignore", stdout, stderr] }, + ); + } finally { + closeSync(stdout); + closeSync(stderr); + } + await waitForServer( + server, + options.port, + options.startupTimeout, + controller.signal, + path.join(runDir, "server.stderr.log"), + ); + const evidence = await probeMcp( + mcp.mcpServers["typeagent-e2e"], + env, + options.smokeTest, + controller.signal, + ); + writeFileSync( + path.join(runDir, "probe.json"), + JSON.stringify(evidence, null, 2) + "\n", + ); + if (options.smokeTest) { + process.stdout.write( + "PASS: real MCP catalog and list.listLists discovery. No action was executed.\n", + ); + } else { + process.stdout.write( + `Ready. Paste the following into Copilot (also saved in prompt.txt):\n\n${testPrompt}\n\n${ + !options.sameWindow + ? "Opening Copilot in a new window. Keep this window open; it owns the server. Ctrl+C here stops both.\n" + : "" + }`, + ); + const launch = options.sameWindow + ? runCommand + : (command, args, env, signal) => + runInNewWindow(command, args, env, signal, runDir); + await launch( + copilot, + [ + "--plugin-dir", + path.join(runDir, "plugin"), + "--disable-builtin-mcps", + ...[ + "typeagent", + "typeagent-workspace", + "typeagent-macros", + ].flatMap((name) => ["--disable-mcp-server", name]), + "--additional-mcp-config", + `@${configPath}`, + "--no-custom-instructions", + "--no-remote-export", + "--log-dir", + path.join(runDir, "copilot-logs"), + ...(options.model ? ["--model", options.model] : []), + ], + env, + controller.signal, + ); + } + } catch (error) { + throw new Error( + `${error.message}\nLogs and configuration retained at ${runDir}`, + { cause: error }, + ); + } finally { + try { + await stopProcess(server); + } finally { + process.removeListener("SIGINT", abort); + process.removeListener("SIGTERM", abort); + } + if (server) + process.stdout.write( + `Owned server stopped. Evidence retained at ${runDir}\n`, + ); + } +} + +if (path.resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} diff --git a/ts/packages/copilot-plugin/scripts/test/discovery-e2e.spec.mjs b/ts/packages/copilot-plugin/scripts/test/discovery-e2e.spec.mjs new file mode 100644 index 0000000000..cfb9f12249 --- /dev/null +++ b/ts/packages/copilot-plugin/scripts/test/discovery-e2e.spec.mjs @@ -0,0 +1,392 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { createServer } from "node:net"; +import { + mkdtempSync, + writeFileSync, + readFileSync, + existsSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { setTimeout as delay } from "node:timers/promises"; +import { + checkPort, + makeBuildCommand, + makeConfiguration, + parseArgs, + runCommand, + runInNewWindow, + startProcess, + startupFailure, + stopProcess, + testPrompt, + waitForServer, +} from "../discovery-e2e.mjs"; + +test("builds selected packages and dependency tasks incrementally without selecting the whole workspace", () => { + const root = path.resolve("workspace with spaces", "ts"); + const { command, args } = makeBuildCommand(root); + assert.equal(command, process.execPath); + assert.deepEqual(args, [ + path.join( + root, + "node_modules", + "@fluidframework", + "build-tools", + "bin", + "fluid-build", + ), + "^(@typeagent/copilot-plugin|agent-server)$", + "-t", + "build", + "--dep", + ]); + const selector = new RegExp(args[1]); + for (const name of ["@typeagent/copilot-plugin", "agent-server"]) + assert.ok(selector.test(name)); + for (const name of ["agent-shell", "agent-server-client", "other-package"]) + assert.ok(!selector.test(name)); + for (const flag of [".", "--all", "--force", "--rebuild", "--clean"]) + assert.ok(!args.includes(flag)); +}); + +test("startup diagnostics explain missing configuration without echoing sensitive logs", () => { + const folder = mkdtempSync(path.join(tmpdir(), "discovery-error-test-")); + const log = path.join(folder, "stderr.log"); + try { + writeFileSync( + log, + "secret=must-not-echo\nFatal startup error: Error: Missing ApiSetting: AZURE_OPENAI_ENDPOINT\n", + ); + const error = startupFailure({ code: 1 }, log); + assert.match( + error.message, + /Missing model configuration: AZURE_OPENAI_ENDPOINT/, + ); + assert.match(error.message, /--config-dir/); + assert.ok(error.message.includes(log)); + assert.doesNotMatch(error.message, /must-not-echo/); + writeFileSync(log, "Another failure containing secret=must-not-echo\n"); + const other = startupFailure({ code: 2 }, log); + assert.match(other.message, /before readiness: 2/); + assert.doesNotMatch( + other.message, + /Missing model configuration|must-not-echo/, + ); + } finally { + rmSync(log, { force: true }); + rmSync(folder, { recursive: true }); + } +}); + +test("validates options without silently ignoring misspellings or unsafe ports", () => { + assert.deepEqual(parseArgs([]), { port: 9024, startupTimeout: 120 }); + assert.equal(parseArgs(["--same-window"]).sameWindow, true); + assert.deepEqual( + parseArgs(["--port", "9321", "--skip-build", "--smoke-test"]), + { + port: 9321, + startupTimeout: 120, + skipBuild: true, + smokeTest: true, + }, + ); + for (const args of [ + ["--port", "0"], + ["--port", "65536"], + ["--port", "-2"], + ["--port", "9024.5"], + ["--port"], + ["--model", "--help"], + ["--startup-timeout", "0"], + ["--unknown"], + ]) + assert.throws(() => parseArgs(args)); +}); + +test( + "new Windows console preserves argv, environment, cwd, completion and failure", + { skip: process.platform !== "win32" }, + async () => { + const folder = mkdtempSync( + path.join(tmpdir(), "discovery console ' & "), + ); + const output = path.join(folder, "result.json"); + const args = [ + "model with spaces", + '{"quoted":"value with spaces"}', + "trailing\\", + "", + 'embedded"quote', + "$value;&'literal", + ]; + try { + await runInNewWindow( + process.execPath, + [ + "-e", + `setTimeout(() => require("node:fs").writeFileSync(${JSON.stringify(output)}, JSON.stringify({ args: process.argv.slice(1), cwd: process.cwd(), marker: process.env.DISCOVERY_WINDOW_TEST, tty: [process.stdin.isTTY, process.stdout.isTTY, process.stderr.isTTY] })), 100);`, + ...args, + ], + { ...process.env, DISCOVERY_WINDOW_TEST: "isolated child" }, + new AbortController().signal, + folder, + ); + const result = JSON.parse(readFileSync(output, "utf8")); + assert.deepEqual(result.args, args); + assert.equal(result.marker, "isolated child"); + assert.deepEqual(result.tty, [true, true, true]); + const status = JSON.parse( + readFileSync(path.join(folder, "console-status.json"), "utf8"), + ); + assert.equal(status.phase, "exited"); + assert.equal(status.code, 0); + assert.ok(status.childPid > 0); + assert.equal( + result.cwd, + path.resolve(import.meta.dirname, "..", "..", "..", ".."), + ); + await assert.rejects( + runInNewWindow( + process.execPath, + ["-e", "process.exit(17)"], + process.env, + new AbortController().signal, + folder, + ), + /exited with 17/, + ); + await assert.rejects( + runInNewWindow( + path.join(folder, "missing.exe"), + [], + process.env, + new AbortController().signal, + folder, + ), + /New console:.*ENOENT.*console-status.json/, + ); + } finally { + rmSync(folder, { recursive: true, force: true }); + } + }, +); + +test("pre-aborted new-window launch writes no files and starts no child", async () => { + const folder = mkdtempSync(path.join(tmpdir(), "discovery-pre-abort-")); + try { + await assert.rejects( + runInNewWindow( + process.execPath, + [], + process.env, + AbortSignal.abort(new Error("already interrupted")), + folder, + ), + /already interrupted/, + ); + assert.equal( + existsSync(path.join(folder, "console-command.mjs")), + false, + ); + assert.equal( + existsSync(path.join(folder, "console-status.json")), + false, + ); + } finally { + rmSync(folder, { recursive: true, force: true }); + } +}); + +test( + "interrupting the new Windows console stops its owned command and descendants", + { skip: process.platform !== "win32" }, + async () => { + const folder = mkdtempSync( + path.join(tmpdir(), "discovery-console-stop-"), + ); + const output = path.join(folder, "pids.json"); + const controller = new AbortController(); + const command = runInNewWindow( + process.execPath, + [ + "-e", + `const child = require("node:child_process").spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" }); +child.once("spawn", () => require("node:fs").writeFileSync(${JSON.stringify(output)}, JSON.stringify([process.pid, child.pid]))); +setInterval(() => {}, 1000);`, + ], + process.env, + controller.signal, + folder, + ); + const rejected = assert.rejects(command, /window interruption/); + try { + const deadline = Date.now() + 15000; + while (!existsSync(output) && Date.now() < deadline) + await delay(50); + assert.ok(existsSync(output), "Window child must start"); + const pids = JSON.parse(readFileSync(output, "utf8")); + controller.abort(new Error("window interruption")); + await rejected; + for (const pid of pids) + assert.throws(() => process.kill(pid, 0), { code: "ESRCH" }); + } finally { + controller.abort(new Error("window interruption")); + await rejected; + rmSync(folder, { recursive: true, force: true }); + } + }, +); + +test("isolates data, saved conversations and hook mode without copying credentials into MCP JSON", () => { + const inherited = { + TYPEAGENT_CONVERSATION_ID: "existing-owner", + TYPEAGENT_PLUGIN_DATA: "existing-plugin-data", + TYPEAGENT_HOST: "remote-server", + TYPEAGENT_CONFIG_DIR: "existing-config", + API_SECRET: "never-write-to-json", + }; + const before = { ...inherited }; + const runDir = path.resolve("folder with spaces", "test-run"); + const { env, mcp } = makeConfiguration( + runDir, + 9025, + inherited, + "explicit-config", + ); + assert.deepEqual(inherited, before); + assert.equal(env.TYPEAGENT_CONVERSATION_ID, undefined); + assert.equal(env.TYPEAGENT_CONFIG_DIR, "explicit-config"); + assert.equal(env.TYPEAGENT_MODE, "bypass"); + assert.equal(env.TYPEAGENT_HOST, "127.0.0.1"); + assert.equal(env.TYPEAGENT_PLUGIN_DATA, path.join(runDir, "plugin-data")); + assert.equal(env.TYPEAGENT_USER_DATA_DIR, path.join(runDir, "data")); + const server = JSON.parse(JSON.stringify(mcp)).mcpServers["typeagent-e2e"]; + assert.equal(server.env.TYPEAGENT_MODE, "mcp"); + assert.equal(server.env.TYPEAGENT_PORT, "9025"); + assert.equal(server.command, process.execPath); + assert.deepEqual(server.args, [ + path.join(runDir, "plugin", "dist", "mcp", "server.js"), + ]); + assert.ok(!JSON.stringify(mcp).includes("never-write-to-json")); + assert.ok(!JSON.stringify(mcp).includes("existing-owner")); + assert.match(testPrompt, /wait for my answer/); + assert.equal( + makeConfiguration(runDir, 9025, inherited).env.TYPEAGENT_CONFIG_DIR, + "existing-config", + ); +}); + +test("refuses an occupied port without affecting its owner", async () => { + const server = createServer(); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const port = server.address().port; + try { + await assert.rejects(checkPort(port), /unavailable/); + assert.equal(server.listening, true); + } finally { + await new Promise((resolve) => server.close(resolve)); + } + await checkPort(port); +}); + +test("reports spawn failures and stops only the owned live process", async () => { + const missing = startProcess( + path.resolve("nonexistent-e2e-executable"), + [], + { stdio: "ignore" }, + ); + assert.ok((await missing.completion).error); + await stopProcess(missing); + const owned = startProcess( + process.execPath, + ["-e", "setInterval(() => {}, 1000)"], + { stdio: "ignore" }, + ); + await once(owned.child, "spawn"); + await stopProcess(owned); + assert.ok(owned.child.exitCode !== null || owned.child.signalCode !== null); + await stopProcess(owned); +}); + +test("readiness reports early exit, timeout and interruption", async () => { + const listener = createServer(); + listener.listen(0, "127.0.0.1"); + await once(listener, "listening"); + const port = listener.address().port; + await new Promise((resolve) => listener.close(resolve)); + const signal = new AbortController().signal; + await assert.rejects( + waitForServer( + { completion: Promise.resolve({ code: 17 }) }, + port, + 1, + signal, + ), + /exited before readiness: 17/, + ); + const pending = { completion: new Promise(() => {}) }; + await assert.rejects( + waitForServer(pending, port, 0.01, signal), + /timed out/, + ); + await assert.rejects( + waitForServer( + pending, + port, + 1, + AbortSignal.abort(new Error("interrupted")), + ), + /interrupted/, + ); +}); + +test("interrupting a running command stops it and preserves the abort reason", async () => { + const controller = new AbortController(); + const command = runCommand( + process.execPath, + ["-e", "setInterval(() => {}, 1000)"], + process.env, + controller.signal, + ); + const rejected = assert.rejects(command, /test interruption/); + await delay(100); + controller.abort(new Error("test interruption")); + await rejected; +}); + +test( + "Windows cleanup terminates the owned descendant as well", + { skip: process.platform !== "win32" }, + async () => { + const owned = startProcess( + process.execPath, + [ + "-e", + ` + const { spawn } = require("node:child_process"); + const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" }); + child.once("spawn", () => console.log(child.pid)); + setInterval(() => {}, 1000); + `, + ], + { stdio: ["ignore", "pipe", "inherit"] }, + ); + try { + const [chunk] = await once(owned.child.stdout, "data"); + const descendant = Number(chunk.toString().trim()); + assert.ok(Number.isInteger(descendant) && descendant > 0); + await stopProcess(owned); + assert.throws(() => process.kill(descendant, 0), { code: "ESRCH" }); + } finally { + await stopProcess(owned); + } + }, +); diff --git a/ts/packages/copilot-plugin/src/hooks/hook-direct.ts b/ts/packages/copilot-plugin/src/hooks/hook-direct.ts index 0877c0cba5..26b91dfd9e 100644 --- a/ts/packages/copilot-plugin/src/hooks/hook-direct.ts +++ b/ts/packages/copilot-plugin/src/hooks/hook-direct.ts @@ -16,6 +16,7 @@ import { import { createClientIO, connectToTypeAgent, + formatPendingNaturalLanguageInteraction, } from "../shared/typeagent-client.js"; import { emitProgress } from "../shared/hook-progress.js"; import type { HookInput, HookOutput } from "./types.js"; @@ -75,7 +76,15 @@ export async function handleDirect( dependencies.emitProgress("Routing to TypeAgent...", { temporary: true }); const responseCollector = { messages: [] as string[] }; + const pendingPrompts: unknown[] = []; + const pendingResult = (): HookOutput => ({ + handled: true, + responseContent: + formatPendingNaturalLanguageInteraction(pendingPrompts), + handledBy: "typeagent", + }); const clientIO = createClientIO({ + onPendingPrompt: (prompt) => pendingPrompts.push(prompt), onSetDisplay: (message) => { collectMessage(message, undefined, responseCollector); }, @@ -139,6 +148,7 @@ export async function handleDirect( }); const result = await awaitCommand(dispatcher, input.prompt); + if (pendingPrompts.length > 0) return pendingResult(); if (options.forceHandled) { return toForcedCommandOutput(result, responseCollector.messages); } @@ -170,6 +180,7 @@ export async function handleDirect( handledBy: "typeagent", }; } catch (error) { + if (pendingPrompts.length > 0) return pendingResult(); console.error("TypeAgent error:", error); if (options.forceHandled) { return { diff --git a/ts/packages/copilot-plugin/src/hooks/hook-mcp-redirect.ts b/ts/packages/copilot-plugin/src/hooks/hook-mcp-redirect.ts index e98aa0084a..0c680cb872 100644 --- a/ts/packages/copilot-plugin/src/hooks/hook-mcp-redirect.ts +++ b/ts/packages/copilot-plugin/src/hooks/hook-mcp-redirect.ts @@ -73,6 +73,9 @@ export function handleMcpRedirect(input: HookInput): HookOutput { "Simply call typeagent-processCommand immediately, then present the COMPLETE result to the user.", "CRITICAL: Display the tool result in FULL — do NOT summarize, truncate, or paraphrase it.", "The tool result is the authoritative response. Show it exactly as returned.", + "This directive preserves the user's natural-language request. For subsequent actions selected by Copilot during orchestration, use typeagent-searchActions -> typeagent-executeAction with concrete typed inputs instead. Search returns complete contracts; reuse a current contract in the same scope without rediscovery.", + "Skip search for a known identity and reuse a current contract in the same binding; status/schema listing is not required. Keep unresolved references on processCommand or ask the user.", + "On requires_interaction show the full prompt/form and ask the USER before typeagent-continueAction, or use typeagent-cancelAction at their request. Never autoapprove or use defaults. Refresh stale contracts without automatic replay; never replay uncertain delivery.", prefixGuidance, psGuidance, ].join("\n"), diff --git a/ts/packages/copilot-plugin/src/hooks/hook-router.ts b/ts/packages/copilot-plugin/src/hooks/hook-router.ts index 84e3f4c85b..fb449e038b 100644 --- a/ts/packages/copilot-plugin/src/hooks/hook-router.ts +++ b/ts/packages/copilot-plugin/src/hooks/hook-router.ts @@ -35,8 +35,8 @@ import { } from "../shared/plugin-config.js"; const modeDescriptions: Record = { - direct: "Hook handles requests directly, bypassing the LLM. Workspace macro tools remain available.", - mcp: "Hook redirects to the TypeAgent MCP tool. Workspace macro tools remain available.", + direct: "Hook handles user natural language directly. Copilot-selected structured actions use the persistent TypeAgent MCP tools. Workspace macro tools remain available.", + mcp: "Hook redirects user natural language to processCommand; Copilot-selected actions use searchActions and executeAction. Workspace macro tools remain available.", dev: "TypeAgent handles registered PowerShell flows and recording directives; other requests fall through to Copilot. Workspace macro tools remain available.", bypass: "TypeAgent is disabled. All requests bypass TypeAgent routing and fall through to other handlers.", }; diff --git a/ts/packages/copilot-plugin/src/mcp/agentServer.ts b/ts/packages/copilot-plugin/src/mcp/agentServer.ts new file mode 100644 index 0000000000..ea5467d228 --- /dev/null +++ b/ts/packages/copilot-plugin/src/mcp/agentServer.ts @@ -0,0 +1,394 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * TypeAgent MCP Server for Copilot CLI. + * + * Exposes TypeAgent dispatcher operations as MCP tools, allowing the + * Copilot LLM to delegate action requests to TypeAgent. + * + * Uses MCP progress notifications to stream display messages to the + * Copilot CLI timeline in real-time as TypeAgent processes the command. + * + * Connection to TypeAgent is lazy — established on first tool call, + * not during MCP server startup. + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { Dispatcher, IAgentMessage } from "@typeagent/agent-server-client"; +import type { DisplayAppendMode } from "@typeagent/agent-sdk"; +import { + createClientIO, + connectToTypeAgent, + formatPendingNaturalLanguageInteraction, + submitCancellableCommand, + TYPEAGENT_URL, +} from "../shared/typeagent-client.js"; +import { extractMessageText } from "../shared/message-formatter.js"; +import { getMode } from "../shared/plugin-config.js"; +import type { StructuredActionClient } from "@typeagent/agent-server-client"; +import { createStructuredActionClient } from "../shared/structured-action-client.js"; +import { registerStructuredActionTools } from "./structuredActionTools.js"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function stripAnsi(text: string): string { + return text.replace(/\x1b\[[0-9;]*m/g, ""); +} + +function toolResult(text: string): CallToolResult { + return { content: [{ type: "text", text }] }; +} + +function toolError(text: string): CallToolResult { + return { isError: true, content: [{ type: "text", text }] }; +} + +/** + * Format a large result for display. Strips markdown formatting and wraps + * in a code fence so the CLI preserves newlines and structured layout. + */ +function formatLargeResult(response: string): CallToolResult { + const lines = response.split("\n").length; + if (lines > 5) { + // Strip markdown bold (**text**) — doesn't render inside code fences + const plain = response.replace(/\*\*([^*]+)\*\*/g, "$1"); + return toolResult("```\n" + plain + "\n```"); + } + return toolResult(response); +} + +function log(message: string): void { + process.stderr.write( + `[${new Date().toISOString()}] [typeagent-mcp] ${message}\n`, + ); +} + +// Type for the extra parameter passed to tool callbacks +interface ToolExtra { + _meta?: { + progressToken?: string | number; + }; + sendNotification: (notification: { + method: string; + params: Record; + }) => Promise; + signal: AbortSignal; +} + +// ── Server ─────────────────────────────────────────────────────────────────── + +export class TypeAgentMcpServer { + readonly server: McpServer; + private readonly structuredClient: StructuredActionClient; + + constructor(structuredClient = createStructuredActionClient()) { + this.structuredClient = structuredClient; + this.server = new McpServer({ + name: "typeagent", + version: "0.1.0", + }); + this.registerTools(); + registerStructuredActionTools(this.server, this.structuredClient); + } + + async close(): Promise { + await this.structuredClient.close(); + await this.server.close(); + } + + async start(): Promise { + const transport = new StdioServerTransport(); + await this.server.connect(transport); + const mode = getMode(); + log( + `TypeAgent MCP server started (target: ${TYPEAGENT_URL}, mode: ${mode})`, + ); + } + + private registerTools(): void { + this.server.registerTool( + "typeagent-processCommand", + { + title: "TypeAgent Command Processor", + description: + "Send a natural language command to TypeAgent for processing. " + + "Use this for action requests like scheduling meetings, sending emails, " + + "playing music, controlling the browser, managing lists, etc. " + + "Do NOT use this for general knowledge questions. " + + "CRITICAL: Preserve special prefixes EXACTLY as written - do NOT strip them: " + + "'learn:', 'dev:', 'record:', 'dev: learn:'. " + + "These are TypeAgent directives that trigger special behavior (e.g., flow recording). " + + "If user says 'learn: create a playlist', pass 'learn: create a playlist' - NOT just 'create a playlist'. " + + "IMPORTANT: Always display the FULL output to the user exactly as returned. " + + "Do NOT summarize, truncate, or paraphrase the tool result. " + + "Present it in a code block if it contains a list or structured data.", + inputSchema: z.object({ + command: z + .string() + .describe( + "The natural language command to execute, including any special prefixes like 'learn:', 'dev:', 'record:'", + ), + }), + annotations: { + displayVerbatim: true, + } as Record, + _meta: { + "com.github/displayVerbatim": true, + }, + }, + async (params, extra) => + this.processCommand(params.command, extra as ToolExtra), + ); + + this.server.tool( + "typeagent-listAgents", + "List available TypeAgent agents and their capabilities.", + {}, + async () => this.listAgents(), + ); + + this.server.tool( + "typeagent-getStatus", + "Get the current TypeAgent dispatcher status.", + {}, + async () => this.getStatus(), + ); + + // TypeAgent PowerShell tools + this.server.tool( + "typeagent-powershell-list", + "List registered TypeAgent PowerShell flows. " + + "These are reusable automation scripts managed by TypeAgent's PowerShell agent " + + "that can be invoked by natural language.", + {}, + async () => this.processCommand("@powershell list"), + ); + + this.server.tool( + "typeagent-powershell-import", + "Import an existing PowerShell (.ps1) script file as a reusable TypeAgent PowerShell flow. " + + "The script is analyzed by TypeAgent's PowerShell agent and registered for future natural language invocation. " + + "Only .ps1 files are supported. The path can be absolute or relative to the working directory.", + { + filePath: z + .string() + .describe( + "Absolute or relative path to the .ps1 file to import", + ), + }, + async (params, extra) => { + const command = `@powershell import ${params.filePath}`; + return this.processCommand(command, extra as ToolExtra); + }, + ); + } + + /** + * Send an MCP progress notification if the client provided a progressToken. + */ + private async sendProgress( + extra: ToolExtra, + message: string, + progress: number, + total: number, + ): Promise { + if (extra._meta?.progressToken === undefined) return; + try { + await extra.sendNotification({ + method: "notifications/progress", + params: { + progressToken: extra._meta.progressToken, + progress, + total, + message, + }, + }); + } catch { + // Progress notifications are best-effort + } + } + + private async processCommand( + command: string, + extra?: ToolExtra, + ): Promise { + const disabled = this.getDisabledReason(); + if (disabled) { + return toolError(disabled); + } + log(`processCommand: ${command}`); + + const responseCollector = { messages: [] as string[] }; + const pendingPrompts: unknown[] = []; + let messageCount = 0; + let dispatcher: Dispatcher | null = null; + + try { + const clientIO = createClientIO({ + onPendingPrompt: (prompt) => pendingPrompts.push(prompt), + onSetDisplay: (message: IAgentMessage) => { + const text = extractMessageText(message); + if (text) { + const cleaned = stripAnsi(text); + responseCollector.messages.push(cleaned); + } + }, + onAppendDisplay: ( + message: IAgentMessage, + mode: DisplayAppendMode, + ) => { + const text = extractMessageText(message); + if (!text) return; + const cleaned = stripAnsi(text); + + if (mode === "temporary") { + // Temporary messages are status updates — stream as progress only + messageCount++; + if (extra) { + void this.sendProgress( + extra, + cleaned, + messageCount, + 0, + ); + } + return; + } + + // Emit progress for status/info/warning/error messages + // (reasoning "thinking", tool calls, and their results + // including error results). These are progress, not final + // content, so we stream them and skip responseCollector — + // keeping every tool call paired with its result. + const msg = message?.message; + if (typeof msg === "object" && msg && "kind" in msg) { + const kind = (msg as { kind: unknown }).kind; + if ( + kind === "info" || + kind === "status" || + kind === "warning" || + kind === "error" + ) { + messageCount++; + if (extra) { + void this.sendProgress( + extra, + cleaned, + messageCount, + 0, + ); + } + return; + } + } + + responseCollector.messages.push(cleaned); + }, + }); + + dispatcher = await connectToTypeAgent(clientIO); + const result = await submitCancellableCommand( + dispatcher, + command, + extra?.signal, + ); + + if (pendingPrompts.length > 0) { + return toolResult( + formatPendingNaturalLanguageInteraction(pendingPrompts), + ); + } + if (result?.lastError) { + return toolResult(`Error: ${result.lastError}`); + } + + if (result?.cancelled) { + return toolResult( + "TypeAgent request was cancelled; effects may already have occurred.", + ); + } + if (responseCollector.messages.length > 0) { + const response = responseCollector.messages.join("\n\n"); + return formatLargeResult(response); + } + + return toolResult( + "TypeAgent returned no display output. No completion is inferred from an empty response.", + ); + } catch (error) { + if (pendingPrompts.length > 0) { + return toolResult( + formatPendingNaturalLanguageInteraction(pendingPrompts), + ); + } + const msg = error instanceof Error ? error.message : String(error); + log(`processCommand error: ${msg}`); + return toolResult(`Error executing command: ${msg}`); + } finally { + if (dispatcher) { + await dispatcher.close(); + } + } + } + + private async listAgents(): Promise { + const disabled = this.getDisabledReason(); + if (disabled) { + return toolError(disabled); + } + let dispatcher: Dispatcher | null = null; + try { + const clientIO = createClientIO({}); + dispatcher = await connectToTypeAgent(clientIO); + const schemas = await dispatcher.getAgentSchemas(); + const agents = schemas.map((s) => ({ + name: s.name, + emoji: s.emoji, + description: s.description, + })); + return toolResult(JSON.stringify(agents, null, 2)); + } catch (error) { + return toolResult( + `Error listing agents: ${error instanceof Error ? error.message : String(error)}`, + ); + } finally { + if (dispatcher) { + await dispatcher.close(); + } + } + } + + private async getStatus(): Promise { + const disabled = this.getDisabledReason(); + if (disabled) { + return toolError(disabled); + } + let dispatcher: Dispatcher | null = null; + try { + const clientIO = createClientIO({}); + dispatcher = await connectToTypeAgent(clientIO); + const status = await dispatcher.getStatus(); + return toolResult(JSON.stringify(status, null, 2)); + } catch (error) { + return toolResult( + `Error getting status: ${error instanceof Error ? error.message : String(error)}`, + ); + } finally { + if (dispatcher) { + await dispatcher.close(); + } + } + } + + private getDisabledReason(): string | undefined { + const mode = getMode(); + if (mode === "dev" || mode === "bypass") { + return `TypeAgent agent-server MCP tools are disabled in ${mode} mode.`; + } + return undefined; + } +} diff --git a/ts/packages/copilot-plugin/src/mcp/server.ts b/ts/packages/copilot-plugin/src/mcp/server.ts index c619ec7dcf..007ba3c5b2 100644 --- a/ts/packages/copilot-plugin/src/mcp/server.ts +++ b/ts/packages/copilot-plugin/src/mcp/server.ts @@ -1,376 +1,27 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -/** - * TypeAgent MCP Server for Copilot CLI. - * - * Exposes TypeAgent dispatcher operations as MCP tools, allowing the - * Copilot LLM to delegate action requests to TypeAgent. - * - * Uses MCP progress notifications to stream display messages to the - * Copilot CLI timeline in real-time as TypeAgent processes the command. - * - * Connection to TypeAgent is lazy — established on first tool call, - * not during MCP server startup. - */ - -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { z } from "zod"; -import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import type { Dispatcher, IAgentMessage } from "@typeagent/agent-server-client"; -import { awaitCommand } from "@typeagent/dispatcher-types"; -import type { DisplayAppendMode } from "@typeagent/agent-sdk"; -import { - createClientIO, - connectToTypeAgent, - TYPEAGENT_URL, -} from "../shared/typeagent-client.js"; -import { extractMessageText } from "../shared/message-formatter.js"; -import { getMode } from "../shared/plugin-config.js"; +import { TypeAgentMcpServer } from "./agentServer.js"; import { TypeAgentMacroMcpServer } from "./macroServer.js"; -import { selectMcpServer } from "./serverSelector.js"; import { TypeAgentWorkspaceMcpServer } from "./workspaceServer.js"; +import { selectMcpServer } from "./serverSelector.js"; -// ── Helpers ────────────────────────────────────────────────────────────────── - -function stripAnsi(text: string): string { - return text.replace(/\x1b\[[0-9;]*m/g, ""); -} - -function toolResult(text: string): CallToolResult { - return { content: [{ type: "text", text }] }; -} - -function toolError(text: string): CallToolResult { - return { isError: true, content: [{ type: "text", text }] }; -} - -/** - * Format a large result for display. Strips markdown formatting and wraps - * in a code fence so the CLI preserves newlines and structured layout. - */ -function formatLargeResult(response: string): CallToolResult { - const lines = response.split("\n").length; - if (lines > 5) { - // Strip markdown bold (**text**) — doesn't render inside code fences - const plain = response.replace(/\*\*([^*]+)\*\*/g, "$1"); - return toolResult("```\n" + plain + "\n```"); - } - return toolResult(response); -} - -function log(message: string): void { - process.stderr.write( - `[${new Date().toISOString()}] [typeagent-mcp] ${message}\n`, - ); -} - -// Type for the extra parameter passed to tool callbacks -interface ToolExtra { - _meta?: { - progressToken?: string | number; - }; - sendNotification: (notification: { - method: string; - params: Record; - }) => Promise; - signal: AbortSignal; -} - -// ── Server ─────────────────────────────────────────────────────────────────── - -class TypeAgentMcpServer { - private server: McpServer; - - constructor() { - this.server = new McpServer({ - name: "typeagent", - version: "0.1.0", - }); - this.registerTools(); - } - - async start(): Promise { - const transport = new StdioServerTransport(); - await this.server.connect(transport); - const mode = getMode(); - log( - `TypeAgent MCP server started (target: ${TYPEAGENT_URL}, mode: ${mode})`, - ); - } - - private registerTools(): void { - this.server.registerTool( - "typeagent-processCommand", - { - title: "TypeAgent Command Processor", - description: - "Send a natural language command to TypeAgent for processing. " + - "Use this for action requests like scheduling meetings, sending emails, " + - "playing music, controlling the browser, managing lists, etc. " + - "Do NOT use this for general knowledge questions. " + - "CRITICAL: Preserve special prefixes EXACTLY as written - do NOT strip them: " + - "'learn:', 'dev:', 'record:', 'dev: learn:'. " + - "These are TypeAgent directives that trigger special behavior (e.g., flow recording). " + - "If user says 'learn: create a playlist', pass 'learn: create a playlist' - NOT just 'create a playlist'. " + - "IMPORTANT: Always display the FULL output to the user exactly as returned. " + - "Do NOT summarize, truncate, or paraphrase the tool result. " + - "Present it in a code block if it contains a list or structured data.", - inputSchema: z.object({ - command: z - .string() - .describe( - "The natural language command to execute, including any special prefixes like 'learn:', 'dev:', 'record:'", - ), - }), - annotations: { - displayVerbatim: true, - } as Record, - _meta: { - "com.github/displayVerbatim": true, - }, - }, - async (params, extra) => - this.processCommand(params.command, extra as ToolExtra), - ); - - this.server.tool( - "typeagent-listAgents", - "List available TypeAgent agents and their capabilities.", - {}, - async () => this.listAgents(), - ); - - this.server.tool( - "typeagent-getStatus", - "Get the current TypeAgent dispatcher status.", - {}, - async () => this.getStatus(), - ); - - // TypeAgent PowerShell tools - this.server.tool( - "typeagent-powershell-list", - "List registered TypeAgent PowerShell flows. " + - "These are reusable automation scripts managed by TypeAgent's PowerShell agent " + - "that can be invoked by natural language.", - {}, - async () => this.processCommand("@powershell list"), - ); - - this.server.tool( - "typeagent-powershell-import", - "Import an existing PowerShell (.ps1) script file as a reusable TypeAgent PowerShell flow. " + - "The script is analyzed by TypeAgent's PowerShell agent and registered for future natural language invocation. " + - "Only .ps1 files are supported. The path can be absolute or relative to the working directory.", - { - filePath: z - .string() - .describe( - "Absolute or relative path to the .ps1 file to import", - ), - }, - async (params, extra) => { - const command = `@powershell import ${params.filePath}`; - return this.processCommand(command, extra as ToolExtra); - }, - ); - } - - /** - * Send an MCP progress notification if the client provided a progressToken. - */ - private async sendProgress( - extra: ToolExtra, - message: string, - progress: number, - total: number, - ): Promise { - if (extra._meta?.progressToken === undefined) return; - try { - await extra.sendNotification({ - method: "notifications/progress", - params: { - progressToken: extra._meta.progressToken, - progress, - total, - message, - }, - }); - } catch { - // Progress notifications are best-effort - } - } - - private async processCommand( - command: string, - extra?: ToolExtra, - ): Promise { - const disabled = this.getDisabledReason(); - if (disabled) { - return toolError(disabled); - } - log(`processCommand: ${command}`); - - const responseCollector = { messages: [] as string[] }; - let messageCount = 0; - let dispatcher: Dispatcher | null = null; - - try { - const clientIO = createClientIO({ - onSetDisplay: (message: IAgentMessage) => { - const text = extractMessageText(message); - if (text) { - const cleaned = stripAnsi(text); - responseCollector.messages.push(cleaned); - } - }, - onAppendDisplay: ( - message: IAgentMessage, - mode: DisplayAppendMode, - ) => { - const text = extractMessageText(message); - if (!text) return; - const cleaned = stripAnsi(text); - - if (mode === "temporary") { - // Temporary messages are status updates — stream as progress only - messageCount++; - if (extra) { - void this.sendProgress( - extra, - cleaned, - messageCount, - 0, - ); - } - return; - } - - // Emit progress for status/info/warning/error messages - // (reasoning "thinking", tool calls, and their results - // including error results). These are progress, not final - // content, so we stream them and skip responseCollector — - // keeping every tool call paired with its result. - const msg = message?.message; - if (typeof msg === "object" && msg && "kind" in msg) { - const kind = (msg as { kind: unknown }).kind; - if ( - kind === "info" || - kind === "status" || - kind === "warning" || - kind === "error" - ) { - messageCount++; - if (extra) { - void this.sendProgress( - extra, - cleaned, - messageCount, - 0, - ); - } - return; - } - } - - responseCollector.messages.push(cleaned); - }, - }); - - dispatcher = await connectToTypeAgent(clientIO); - const result = await awaitCommand(dispatcher, command); - - if (result?.lastError) { - return toolResult(`Error: ${result.lastError}`); - } - - if (responseCollector.messages.length > 0) { - const response = responseCollector.messages.join("\n\n"); - return formatLargeResult(response); - } - - return toolResult(`Successfully executed: ${command}`); - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - log(`processCommand error: ${msg}`); - return toolResult(`Error executing command: ${msg}`); - } finally { - if (dispatcher) { - await dispatcher.close(); - } - } - } - - private async listAgents(): Promise { - const disabled = this.getDisabledReason(); - if (disabled) { - return toolError(disabled); - } - let dispatcher: Dispatcher | null = null; - try { - const clientIO = createClientIO({}); - dispatcher = await connectToTypeAgent(clientIO); - const schemas = await dispatcher.getAgentSchemas(); - const agents = schemas.map((s) => ({ - name: s.name, - emoji: s.emoji, - description: s.description, - })); - return toolResult(JSON.stringify(agents, null, 2)); - } catch (error) { - return toolResult( - `Error listing agents: ${error instanceof Error ? error.message : String(error)}`, - ); - } finally { - if (dispatcher) { - await dispatcher.close(); - } - } - } - - private async getStatus(): Promise { - const disabled = this.getDisabledReason(); - if (disabled) { - return toolError(disabled); - } - let dispatcher: Dispatcher | null = null; - try { - const clientIO = createClientIO({}); - dispatcher = await connectToTypeAgent(clientIO); - const status = await dispatcher.getStatus(); - return toolResult(JSON.stringify(status, null, 2)); - } catch (error) { - return toolResult( - `Error getting status: ${error instanceof Error ? error.message : String(error)}`, - ); - } finally { - if (dispatcher) { - await dispatcher.close(); - } - } - } - - private getDisabledReason(): string | undefined { - const mode = getMode(); - if (mode === "dev" || mode === "bypass") { - return `TypeAgent agent-server MCP tools are disabled in ${mode} mode.`; - } - return undefined; - } -} - -// ── Main ───────────────────────────────────────────────────────────────────── - -const serverKind = selectMcpServer(process.argv.slice(2)); +const kind = selectMcpServer(process.argv.slice(2)); const server = - serverKind === "workspace" + kind === "workspace" ? new TypeAgentWorkspaceMcpServer() - : serverKind === "macros" + : kind === "macros" ? new TypeAgentMacroMcpServer() : new TypeAgentMcpServer(); -server.start().catch((error) => { - log(`Fatal error: ${error}`); - process.exit(1); + +if (server instanceof TypeAgentMcpServer) { + // Stdio has no intrinsic Copilot session identity. This process retains one + // private structured owner until shutdown, without cancelling pending work. + process.once("SIGINT", () => void server.close()); + process.once("SIGTERM", () => void server.close()); + process.stdin.once("end", () => void server.close()); +} +server.start().catch(() => { + console.error("Unable to start the TypeAgent MCP server."); + process.exitCode = 1; }); diff --git a/ts/packages/copilot-plugin/src/mcp/structuredActionTools.ts b/ts/packages/copilot-plugin/src/mcp/structuredActionTools.ts new file mode 100644 index 0000000000..da8483c0e0 --- /dev/null +++ b/ts/packages/copilot-plugin/src/mcp/structuredActionTools.ts @@ -0,0 +1,255 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; +import type { + ExecuteActionRequest, + ContinueActionRequest, + CancelActionRequest, +} from "@typeagent/dispatcher-types"; +import { getMode, type Mode } from "../shared/plugin-config.js"; +import { + StructuredActionClientError, + type StructuredActionClient, +} from "@typeagent/agent-server-client"; + +const identity = { + schemaName: z.string(), + actionName: z.string(), +}; +const envelope = { + protocolVersion: z.literal(1), + scopeId: z.string(), +}; +const fieldAnswer = z.discriminatedUnion("kind", [ + z + .object({ + kind: z.literal("pick"), + selected: z.number(), + text: z.string().optional(), + }) + .strict(), + z + .object({ + kind: z.literal("multiChoice"), + selected: z.array(z.number()), + text: z.string().optional(), + }) + .strict(), + z.object({ kind: z.literal("yesNo"), value: z.boolean() }).strict(), +]); +const response = z.discriminatedUnion("type", [ + z + .object({ type: z.literal("confirmation"), approved: z.boolean() }) + .strict(), + z.object({ type: z.literal("question"), selected: z.number() }).strict(), + z.object({ type: z.literal("yesNo"), value: z.boolean() }).strict(), + z + .object({ + type: z.literal("multiChoice"), + selected: z.array(z.number()), + }) + .strict(), + z + .object({ + type: z.literal("pickRemember"), + selected: z.number(), + remember: z.boolean(), + }) + .strict(), + z + .object({ + type: z.literal("form"), + value: z + .object({ + answers: z.record(fieldAnswer), + cancelled: z.boolean().optional(), + }) + .strict(), + }) + .strict(), + z + .object({ + type: z.literal("proposal"), + accepted: z.boolean(), + data: z.unknown().optional(), + }) + .strict(), +]); + +function result(data: Record): CallToolResult { + return { + structuredContent: data, + content: [{ type: "text", text: JSON.stringify(data, null, 2) }], + ...(data.status !== undefined && + data.status !== "completed" && + data.status !== "requires_interaction" + ? { isError: true } + : {}), + }; +} + +/** + * A transport-only mapping. Contracts, effects, validation and user interaction + * state all belong to Dispatcher, not to this tool catalog. + * + * These same tools are the structured caller in Direct mode: unlike a one-shot + * prompt hook, the MCP process can retain the private binding across user turns. + */ +export function registerStructuredActionTools( + server: McpServer, + client: StructuredActionClient, + mode: () => Mode = getMode, +): void { + // MCP arguments are JSON: optional properties are absent, never undefined. + // Zod 3 adds undefined to optional inferred types, so the calls below narrow + // only that type-level difference. Dispatcher validates the actual contract. + async function invoke( + operation: ( + client: StructuredActionClient, + ) => Promise>, + effect: boolean, + allowWhenDisabled = false, + ): Promise { + if (!allowWhenDisabled && mode() !== "direct" && mode() !== "mcp") { + return { + ...result({ + error: "Structured action tools require Direct or MCP mode.", + }), + isError: true, + }; + } + try { + const data = await operation(client); + return result({ ...data, binding: client.binding }); + } catch (error) { + const submitted = + error instanceof StructuredActionClientError + ? error.dispatched + : true; + // Never infer completion, rollback, or retry safety from a lost RPC + // reply. Do not echo transport exceptions or connection capabilities. + return { + ...result({ + status: + effect && submitted + ? "execution_uncertain" + : "unavailable", + error: { + code: + error instanceof StructuredActionClientError + ? error.reason + : "transport_error", + message: + error instanceof StructuredActionClientError + ? error.message + : effect && submitted + ? "No authoritative result was received. Effects may have occurred. Do not replay this call. Use a known operation/interaction id to continue or cancel only after checking with the user." + : submitted + ? "No authoritative discovery result was received. Check the connection and request inputs; no call was retried." + : "The structured request was not dispatched. Check the binding, connection, and caller cancellation.", + }, + source: "copilot-transport", + ...(client.binding.conversationId === undefined + ? {} + : { conversationId: client.binding.conversationId }), + }), + isError: true, + }; + } + } + + server.registerTool( + "typeagent-searchActions", + { + description: + "Search complete TypeAgent action contracts with a required free-text query. Each candidate includes its exact identity, closed input types, effects, output and interaction requirements. Select a candidate and execute with concrete parameters; no separate contract/status/schema-list stage. Reuse a current contract in the same scope without repeating discovery. Discovery does not authorize execution. Clarify unresolved inputs or use natural language.", + inputSchema: z + .object({ + query: z.string().trim().min(1), + }) + .strict(), + }, + (request, extra) => + invoke( + (dispatcher) => dispatcher.searchActions(request, extra.signal), + false, + ), + ); + + server.registerTool( + "typeagent-executeAction", + { + description: + "Execute one Copilot-selected typed action through Dispatcher using its exact schemaName/actionName, scopeId and concrete parameters. The service resolves the current definition and revalidates parameters, availability and confirmation policy before effects, independently of discovery ranking. No command strings or NL translation. Unknown/state-changing effects require USER confirmation; selection is not consent. Preserve all six result statuses and true nested results. For requires_interaction show the full prompt/form and ask the USER, then continue or cancel. Never invent/default/autoapprove a response or replay an uncertain call. Recording directives stay on processCommand with exact prefixes.", + inputSchema: z + .object({ + ...identity, + ...envelope, + parameters: z.record(z.unknown()).optional(), + }) + .strict(), + }, + (request, extra) => + invoke( + (dispatcher) => + dispatcher.executeAction( + request as ExecuteActionRequest, + extra.signal, + ), + true, + ), + ); + + server.registerTool( + "typeagent-continueAction", + { + description: + "Submit the actual USER response to a pending TypeAgent prompt using its exact operationId, interactionId and scopeId. Show all choices/form fields to the user first. Never choose a default or approve on the user's behalf. A new requires_interaction needs another user response; pending interaction is not completion or a tool error.", + inputSchema: z + .object({ + ...envelope, + operationId: z.string(), + interactionId: z.string(), + response, + }) + .strict(), + }, + (request, extra) => + invoke( + (dispatcher) => + dispatcher.continueAction( + request as ContinueActionRequest, + extra.signal, + ), + true, + ), + ); + + server.registerTool( + "typeagent-cancelAction", + { + description: + "Cancel a pending TypeAgent operation at the USER's request with its exact scopeId/operationId and interactionId when supplied. Remains available after switching out of Direct/MCP mode so pending work can be stopped. Return the authoritative service status; cancellation or disconnect is not proof effects were rolled back.", + inputSchema: z + .object({ + ...envelope, + operationId: z.string(), + interactionId: z.string().optional(), + }) + .strict(), + }, + (request, extra) => + invoke( + (dispatcher) => + dispatcher.cancelAction( + request as CancelActionRequest, + extra.signal, + ), + true, + true, + ), + ); +} diff --git a/ts/packages/copilot-plugin/src/shared/plugin-config.ts b/ts/packages/copilot-plugin/src/shared/plugin-config.ts index 9bb9d827a6..a837223c50 100644 --- a/ts/packages/copilot-plugin/src/shared/plugin-config.ts +++ b/ts/packages/copilot-plugin/src/shared/plugin-config.ts @@ -9,6 +9,8 @@ export type Mode = "direct" | "mcp" | "dev" | "bypass"; export interface PluginConfig { mode: Mode; + /** Public server conversation id, never a structured resume capability. */ + conversationId?: string; powershell?: { enabled?: boolean; }; diff --git a/ts/packages/copilot-plugin/src/shared/structured-action-client.ts b/ts/packages/copilot-plugin/src/shared/structured-action-client.ts new file mode 100644 index 0000000000..54c854fc99 --- /dev/null +++ b/ts/packages/copilot-plugin/src/shared/structured-action-client.ts @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { StructuredActionClient } from "@typeagent/agent-server-client"; +import { randomUUID } from "node:crypto"; +import { createClientIO, TYPEAGENT_URL } from "./typeagent-client.js"; +import { readConfig } from "./plugin-config.js"; + +/** Plugin configuration only; transport and private binding live in the client package. */ +export function createStructuredActionClient(): StructuredActionClient { + const conversationId = + process.env.TYPEAGENT_CONVERSATION_ID ?? readConfig()?.conversationId; + return new StructuredActionClient({ + url: TYPEAGENT_URL, + clientIO: createClientIO({}), + createConversationName: () => + `Copilot structured actions ${randomUUID()}`, + ...(conversationId === undefined ? {} : { conversationId }), + }); +} diff --git a/ts/packages/copilot-plugin/src/shared/tool-identities.ts b/ts/packages/copilot-plugin/src/shared/tool-identities.ts index 66e77c9786..a1940dc201 100644 --- a/ts/packages/copilot-plugin/src/shared/tool-identities.ts +++ b/ts/packages/copilot-plugin/src/shared/tool-identities.ts @@ -2,6 +2,10 @@ // Licensed under the MIT License. const TYPEAGENT_AGENT_SERVER_TOOLS = [ + "typeagent-searchactions", + "typeagent-executeaction", + "typeagent-continueaction", + "typeagent-cancelaction", "typeagent-processcommand", "typeagent-listagents", "typeagent-getstatus", diff --git a/ts/packages/copilot-plugin/src/shared/typeagent-client.ts b/ts/packages/copilot-plugin/src/shared/typeagent-client.ts index 7c93bbd4f7..8202d0e769 100644 --- a/ts/packages/copilot-plugin/src/shared/typeagent-client.ts +++ b/ts/packages/copilot-plugin/src/shared/typeagent-client.ts @@ -13,10 +13,14 @@ import { type Dispatcher, type IAgentMessage, } from "@typeagent/agent-server-client"; +import { randomUUID } from "node:crypto"; import type { DisplayAppendMode } from "@typeagent/agent-sdk"; -import type { - RequestId, - TemplateEditConfig, +import { + QueueFullError, + ServerStoppingError, + type CommandResult, + type RequestId, + type TemplateEditConfig, } from "@typeagent/dispatcher-types"; export const TYPEAGENT_HOST = process.env.TYPEAGENT_HOST || "localhost"; @@ -26,6 +30,16 @@ export const TYPEAGENT_URL = `ws://${TYPEAGENT_HOST}:${TYPEAGENT_PORT}`; export interface DisplayCallbacks { onSetDisplay?: (message: IAgentMessage) => void; onAppendDisplay?: (message: IAgentMessage, mode: DisplayAppendMode) => void; + onPendingPrompt?: (prompt: unknown) => void; +} + +export function formatPendingNaturalLanguageInteraction( + prompts: unknown[], +): string { + return ( + "USER interaction required. This natural-language call cannot be continued through structured-action tools. No answer was supplied and completion is not implied.\n" + + JSON.stringify(prompts, null, 2) + ); } /** @@ -50,13 +64,20 @@ export function createClientIO(callbacks: DisplayCallbacks): ClientIO { _actionTemplates: TemplateEditConfig, _source: string, ): Promise { - return undefined; + callbacks.onPendingPrompt?.(_actionTemplates); + throw new Error( + "A user action-proposal response is required; this natural-language client cannot supply one.", + ); }, notify(): void {}, async openLocalView(): Promise {}, async closeLocalView(): Promise {}, - requestChoice(): void {}, - requestForm(): void {}, + requestChoice(...args: unknown[]): void { + callbacks.onPendingPrompt?.({ type: "choice", arguments: args }); + }, + requestForm(...args: unknown[]): void { + callbacks.onPendingPrompt?.({ type: "form", arguments: args }); + }, takeAction(): void {}, shutdown(): void {}, async question( @@ -66,9 +87,28 @@ export function createClientIO(callbacks: DisplayCallbacks): ClientIO { defaultId?: number, _source?: string, ): Promise { - return defaultId ?? Math.max(choices.length - 1, 0); + callbacks.onPendingPrompt?.({ + type: "question", + message: _message, + choices, + defaultId, + }); + throw new Error( + "A user answer is required; this natural-language client cannot choose a default.", + ); + }, + async askForm(_requestId: RequestId | undefined, form: unknown) { + callbacks.onPendingPrompt?.({ type: "form", form }); + throw new Error( + "A user form response is required; this natural-language client cannot supply one.", + ); + }, + requestInteraction(...args: unknown[]): void { + callbacks.onPendingPrompt?.({ + type: "interaction", + arguments: args, + }); }, - requestInteraction(): void {}, interactionResolved(): void {}, interactionCancelled(): void {}, } as ClientIO; @@ -89,3 +129,44 @@ export async function connectToTypeAgent( export function connectToAgentServer(): Promise { return connectAgentServer(TYPEAGENT_URL); } + +/** Preserve the user's exact NL/directive text and cancel without replay. */ +export async function submitCancellableCommand( + dispatcher: Dispatcher, + command: string, + signal?: AbortSignal, +): Promise { + if (signal?.aborted) return { cancelled: true }; + const clientRequestId = `copilot-plugin-${randomUUID()}`; + let requestId: string | undefined; + const cancel = () => { + try { + if (requestId === undefined) { + dispatcher.cancelCommandByClientId(clientRequestId); + } else { + void dispatcher.cancelCommand(requestId).catch(() => {}); + } + } catch { + // Cancellation is best-effort. A failure does not imply rollback. + } + }; + signal?.addEventListener("abort", cancel, { once: true }); + try { + const submitted = await dispatcher.submitCommand( + command, + undefined, + undefined, + clientRequestId, + ); + if (!submitted.ok) { + throw submitted.error === "queue_full" + ? new QueueFullError(submitted.maxDepth) + : new ServerStoppingError(); + } + requestId = submitted.entry.requestId; + if (signal?.aborted) cancel(); + return await submitted.entry.completion; + } finally { + signal?.removeEventListener("abort", cancel); + } +} diff --git a/ts/packages/copilot-plugin/test/hookDevActions.spec.ts b/ts/packages/copilot-plugin/test/hookDevActions.spec.ts index 8e294a96b2..a8fbb7242c 100644 --- a/ts/packages/copilot-plugin/test/hookDevActions.spec.ts +++ b/ts/packages/copilot-plugin/test/hookDevActions.spec.ts @@ -232,8 +232,11 @@ describe("Copilot dev actions hook", () => { expect(cancelCommand).toHaveBeenCalledWith("request-1"); }); - it("defaults unattended interactions to denial", async () => { - const clientIO = createClientIO({}); + it("reports full unattended questions without choosing any default", async () => { + const prompts: unknown[] = []; + const clientIO = createClientIO({ + onPendingPrompt: (prompt) => prompts.push(prompt), + }); await expect( clientIO.question( @@ -243,15 +246,29 @@ describe("Copilot dev actions hook", () => { undefined, "powershell", ), - ).resolves.toBe(1); + ).rejects.toThrow("A user answer is required"); await expect( clientIO.question( undefined, "Allow action?", ["Run", "Cancel"], - 1, + 0, "powershell", ), - ).resolves.toBe(1); + ).rejects.toThrow("A user answer is required"); + expect(prompts).toEqual([ + { + type: "question", + message: "Allow action?", + choices: ["Run", "Cancel"], + defaultId: undefined, + }, + { + type: "question", + message: "Allow action?", + choices: ["Run", "Cancel"], + defaultId: 0, + }, + ]); }); }); diff --git a/ts/packages/copilot-plugin/test/hookDirect.spec.ts b/ts/packages/copilot-plugin/test/hookDirect.spec.ts index e0737adf0d..e558b2b3a2 100644 --- a/ts/packages/copilot-plugin/test/hookDirect.spec.ts +++ b/ts/packages/copilot-plugin/test/hookDirect.spec.ts @@ -79,6 +79,40 @@ function setDisplay(content: DisplayContent): EmitDisplay { const forced = { forceHandled: true }; describe("direct TypeAgent hook", () => { + it.each([false, true])( + "preserves pending user input instead of claiming completion (forced: %s)", + async (forceHandled) => { + const { dependencies, close } = createDependencies({}, (io) => { + io.requestChoice( + { + requestId: "request-1", + connectionId: "connection-1", + }, + "choice-1", + "yesNo", + "Apply the change?", + ["Yes", "No"], + "test-agent", + ); + }); + const result = await handleDirect( + input, + { forceHandled }, + dependencies, + ); + expect(result.handled).toBe(true); + expect(result.responseContent).toContain( + "USER interaction required", + ); + expect(result.responseContent).toContain("Apply the change?"); + expect(result.responseContent).toContain("choice-1"); + expect(result.responseContent).not.toContain( + "TypeAgent completed the command", + ); + expect(close).toHaveBeenCalledTimes(1); + }, + ); + it("returns a warning without duplicating it as persistent progress", async () => { const { dependencies, close, emitProgress } = createDependencies( {}, diff --git a/ts/packages/copilot-plugin/test/hookRouter.spec.ts b/ts/packages/copilot-plugin/test/hookRouter.spec.ts index b4160eccb4..fbe867cbfe 100644 --- a/ts/packages/copilot-plugin/test/hookRouter.spec.ts +++ b/ts/packages/copilot-plugin/test/hookRouter.spec.ts @@ -26,6 +26,26 @@ function createDependencies(claimed: boolean): RoutePromptDependencies { } describe("macro recording routing override", () => { + it.each([ + "list the playlists", + "learn: create a playlist", + "dev: create a playlist", + "record: create a playlist", + "dev: learn: create a playlist", + 'keep "quotes", 東京 and\nnewlines', + ])("keeps the exact Direct user prompt: %s", async (prompt) => { + const dependencies = createDependencies(false); + const request = { ...input, prompt }; + await routePrompt( + request, + "direct", + new AbortController().signal, + dependencies, + ); + expect(dependencies.direct).toHaveBeenCalledWith(request); + expect(dependencies.mcp).not.toHaveBeenCalled(); + expect(dependencies.dev).not.toHaveBeenCalled(); + }); it.each(["direct", "mcp", "dev"] as const)( "falls through one claimed interaction in %s mode", async (mode) => { diff --git a/ts/packages/copilot-plugin/test/naturalLanguageClient.spec.ts b/ts/packages/copilot-plugin/test/naturalLanguageClient.spec.ts new file mode 100644 index 0000000000..45d2098164 --- /dev/null +++ b/ts/packages/copilot-plugin/test/naturalLanguageClient.spec.ts @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { jest } from "@jest/globals"; +import type { Dispatcher } from "@typeagent/agent-server-client"; +import { + createClientIO, + submitCancellableCommand, +} from "../src/shared/typeagent-client.js"; + +describe("unchanged user-originated natural-language requests", () => { + it.each([ + "list my playlists", + "learn: create a playlist", + "dev: create a playlist", + "record: create a playlist", + "dev: learn: create a playlist", + 'read "東京"\nIDs: 007, α\\β', + ])( + "submits exact text without structured reinterpretation: %s", + async (command) => { + const submitCommand = jest.fn(async () => ({ + ok: true, + entry: { + requestId: "id", + completion: Promise.resolve(undefined), + }, + })); + const dispatcher = { submitCommand } as unknown as Dispatcher; + await submitCancellableCommand(dispatcher, command); + expect(submitCommand).toHaveBeenCalledWith( + command, + undefined, + undefined, + expect.stringMatching(/^copilot-plugin-/), + ); + }, + ); + + it("does not submit a command for an already-cancelled caller", async () => { + const submitCommand = jest.fn(); + const dispatcher = { submitCommand } as unknown as Dispatcher; + const controller = new AbortController(); + controller.abort(); + await expect( + submitCancellableCommand( + dispatcher, + "learn: keep exact", + controller.signal, + ), + ).resolves.toEqual({ cancelled: true }); + expect(submitCommand).not.toHaveBeenCalled(); + }); + + it("reports complete legacy forms and choices instead of supplying answers", () => { + const prompts: unknown[] = []; + const io = createClientIO({ + onPendingPrompt: (value) => prompts.push(value), + }); + const requestId = { requestId: "request", connectionId: "connection" }; + io.requestChoice( + requestId, + 'id-"東京"', + "multiChoice", + "Choose", + ["one", "two"], + "fixture", + ); + expect(prompts[0]).toEqual({ + type: "choice", + arguments: [ + requestId, + 'id-"東京"', + "multiChoice", + "Choose", + ["one", "two"], + "fixture", + ], + }); + }); +}); diff --git a/ts/packages/copilot-plugin/test/pluginArtifact.spec.ts b/ts/packages/copilot-plugin/test/pluginArtifact.spec.ts index 74e3d0abd7..3a9d91eb8d 100644 --- a/ts/packages/copilot-plugin/test/pluginArtifact.spec.ts +++ b/ts/packages/copilot-plugin/test/pluginArtifact.spec.ts @@ -40,6 +40,45 @@ describe("staged plugin artifact", () => { expect(bundle).toContain('from "@github/copilot-sdk/extension"'); }); + it("registers the structured Direct bridge in the actual bundled agent server", async () => { + const pluginRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "..", + ); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [path.join(pluginRoot, "dist/mcp/server.js")], + stderr: "pipe", + }); + const client = new Client({ + name: "structured-artifact-test", + version: "1", + }); + try { + await client.connect(transport); + const catalog = await client.listTools(); + expect(catalog.tools.map((tool) => tool.name)).toEqual( + expect.arrayContaining([ + "typeagent-searchActions", + "typeagent-executeAction", + "typeagent-continueAction", + "typeagent-cancelAction", + "typeagent-processCommand", + ]), + ); + expect(catalog.tools.map((tool) => tool.name)).not.toContain( + "typeagent-getActionContract", + ); + expect( + catalog.tools.find( + (tool) => tool.name === "typeagent-searchActions", + )?.inputSchema.required, + ).toEqual(["query"]); + } finally { + await client.close(); + } + }); it("starts the bundled macro server declared by .mcp.json", async () => { const testDir = path.dirname(fileURLToPath(import.meta.url)); const pluginRoot = path.resolve(testDir, "..", ".."); diff --git a/ts/packages/copilot-plugin/test/structuredActionClient.spec.ts b/ts/packages/copilot-plugin/test/structuredActionClient.spec.ts new file mode 100644 index 0000000000..895dc3291a --- /dev/null +++ b/ts/packages/copilot-plugin/test/structuredActionClient.spec.ts @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { createStructuredActionClient } from "../src/shared/structured-action-client.js"; +import { StructuredActionClient } from "@typeagent/agent-server-client"; + +describe("plugin structured client configuration", () => { + it("uses the shared public connector with the configured public conversation id", async () => { + const saved = process.env.TYPEAGENT_CONVERSATION_ID; + process.env.TYPEAGENT_CONVERSATION_ID = "configured-public-id"; + try { + const client = createStructuredActionClient(); + expect(client).toBeInstanceOf(StructuredActionClient); + expect(client.binding).toEqual({ + conversationId: "configured-public-id", + connected: false, + }); + expect(JSON.stringify(client)).toBe("{}"); + await client.close(); + } finally { + if (saved === undefined) + delete process.env.TYPEAGENT_CONVERSATION_ID; + else process.env.TYPEAGENT_CONVERSATION_ID = saved; + } + }); +}); diff --git a/ts/packages/copilot-plugin/test/structuredActionFixture.ts b/ts/packages/copilot-plugin/test/structuredActionFixture.ts new file mode 100644 index 0000000000..e60b168897 --- /dev/null +++ b/ts/packages/copilot-plugin/test/structuredActionFixture.ts @@ -0,0 +1,346 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Uses the same real Dispatcher context/service as the lower-layer offline +// structured execution tests. Only the agent and connection delivery are fake. +import { randomUUID } from "node:crypto"; +import type { + ActionResult, + AppAgent, + AppAgentManifest, + ReadinessReport, +} from "@typeagent/agent-sdk"; +import { ChoiceManager } from "@typeagent/agent-sdk/helpers/action"; +import type { AppAgentProvider } from "agent-dispatcher"; +import { + initializeCommandHandlerContext, + closeCommandHandlerContext, + createDispatcherFromContext, +} from "agent-dispatcher/internal"; +import type { + AgentServerConnection, + DispatcherConnectOptions, +} from "@typeagent/agent-server-client"; +import { createClientIO } from "../src/shared/typeagent-client.js"; + +export const oddValue = ' ID: α/東京 "quoted" \\ path\n@action --flag\t💡 '; +export const form = { + message: "Supply every USER answer", + paged: true, + fields: [ + { + id: oddValue, + kind: "pick" as const, + prompt: "Which one?", + choices: ["first", oddValue], + allowFreeText: true, + }, + { + id: "many", + kind: "multiChoice" as const, + prompt: "Choose", + choices: [oddValue, "other"], + }, + { + id: "yes", + kind: "yesNo" as const, + prompt: "Really?", + defaultValue: true, + }, + ], +}; +const manifest: AppAgentManifest = { + description: "Offline MCP structured integration", + emojiChar: "", + schema: { + description: "Selected contracts", + schemaType: "Actions", + schemaFile: { + format: "ts", + content: ` + export type Actions = Read | Write | Clear | Other; + type Params = { text: string; ids: string[]; nested: Nested; mode?: string }; + type Nested = { name: string; count: number }; + type Read = { actionName: "read"; parameters: Params }; + type Write = { actionName: "write"; parameters: Params }; + type Clear = { actionName: "clear" }; + type Other = { actionName: "other"; parameters: { unrelated: boolean } }; + `, + }, + actionPolicies: { + read: { effects: "read-only" }, + write: { effects: "state-changing" }, + }, + }, +}; + +export async function structuredFixture() { + let readiness: ReadinessReport = { state: "ready" }; + let effects = 0; + let handlers = 0; + let callbacks = 0; + const submitted: unknown[] = []; + const joins: DispatcherConnectOptions[] = []; + const created: string[] = []; + const choices = new ChoiceManager(); + const disconnects: (() => void)[] = []; + const owners = new Map(); + let rejectResume = false; + let resumeRejection: string | undefined; + let failExecute = false; + let waitForExecution: (() => void) | undefined; + let releaseExecution: (() => void) | undefined; + let lastResponse: unknown; + const complete = (): ActionResult => ({ + displayContent: { type: "html", content: `${oddValue}` }, + historyText: "Real result", + entities: [{ name: oddValue, type: ["Item"], uniqueId: oddValue }], + resultEntity: { name: oddValue, type: ["Item"], uniqueId: oddValue }, + resultValue: { + ids: [oddValue, "", "007"], + nested: { values: [null, true, { name: oddValue }] }, + }, + }); + const agent: AppAgent = { + checkReadiness: async () => readiness, + handleChoice: (id, response, context) => + choices.handleChoice(id, response, context), + cancelChoice: async (id) => { + choices.cancelChoice(id); + }, + executeAction: async (action, actionContext) => { + handlers++; + submitted.push(structuredClone(action)); + const mode = action.parameters?.mode; + if (mode === "throw") throw new Error("fixture action failed"); + if (mode === "hold") { + await new Promise((resolve) => { + releaseExecution = resolve; + waitForExecution?.(); + }); + } + if (mode === "question") { + lastResponse = await actionContext.sessionContext.popupQuestion( + oddValue, + [oddValue, "No"], + 0, + ); + } + if (mode === "blockingForm") { + lastResponse = await context.clientIO.askForm!( + context.currentRequestId, + form, + "fixture", + ); + } + if (mode === "child") { + effects++; + return { + ...complete(), + additionalActions: [ + { + actionName: "read", + parameters: { + text: "child", + ids: [oddValue], + nested: { name: oddValue, count: 1 }, + }, + }, + ], + }; + } + if (mode === "choice" || mode === "form") { + const choiceId = choices.registerChoice(async (response) => { + callbacks++; + lastResponse = response; + effects++; + return complete(); + }); + return { + entities: [], + pendingChoice: + mode === "form" + ? { type: "form", choiceId, ...form } + : { + type: "multiChoice", + choiceId, + message: oddValue, + choices: [oddValue, "second"], + }, + }; + } + effects++; + return complete(); + }, + }; + const provider: AppAgentProvider = { + getAppAgentNames: () => ["fixture"], + getAppAgentManifest: async () => manifest, + loadAppAgent: async () => agent, + unloadAppAgent: async () => {}, + }; + const context = await initializeCommandHandlerContext( + "plugin-structured-test", + { + agents: { schemas: ["fixture"], actions: ["fixture"] }, + appAgentProviders: [provider], + translation: { enabled: false }, + explainer: { enabled: false }, + cache: { enabled: false }, + collectCommandResult: true, + metrics: true, + conversationMemorySettings: { + requestKnowledgeExtraction: false, + actionResultEntityStorage: false, + actionResultKnowledgeExtraction: false, + }, + clientIO: createClientIO({}), + }, + ); + + const connect = async (onDisconnect: () => void) => { + let active = true; + const disconnect = () => { + active = false; + onDisconnect(); + }; + disconnects.push(disconnect); + const connection: Pick< + AgentServerConnection, + | "listConversations" + | "createConversation" + | "joinConversation" + | "close" + > = { + listConversations: async () => [], + createConversation: async (name: string) => { + created.push(name); + return { + conversationId: randomUUID(), + name, + clientCount: 0, + messageCount: 0, + createdAt: new Date().toISOString(), + }; + }, + joinConversation: async (_io, options) => { + if ( + !options?.conversationId || + options.structuredActions === undefined + ) { + throw new Error("Explicit bound join required"); + } + joins.push(structuredClone(options)); + const token = options.structuredActions.resumeToken; + let owner = token === undefined ? undefined : owners.get(token); + if ( + token !== undefined && + (rejectResume || + !owner || + owner.conversationId !== options.conversationId) + ) { + throw new Error( + resumeRejection ?? + `Invalid private capability ${token}`, + ); + } + if (!owner) { + owner = { + scope: {}, + conversationId: options.conversationId, + }; + } + const resumeToken = token ?? randomUUID(); + owners.set(resumeToken, owner); + const scope = owner.scope; + const dispatcher = createDispatcherFromContext( + context, + randomUUID(), + undefined, + () => ({ + scope, + isActive: () => active, + canDiscoverSchema: () => true, + }), + ); + const execute = dispatcher.executeAction.bind(dispatcher); + dispatcher.executeAction = async (request) => { + const value = await execute(request); + if (failExecute) { + disconnect(); + throw new Error("Lost effect reply"); + } + return value; + }; + return { + dispatcher, + conversationId: options.conversationId, + name: "Test", + connectionId: randomUUID(), + structuredActions: { resumeToken }, + }; + }, + close: async () => disconnect(), + }; + return connection as AgentServerConnection; + }; + return { + connect, + joins, + created, + submitted, + context, + get effects() { + return effects; + }, + get handlers() { + return handlers; + }, + get callbacks() { + return callbacks; + }, + get lastResponse() { + return lastResponse; + }, + get owners() { + return owners.size; + }, + disconnect: () => disconnects.at(-1)!(), + rejectResume: (message?: string) => { + rejectResume = true; + resumeRejection = message; + }, + loseEffectReply: () => { + failExecute = true; + }, + held: () => + new Promise((resolve) => { + waitForExecution = resolve; + }), + release: () => releaseExecution?.(), + unready: async () => { + readiness = { state: "setup-required", message: "Setup required" }; + await context.agents.refreshReadiness("fixture"); + }, + disable: () => { + const agents = context.agents as unknown as { + agents: Map }>; + }; + agents.agents.get("fixture")!.actions.delete("fixture"); + }, + removeAction: (actionName: string) => { + const config = context.agents.getActionConfig("fixture"); + context.agents + .getActionSchemaFileForConfig(config) + .parsedActionSchema.actionSchemas.delete(actionName); + }, + requireReadConfirmation: () => { + const config = context.agents.getActionConfig("fixture"); + config.actionPolicies = { + ...config.actionPolicies, + read: { effects: "state-changing" }, + }; + }, + close: () => closeCommandHandlerContext(context), + }; +} diff --git a/ts/packages/copilot-plugin/test/structuredActionTools.spec.ts b/ts/packages/copilot-plugin/test/structuredActionTools.spec.ts new file mode 100644 index 0000000000..4380830423 --- /dev/null +++ b/ts/packages/copilot-plugin/test/structuredActionTools.spec.ts @@ -0,0 +1,605 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { jest } from "@jest/globals"; +import type { + ActionSearchResult, + ExecuteActionRequest, + StructuredActionExecutionResult, + StructuredActionResponse, +} from "@typeagent/dispatcher-types"; +import { TypeAgentMcpServer } from "../src/mcp/agentServer.js"; +import { StructuredActionClient } from "@typeagent/agent-server-client"; +import { + structuredFixture, + oddValue, + form, +} from "./structuredActionFixture.js"; + +type Pending = Extract< + StructuredActionExecutionResult, + { status: "requires_interaction" } +>; +function pending(result: StructuredActionExecutionResult): Pending { + if (result.status !== "requires_interaction") { + throw new Error(`Expected pending, got ${JSON.stringify(result)}`); + } + return result; +} +const parameters = { + text: oddValue, + ids: [oddValue, "007", ""], + nested: { name: oddValue, count: 42 }, +}; +const formResponse: StructuredActionResponse = { + type: "form", + value: { + answers: { + [oddValue]: { kind: "pick", selected: -1, text: oddValue }, + many: { kind: "multiChoice", selected: [0, 1] }, + yes: { kind: "yesNo", value: false }, + }, + }, +}; + +describe("real MCP protocol over the shared real structured Dispatcher", () => { + let fixture: Awaited>; + let connector: StructuredActionClient; + let server: TypeAgentMcpServer; + let client: Client; + const oldMode = process.env.TYPEAGENT_MODE; + beforeEach(async () => { + process.env.TYPEAGENT_MODE = "direct"; + fixture = await structuredFixture(); + connector = new StructuredActionClient({ + connect: fixture.connect, + conversationId: "explicit-public-conversation", + }); + server = new TypeAgentMcpServer(connector); + client = new Client({ name: "real-plugin-test", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.server.connect(serverTransport); + await client.connect(clientTransport); + }); + afterEach(async () => { + fixture.release(); + await client.close(); + await server.close(); + await fixture.close(); + if (oldMode === undefined) delete process.env.TYPEAGENT_MODE; + else process.env.TYPEAGENT_MODE = oldMode; + }); + + async function call( + name: string, + args: Record, + ): Promise { + const response = await client.callTool({ + name: `typeagent-${name}`, + arguments: args, + }); + const status = ( + response.structuredContent as { status?: string } | undefined + )?.status; + expect(response.isError).toBe( + status !== undefined && + !["completed", "requires_interaction"].includes(status) + ? true + : undefined, + ); + expect(response.content).toEqual([ + { + type: "text", + text: JSON.stringify(response.structuredContent, null, 2), + }, + ]); + return response.structuredContent as T; + } + async function request( + actionName = "read", + mode?: string, + ): Promise { + const found = await call("searchActions", { + query: actionName, + }); + if ( + !found.actions.some( + (action) => + action.schemaName === "fixture" && + action.actionName === actionName, + ) + ) + throw new Error("Missing fixture contract"); + return { + protocolVersion: 1, + scopeId: found.scopeId, + schemaName: "fixture", + actionName, + ...(actionName === "clear" + ? {} + : { parameters: { ...parameters, ...(mode ? { mode } : {}) } }), + }; + } + const execute = (input: ExecuteActionRequest) => + call("executeAction", input); + const answer = (input: Pending, response: StructuredActionResponse) => + call("continueAction", { + protocolVersion: 1, + scopeId: input.scopeId, + operationId: input.operationId, + interactionId: input.interactionId, + response, + }); + + it.each(["direct", "mcp"] as const)( + "exposes real fixed tools in %s mode and preserves nested typed values", + async (mode) => { + process.env.TYPEAGENT_MODE = mode; + const tools = await client.listTools(); + expect(tools.tools.map((tool) => tool.name)).toEqual( + expect.arrayContaining([ + "typeagent-processCommand", + "typeagent-searchActions", + "typeagent-executeAction", + "typeagent-continueAction", + "typeagent-cancelAction", + ]), + ); + expect(tools.tools.map((tool) => tool.name)).not.toContain( + "typeagent-getActionContract", + ); + const searchTool = tools.tools.find( + (tool) => tool.name === "typeagent-searchActions", + )!; + expect(searchTool.inputSchema.required).toEqual(["query"]); + expect(Object.keys(searchTool.inputSchema.properties!)).toEqual([ + "query", + ]); + const executionTool = tools.tools.find( + (tool) => tool.name === "typeagent-executeAction", + )!; + expect(executionTool.inputSchema.properties).not.toHaveProperty( + "fingerprint", + ); + const candidates = await call("searchActions", { + query: "write", + }); + expect(candidates.actions).toHaveLength(1); + expect(candidates).toMatchObject({ + binding: { + conversationId: "explicit-public-conversation", + connected: true, + }, + }); + expect(candidates.actions[0]).toMatchObject({ + schemaName: "fixture", + actionName: "write", + }); + const contract = candidates.actions[0]; + expect(contract.input.schemaText).toContain("Nested"); + expect(contract.input.schemaText).not.toContain("unrelated"); + expect(contract).not.toHaveProperty("fingerprint"); + expect(contract).not.toHaveProperty("availability"); + const input: ExecuteActionRequest = { + protocolVersion: candidates.protocolVersion, + scopeId: candidates.scopeId, + schemaName: contract.schemaName, + actionName: contract.actionName, + parameters, + }; + const confirmation = pending(await execute(input)); + expect(confirmation.prompt.type).toBe("confirmation"); + expect(fixture.effects).toBe(0); + expect(fixture.handlers).toBe(0); + // Represents a separate user turn, not an adapter-supplied default. + const completed = await answer(confirmation, { + type: "confirmation", + approved: true, + }); + expect(completed.status).toBe("completed"); + expect(fixture.submitted).toEqual([ + expect.objectContaining({ + actionName: "write", + parameters, + }), + ]); + expect(completed.results[0].result).toMatchObject({ + resultEntity: { uniqueId: oddValue }, + entities: [{ uniqueId: oddValue }], + resultValue: { + ids: [oddValue, "", "007"], + nested: { values: [null, true, { name: oddValue }] }, + }, + displayContent: { type: "html", content: `${oddValue}` }, + }); + expect(fixture.joins).toHaveLength(1); + expect(fixture.joins[0]).toEqual({ + conversationId: "explicit-public-conversation", + structuredActions: {}, + }); + }, + ); + + it("unknown-policy clear requires consent even with no parameters", async () => { + const confirmation = pending(await execute(await request("clear"))); + expect(confirmation.prompt).toMatchObject({ + type: "confirmation", + contract: { + policy: { effects: "unknown", confirmation: "required" }, + }, + }); + expect(fixture.effects).toBe(0); + expect( + ( + await answer(confirmation, { + type: "confirmation", + approved: false, + }) + ).status, + ).toBe("cancelled"); + expect(fixture.effects).toBe(0); + }); + + it.each(["removed", "invalid", "disabled", "readiness", "scope"] as const)( + "%s rejects before any handler or effect", + async (kind) => { + const input = await request(); + if (kind === "removed") fixture.removeAction(input.actionName); + if (kind === "invalid") + input.parameters = { ...parameters, ids: 42 }; + if (kind === "disabled") fixture.disable(); + if (kind === "readiness") await fixture.unready(); + if (kind === "scope") input.scopeId += "foreign"; + const actual = await execute(input); + expect(actual.status).toBe( + kind === "removed" || + kind === "disabled" || + kind === "readiness" + ? "unavailable" + : "failed", + ); + expect(fixture.handlers).toBe(0); + expect(fixture.effects).toBe(0); + }, + ); + + it.each([{}, { query: "" }, { query: " " }, { query: "read", limit: 1 }])( + "rejects invalid query-only discovery arguments before connecting: %j", + async (arguments_) => { + const result = await client.callTool({ + name: "typeagent-searchActions", + arguments: arguments_, + }); + expect(result.isError).toBe(true); + expect(fixture.joins).toHaveLength(0); + expect(fixture.handlers).toBe(0); + }, + ); + + it("executes a known contract independently of the latest semantic candidate set", async () => { + const input = await request(); + const rank = jest + .spyOn(fixture.context.agents, "rankActionCandidates") + .mockResolvedValue([]); + try { + const search = await call("searchActions", { + query: "read", + }); + expect(search.actions).toEqual([]); + expect(search.scopeId).toBe(input.scopeId); + expect((await execute(input)).status).toBe("completed"); + expect(rank).toHaveBeenCalledTimes(1); + expect(fixture.handlers).toBe(1); + } finally { + rank.mockRestore(); + } + }); + + it("rechecks current confirmation policy rather than trusting discovery-time policy", async () => { + const input = await request(); + fixture.requireReadConfirmation(); + const interaction = pending(await execute(input)); + expect(interaction.prompt).toMatchObject({ + type: "confirmation", + contract: { + policy: { effects: "state-changing", confirmation: "required" }, + }, + }); + expect(fixture.handlers).toBe(0); + expect( + ( + await answer(interaction, { + type: "confirmation", + approved: true, + }) + ).status, + ).toBe("completed"); + expect(fixture.effects).toBe(1); + }); + + it.each(["question", "choice", "form", "blockingForm"] as const)( + "returns full %s prompt and resumes only a USER response", + async (mode) => { + const interaction = pending( + await execute(await request("read", mode)), + ); + expect(interaction.interactionId).toEqual(expect.any(String)); + expect(interaction.operationId).toEqual(expect.any(String)); + expect(fixture.effects).toBe(0); + if (mode === "question") { + expect(interaction.prompt).toEqual({ + type: "question", + message: oddValue, + choices: [oddValue, "No"], + defaultId: 0, + }); + } else if (mode === "choice") { + expect(interaction.prompt).toMatchObject({ + type: "multiChoice", + message: oddValue, + choices: [oddValue, "second"], + }); + } else { + expect(interaction.prompt).toEqual({ type: "form", ...form }); + } + const response: StructuredActionResponse = + mode === "question" + ? { type: "question", selected: 1 } + : mode === "choice" + ? { type: "multiChoice", selected: [1] } + : formResponse; + const completed = await answer(interaction, response); + expect(completed.status).toBe("completed"); + expect(fixture.effects).toBe(1); + // Finished operations return their retained terminal result. This + // does not invoke the handler or consume the response a second time. + expect(await answer(interaction, response)).toEqual(completed); + expect(fixture.effects).toBe(1); + }, + ); + + it("rejects wrong interaction ids without consuming the choice, then cancels by exact id", async () => { + const interaction = pending( + await execute(await request("read", "choice")), + ); + const wrong = { ...interaction, interactionId: "wrong" }; + expect( + (await answer(wrong, { type: "multiChoice", selected: [0] })) + .status, + ).toBe("failed"); + expect(fixture.callbacks).toBe(0); + const cancelled = await call( + "cancelAction", + { + protocolVersion: 1, + scopeId: interaction.scopeId, + operationId: interaction.operationId, + interactionId: interaction.interactionId, + }, + ); + // The service cannot promise no effects after entering the handler, + // even though this offline fixture knows its callback has not run. + expect(cancelled.status).toBe("execution_uncertain"); + expect(fixture.effects).toBe(0); + expect(fixture.callbacks).toBe(0); + }); + + it("cancels confirmation before any effect is possible", async () => { + const interaction = pending(await execute(await request("write"))); + const cancelled = await call( + "cancelAction", + { + protocolVersion: 1, + scopeId: interaction.scopeId, + operationId: interaction.operationId, + interactionId: interaction.interactionId, + }, + ); + expect(cancelled.status).toBe("cancelled"); + expect(fixture.handlers).toBe(0); + expect(fixture.effects).toBe(0); + }); + + it("rejects a fresh owner on the same public conversation and resumes the original owner", async () => { + const interaction = pending(await execute(await request("write"))); + const foreign = new StructuredActionClient({ + connect: fixture.connect, + conversationId: connector.binding.conversationId!, + }); + try { + const rejected = await foreign.continueAction({ + protocolVersion: 1, + scopeId: interaction.scopeId, + operationId: interaction.operationId, + interactionId: interaction.interactionId, + response: { type: "confirmation", approved: true }, + }); + expect(rejected.status).toBe("failed"); + await foreign.close(); + expect(fixture.effects).toBe(0); + expect( + ( + await answer(interaction, { + type: "confirmation", + approved: true, + }) + ).status, + ).toBe("completed"); + } finally { + await foreign.close(); + } + }); + + it("reconnects with the SAME id and private token, preserving scope and pending operations", async () => { + const interaction = pending(await execute(await request("write"))); + fixture.disconnect(); + const resumed = await call("searchActions", { + query: "write", + }); + expect(resumed.scopeId).toBe(interaction.scopeId); + expect(fixture.joins[1].conversationId).toBe( + fixture.joins[0].conversationId, + ); + expect(fixture.joins[1].structuredActions?.resumeToken).toEqual( + expect.any(String), + ); + expect(JSON.stringify(resumed)).not.toContain( + fixture.joins[1].structuredActions!.resumeToken!, + ); + expect( + ( + await answer(interaction, { + type: "confirmation", + approved: true, + }) + ).status, + ).toBe("completed"); + expect(fixture.owners).toBe(1); + }); + + it("fails closed on rejected resume without leaking token or creating a fresh owner", async () => { + await request(); + fixture.disconnect(); + fixture.rejectResume(); + const actual = await client.callTool({ + name: "typeagent-searchActions", + arguments: { query: "read" }, + }); + expect(actual.isError).toBe(true); + expect(fixture.owners).toBe(1); + const token = fixture.joins[1].structuredActions!.resumeToken!; + expect(JSON.stringify(actual)).not.toContain(token); + expect(actual.structuredContent).toMatchObject({ + error: { code: "resume_failed" }, + }); + }); + + it("exposes authoritative resume rejection as a safe reason, not a generic transport error", async () => { + await request(); + fixture.disconnect(); + fixture.rejectResume( + "Structured action resume state is unavailable; do not replay an interrupted action", + ); + const actual = await client.callTool({ + name: "typeagent-searchActions", + arguments: { query: "read" }, + }); + expect(actual.structuredContent).toMatchObject({ + status: "unavailable", + error: { + code: "resume_rejected", + message: expect.stringContaining("session or host restart"), + }, + }); + expect(fixture.owners).toBe(1); + expect(JSON.stringify(actual)).not.toContain( + fixture.joins[1].structuredActions!.resumeToken!, + ); + }); + + it("reports an ambiguous effect reply without replaying", async () => { + const input = await request(); + fixture.loseEffectReply(); + const actual = await client.callTool({ + name: "typeagent-executeAction", + arguments: input, + }); + expect(actual.structuredContent).toMatchObject({ + status: "execution_uncertain", + source: "copilot-transport", + }); + expect(fixture.effects).toBe(1); + expect(fixture.handlers).toBe(1); + expect(fixture.joins).toHaveLength(1); + }); + + it("does not replay when the MCP caller times out during execution", async () => { + const input = await request("read", "hold"); + const held = fixture.held(); + const abort = new AbortController(); + const callResult = client + .callTool( + { name: "typeagent-executeAction", arguments: input }, + undefined, + { signal: abort.signal }, + ) + .catch((error: unknown) => error); + await held; + abort.abort(); + await callResult; + fixture.release(); + await new Promise((resolve) => setImmediate(resolve)); + expect(fixture.handlers).toBe(1); + expect(fixture.joins).toHaveLength(1); + }); + + it("preserves authoritative execution failure instead of inventing completion", async () => { + expect((await execute(await request("read", "throw"))).status).toBe( + "failed", + ); + expect(fixture.effects).toBe(0); + }); + + it("retains parent and child ActionResult envelopes without synthesizing display data", async () => { + const completed = await execute(await request("read", "child")); + expect(completed.status).toBe("completed"); + expect(completed.results).toHaveLength(2); + for (const entry of completed.results) { + expect(entry.result).toMatchObject({ + resultValue: { + ids: [oddValue, "", "007"], + nested: { values: [null, true, { name: oddValue }] }, + }, + }); + } + expect(fixture.handlers).toBe(2); + expect(fixture.effects).toBe(2); + }); + + it.each(["dev", "bypass"])( + "retains explicit cancellation but blocks continuation after switching to %s", + async (mode) => { + const interaction = pending(await execute(await request("clear"))); + process.env.TYPEAGENT_MODE = mode; + const denied = await client.callTool({ + name: "typeagent-continueAction", + arguments: { + protocolVersion: 1, + scopeId: interaction.scopeId, + operationId: interaction.operationId, + interactionId: interaction.interactionId, + response: { type: "confirmation", approved: true }, + }, + }); + expect(denied.isError).toBe(true); + const cancelled = await call( + "cancelAction", + { + protocolVersion: 1, + scopeId: interaction.scopeId, + operationId: interaction.operationId, + interactionId: interaction.interactionId, + }, + ); + expect(cancelled.status).toBe("cancelled"); + expect(fixture.effects).toBe(0); + expect(fixture.joins).toHaveLength(1); + }, + ); + + it.each(["dev", "bypass"])( + "does not dispatch structured tools in %s mode", + async (mode) => { + process.env.TYPEAGENT_MODE = mode; + const actual = await client.callTool({ + name: "typeagent-searchActions", + arguments: { query: "read" }, + }); + expect(actual.isError).toBe(true); + expect(fixture.joins).toHaveLength(0); + }, + ); +}); diff --git a/ts/packages/copilot-plugin/test/toolIdentities.spec.ts b/ts/packages/copilot-plugin/test/toolIdentities.spec.ts index 65120530a6..abbe281447 100644 --- a/ts/packages/copilot-plugin/test/toolIdentities.spec.ts +++ b/ts/packages/copilot-plugin/test/toolIdentities.spec.ts @@ -5,6 +5,18 @@ import { describe, expect, it } from "@jest/globals"; import { isTypeAgentAgentServerTool } from "../src/shared/tool-identities.js"; describe("TypeAgent MCP tool identity", () => { + it.each([ + "searchActions", + "executeAction", + "continueAction", + "cancelAction", + ])("recognizes %s with and without the MCP server prefix", (name) => { + expect(isTypeAgentAgentServerTool(`typeagent-${name}`)).toBe(true); + expect(isTypeAgentAgentServerTool(`typeagent-typeagent-${name}`)).toBe( + true, + ); + expect(isTypeAgentAgentServerTool(name, "typeagent")).toBe(true); + }); it("distinguishes agent-server tools from workspace tools", () => { expect( isTypeAgentAgentServerTool("typeagent-processCommand", "typeagent"), diff --git a/ts/packages/copilot-plugin/test/tsconfig.json b/ts/packages/copilot-plugin/test/tsconfig.json index 0e71ed8c2d..04cc01ec7a 100644 --- a/ts/packages/copilot-plugin/test/tsconfig.json +++ b/ts/packages/copilot-plugin/test/tsconfig.json @@ -10,5 +10,8 @@ "ts-node": { "esm": true }, - "references": [{ "path": "../src" }] + "references": [ + { "path": "../src" }, + { "path": "../../dispatcher/dispatcher/src" } + ] } diff --git a/ts/packages/dispatcher/dispatcher/src/command/command.ts b/ts/packages/dispatcher/dispatcher/src/command/command.ts index 5b5424f940..626e7d925c 100644 --- a/ts/packages/dispatcher/dispatcher/src/command/command.ts +++ b/ts/packages/dispatcher/dispatcher/src/command/command.ts @@ -42,6 +42,7 @@ import { import { DispatcherName } from "../context/dispatcher/dispatcherUtils.js"; import { getAppAgentName } from "../internal.js"; import { getStructuredExecution } from "../structuredAction/executionHooks.js"; +import { ExecutionFailure } from "../structuredAction/executionFailure.js"; import { logCommandException, logRequestCompleted, @@ -399,6 +400,12 @@ export async function processCommandNoLock( attachments, ); } catch (e: any) { + if ( + e instanceof ExecutionFailure && + getStructuredExecution(context) !== undefined + ) { + throw e; + } if ( otel.isTelemetryCancellation( e, diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/configCommandHandlers.ts b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/configCommandHandlers.ts index 5caeb00da1..a687abedf5 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/configCommandHandlers.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/configCommandHandlers.ts @@ -13,6 +13,11 @@ import { import { getAppAgentName } from "../../../translation/agentTranslators.js"; import { getActionContext } from "../../../execute/actionContext.js"; import { emitActionResult } from "../../../execute/actionHandlers.js"; +import { getStructuredExecution } from "../../../structuredAction/executionHooks.js"; +import { + ExecutionFailure, + nestedSetupUnavailable, +} from "../../../structuredAction/executionFailure.js"; import { simpleStarRegex } from "@typeagent/common-utils"; import { @@ -674,6 +679,13 @@ class AgentSetupCommandHandler implements CommandHandler { params: ParsedCommandParams, ) { const systemContext = context.sessionContext.agentContext; + if (getStructuredExecution(systemContext) !== undefined) { + throw new ExecutionFailure( + "unavailable", + nestedSetupUnavailable, + "unavailable", + ); + } const agents = systemContext.agents; const name = params.args.agentName; diff --git a/ts/packages/dispatcher/dispatcher/src/execute/actionHandlers.ts b/ts/packages/dispatcher/dispatcher/src/execute/actionHandlers.ts index fd6fb7f2a4..ef227efa57 100644 --- a/ts/packages/dispatcher/dispatcher/src/execute/actionHandlers.ts +++ b/ts/packages/dispatcher/dispatcher/src/execute/actionHandlers.ts @@ -64,6 +64,7 @@ import { import { otel } from "@typeagent/telemetry"; import { getActionContext } from "./actionContext.js"; import { getStructuredExecution } from "../structuredAction/executionHooks.js"; +import { ExecutionFailure } from "../structuredAction/executionFailure.js"; import { RpcDisconnectedError } from "@typeagent/agent-rpc/rpc"; import { AgentNotReadyError, @@ -219,7 +220,8 @@ function rethrowIfActionCancelled( systemContext: CommandHandlerContext, ): void { if ( - error instanceof RpcDisconnectedError && + (error instanceof RpcDisconnectedError || + error instanceof ExecutionFailure) && getStructuredExecution(systemContext) !== undefined ) throw error; diff --git a/ts/packages/dispatcher/dispatcher/src/structuredAction/discovery.ts b/ts/packages/dispatcher/dispatcher/src/structuredAction/discovery.ts index 477aed1334..95da333c81 100644 --- a/ts/packages/dispatcher/dispatcher/src/structuredAction/discovery.ts +++ b/ts/packages/dispatcher/dispatcher/src/structuredAction/discovery.ts @@ -20,6 +20,7 @@ import { import { createActionContract } from "./contract.js"; import { getAppAgentName } from "../translation/agentTranslators.js"; import registerDebug from "debug"; +import { getStructuredActionUnsupportedReason } from "./executionFailure.js"; const debugError = registerDebug( "typeagent:dispatcher:structuredActionDiscovery:error", @@ -62,6 +63,25 @@ function validateSearch(request: ActionSearchRequest): void { validateString(request.query, "query"); } +function createDiscoverableActionContract( + identity: { schemaName: string; actionName: string }, + definition: Parameters[1], + config: Parameters[2], +): ActionContract { + const contract = createActionContract(identity, definition, config); + const unsupportedReason = getStructuredActionUnsupportedReason(identity); + if (unsupportedReason === undefined) return contract; + + const unsupportedDescription = `Structured execution is unsupported: ${unsupportedReason}`; + return { + ...contract, + description: + contract.description.length === 0 + ? unsupportedDescription + : `${contract.description}\n\n${unsupportedDescription}`, + }; +} + export class StructuredActionDiscovery { private readonly anonymousScope = {}; @@ -154,7 +174,7 @@ export class StructuredActionDiscovery { return []; } return [ - createActionContract( + createDiscoverableActionContract( { schemaName, actionName }, definition, config, @@ -186,7 +206,7 @@ export class StructuredActionDiscovery { continue; } matches.push( - createActionContract( + createDiscoverableActionContract( { schemaName: config.schemaName, actionName }, definition, config, @@ -242,6 +262,15 @@ export class StructuredActionDiscovery { message: "Action is disabled or inactive", }; } + const unsupportedReason = + getStructuredActionUnsupportedReason(identity); + if (unsupportedReason !== undefined) { + return { + status: "unavailable", + envelope, + message: unsupportedReason, + }; + } const agentName = getAppAgentName(identity.schemaName); if (this.context.agents.hasUnknownReadiness(agentName)) { return { diff --git a/ts/packages/dispatcher/dispatcher/src/structuredAction/execution.ts b/ts/packages/dispatcher/dispatcher/src/structuredAction/execution.ts index e0a395f8d6..6718001cd2 100644 --- a/ts/packages/dispatcher/dispatcher/src/structuredAction/execution.ts +++ b/ts/packages/dispatcher/dispatcher/src/structuredAction/execution.ts @@ -45,6 +45,7 @@ import { validateJson, validateResponse, } from "./validation.js"; +import { ExecutionFailure } from "./executionFailure.js"; const OPERATION_TTL = 10 * 60_000; const MAX_OPERATIONS = 100; @@ -56,22 +57,6 @@ type ExecutionRuntime = { getActionContext: typeof getActionContext; }; -type FailureStatus = - | "failed" - | "unavailable" - | "cancelled" - | "execution_uncertain"; - -class ExecutionFailure extends Error { - constructor( - readonly code: StructuredActionError["code"], - message: string, - readonly status: FailureStatus = "failed", - ) { - super(message); - } -} - function binding(discovery: StructuredActionDiscovery) { let current: ReturnType; try { @@ -197,6 +182,7 @@ class Operation implements StructuredExecutionHooks { >(); private readonly knownInteractions = new Set(); private possibleEffects = false; + private promptFailure: ExecutionFailure | undefined; private promptTail: Promise = Promise.resolve(); private queuedPrompts = 0; private timer: ReturnType; @@ -243,6 +229,7 @@ class Operation implements StructuredExecutionHooks { } private checkLive(): void { + if (this.promptFailure !== undefined) throw this.promptFailure; if (this.terminal !== undefined) throw new DOMException("Operation ended", "AbortError"); this.context.currentAbortSignal?.throwIfAborted(); @@ -510,6 +497,11 @@ class Operation implements StructuredExecutionHooks { this.checkLive(); this.revalidate(); return response; + } catch (error) { + // Agent RPC serializes thrown callback errors. Retain the host's + // authoritative guard failure rather than trusting the roundtrip. + if (error instanceof ExecutionFailure) this.promptFailure = error; + throw error; } finally { if (this.pending === pending) this.pending = undefined; this.context.requestQueue.markUnblocked(this.id); @@ -607,6 +599,13 @@ class Operation implements StructuredExecutionHooks { finish(error?: unknown): void { if (this.terminal !== undefined) return; + if ( + this.promptFailure !== undefined && + (!(error instanceof ExecutionFailure) || + error.code === "execution_failed") + ) { + error = this.promptFailure; + } if ( error === undefined && this.context.currentRequestId?.requestId === this.id && diff --git a/ts/packages/dispatcher/dispatcher/src/structuredAction/executionFailure.ts b/ts/packages/dispatcher/dispatcher/src/structuredAction/executionFailure.ts new file mode 100644 index 0000000000..f9734cbd16 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/structuredAction/executionFailure.ts @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { StructuredActionError } from "@typeagent/dispatcher-types"; + +export class ExecutionFailure extends Error { + constructor( + readonly code: StructuredActionError["code"], + message: string, + readonly status: + | "failed" + | "unavailable" + | "cancelled" + | "execution_uncertain" = "failed", + ) { + super(message); + } +} + +export const nestedSetupUnavailable = + "This action can enter legacy agent setup without a structured setup contract or resumable result path. Use the natural-language interface to configure agents."; + +export function getStructuredActionUnsupportedReason(identity: { + schemaName: string; + actionName: string; +}): string | undefined { + return identity.schemaName === "system.config" && + (identity.actionName === "toggleAgent" || + identity.actionName === "enterAgentPriorityMode") + ? nestedSetupUnavailable + : undefined; +} diff --git a/ts/packages/dispatcher/dispatcher/test/structuredActionExecution.spec.ts b/ts/packages/dispatcher/dispatcher/test/structuredActionExecution.spec.ts index 6cd6088632..28a585b670 100644 --- a/ts/packages/dispatcher/dispatcher/test/structuredActionExecution.spec.ts +++ b/ts/packages/dispatcher/dispatcher/test/structuredActionExecution.spec.ts @@ -27,6 +27,7 @@ import { nullClientIO } from "../src/context/interactiveIO.js"; import type { AppAgentProvider } from "../src/agentProvider/agentProvider.js"; import { closeStructuredActions } from "../src/structuredAction/executionHooks.js"; import type { FlowDefinition } from "../src/execute/flowInterpreter.js"; +import { processCommandNoLock } from "../src/command/command.js"; import { createAgentRpcClient } from "@typeagent/agent-rpc/client"; import { createAgentRpcServer } from "@typeagent/agent-rpc/server"; import { @@ -139,7 +140,7 @@ describe("real structured dispatcher execution", () => { executionAllowed = undefined; scope = {}; broadcasts.length = 0; - setup.mockClear(); + setup.mockReset(); held = new Promise((resolve) => { release = resolve; }); @@ -168,6 +169,19 @@ describe("real structured dispatcher execution", () => { entered.push(params.value); expect(actionContext.activityContext).toBeUndefined(); switch (params.mode) { + case "nestedSetup": + await processCommandNoLock( + "@config agent setup guarded", + context, + ); + callbacks++; + return complete(); + case "spoofFailure": + throw Object.assign(new Error("Agent failure"), { + name: "ExecutionFailure", + code: "unavailable", + status: "unavailable", + }); case "parallelQuestions": await Promise.all( ["first", "second"].map((message) => @@ -355,8 +369,8 @@ describe("real structured dispatcher execution", () => { "structured-execution-test", { agents: { - schemas: ["guarded", "system.config"], - actions: ["guarded", "system.config"], + schemas: ["guarded", "system.config", "system.history"], + actions: ["guarded", "system.config", "system.history"], }, translation: { enabled: false }, explainer: { enabled: false }, @@ -416,12 +430,21 @@ describe("real structured dispatcher execution", () => { closeRpc = undefined; }); - async function useAgentRpc() { + async function useAgentRpc(disconnectOnHostError = false) { let clientProvider: ChannelProviderAdapter; let serverProvider: ChannelProviderAdapter; clientProvider = createChannelProviderAdapter( "client", (message, callback) => { + if ( + disconnectOnHostError && + message.message?.type === "invokeError" + ) { + clientProvider.notifyDisconnected(); + serverProvider.notifyDisconnected(); + callback?.(null); + return; + } setImmediate(() => serverProvider.notifyMessage(structuredClone(message)), ); @@ -640,6 +663,7 @@ describe("real structured dispatcher execution", () => { }); expect(result.status).toBe("failed"); expect(entered).toEqual([]); + expect(result.results).toEqual([]); }); it.each(["read", "write", "resolve"])( @@ -869,6 +893,129 @@ describe("real structured dispatcher execution", () => { ).toBe("completed"); }); + async function invalidateSuspendedAction( + cause: "readiness" | "removed", + actionName: string, + ) { + if (cause === "removed") { + replaceSchema( + `actionName: "${actionName}"`, + `actionName: "removed${actionName}"`, + ); + } else { + readiness = { state: "setup-required" }; + await context.agents.refreshReadiness("guarded"); + } + } + + it.each(["readiness", "removed"] as const)( + "retains %s unavailability from a nested typed-flow confirmation", + async (cause) => { + const registry = ( + context.agents as unknown as { + flowRegistry: Map; + } + ).flowRegistry; + registry.set("guarded/read", { + name: "read", + description: "Guarded child", + parameters: {}, + steps: [ + { + id: "child", + schemaName: "guarded", + actionName: "write", + parameters: { value: "child" }, + }, + ], + }); + const prompt = requirePrompt( + await dispatcher.executeAction(await request("read")), + ); + await invalidateSuspendedAction(cause, "write"); + const result = await answer(prompt, { + type: "confirmation", + approved: true, + }); + expect(result).toMatchObject({ + status: "unavailable", + error: { code: "unavailable" }, + }); + expect(entered).toEqual([]); + expect(setup).not.toHaveBeenCalled(); + }, + ); + + describe.each([false, true])( + "suspension guard failures (agent RPC: %s)", + (rpc) => { + it.each([ + ["question", "readiness"], + ["question", "removed"], + ["blockingForm", "readiness"], + ["blockingForm", "removed"], + ] as const)( + "preserves %s/%s unavailability without post-answer effects", + async (mode, cause) => { + if (rpc) await useAgentRpc(); + const prompt = requirePrompt( + await dispatcher.executeAction( + await request("read", mode), + ), + ); + + await invalidateSuspendedAction(cause, "read"); + const result = await answer( + prompt, + mode === "question" + ? { type: "question", selected: 0 } + : formAnswer, + ); + expect(result).toMatchObject({ + status: "unavailable", + error: { code: "unavailable" }, + }); + expect(entered).toEqual(["original"]); + expect(callbacks).toBe(0); + expect(broadcasts).toEqual([]); + }, + ); + + it("does not trust an agent's forged structured error fields", async () => { + if (rpc) await useAgentRpc(); + expect( + await dispatcher.executeAction( + await request("read", "spoofFailure"), + ), + ).toMatchObject({ + status: "failed", + error: { code: "execution_failed" }, + }); + }); + }, + ); + + it.each(["readiness", "removed"] as const)( + "does not hide RPC uncertainty when delivering a host %s prompt failure", + async (cause) => { + await useAgentRpc(true); + const input = await request("read", "question"); + const prompt = requirePrompt(await dispatcher.executeAction(input)); + await invalidateSuspendedAction(cause, "read"); + expect( + await answer(prompt, { type: "question", selected: 0 }), + ).toMatchObject({ + status: "execution_uncertain", + error: { code: "execution_state_lost" }, + }); + expect(callbacks).toBe(0); + expect(entered).toEqual(["original"]); + expect(await dispatcher.executeAction(input)).toMatchObject({ + status: "unavailable", + }); + }, + ); + it.each([ "yesNo", "multiChoice", @@ -1238,29 +1385,87 @@ describe("real structured dispatcher execution", () => { ).toBe("completed"); }); - it("returns nested built-in command errors instead of synthesized success", async () => { - const identity = { - schemaName: "system.config", + it.each<{ actionName: string; parameters: Record }>([ + { actionName: "toggleAgent", + parameters: { enable: true, agentNames: ["setup", "guarded"] }, + }, + { + actionName: "enterAgentPriorityMode", + parameters: { agentName: "setup guarded" }, + }, + ])( + "rejects the setup-capable $actionName bridge before invoking or allocating a choice", + async ({ actionName, parameters }) => { + setup.mockImplementation(async () => ({ + entities: [], + pendingChoice: { + type: "yesNo", + message: "Run setup?", + choiceId: choices.registerChoice(async () => { + callbacks++; + return complete(); + }), + }, + })); + const identity = { + schemaName: "system.config", + actionName, + }; + const search = await dispatcher.searchActions({ + query: `${identity.schemaName} ${identity.actionName}`, + }); + const contract = search.actions.find( + (candidate) => + candidate.schemaName === identity.schemaName && + candidate.actionName === identity.actionName, + ); + if (contract === undefined) + throw new Error("Expected built-in action"); + expect(contract.description).toContain( + "Structured execution is unsupported", + ); + const result = await dispatcher.executeAction({ + protocolVersion: search.protocolVersion, + scopeId: search.scopeId, + ...identity, + parameters, + }); + expect(result).toMatchObject({ + status: "unavailable", + error: { code: "unavailable" }, + results: [], + }); + expect(setup).not.toHaveBeenCalled(); + expect(callbacks).toBe(0); + expect(context.pendingChoiceRoutes.size).toBe(0); + expect(broadcasts).toEqual([]); + }, + ); + + it("returns supported built-in command errors instead of synthesized success", async () => { + const identity = { + schemaName: "system.history", + actionName: "deleteHistory", }; const search = await dispatcher.searchActions({ query: `${identity.schemaName} ${identity.actionName}`, }); const contract = search.actions.find( - (action) => - action.schemaName === identity.schemaName && - action.actionName === identity.actionName, + (candidate) => + candidate.schemaName === identity.schemaName && + candidate.actionName === identity.actionName, ); if (contract === undefined) throw new Error("Expected built-in action"); + expect(contract.description).not.toContain( + "Structured execution is unsupported", + ); const prompt = requirePrompt( await dispatcher.executeAction({ protocolVersion: search.protocolVersion, scopeId: search.scopeId, ...identity, - parameters: { - enable: true, - agentNames: ["review-no-such-agent"], - }, + parameters: { messageNumber: 999 }, }), ); const result = await answer(prompt, { @@ -1268,19 +1473,62 @@ describe("real structured dispatcher execution", () => { approved: true, }); expect(result.status).toBe("failed"); - expect(result.results[0].result.error).toContain("Invalid agent name"); - expect(result.output.join("\n")).toContain("review-no-such-agent"); + expect(result.results[0].result.error).toContain( + "outside the range of available indices", + ); + expect(result.output.join("\n")).toContain("999"); expect(result.output.join("\n")).not.toContain("completed."); - const legacy = await dispatcher.submitCommand( - "@config agent review-no-such-agent", - ); + const legacy = await dispatcher.submitCommand("@history delete 999"); if (!legacy.ok) throw new Error("Expected legacy submission"); expect((await legacy.entry.completion)?.disposition?.status).toBe( "failed", ); }); + it("rejects nested setup before hooks while retaining ordinary NL setup choices", async () => { + let choiceId: string | undefined; + setup.mockImplementation(async () => { + choiceId = choices.registerChoice(async () => { + callbacks++; + return complete(); + }); + return { + entities: [], + pendingChoice: { + type: "yesNo", + message: "Run setup?", + choiceId, + }, + }; + }); + const result = await dispatcher.executeAction( + await request("read", "nestedSetup"), + ); + expect(result).toMatchObject({ + status: "unavailable", + error: { code: "unavailable" }, + }); + expect(setup).not.toHaveBeenCalled(); + expect(choiceId).toBeUndefined(); + expect(callbacks).toBe(0); + expect(context.pendingChoiceRoutes.size).toBe(0); + + readiness = { state: "setup-required" }; + await context.agents.refreshReadiness("guarded"); + const legacy = await dispatcher.submitCommand( + "@config agent setup guarded", + ); + if (!legacy.ok) throw new Error("Expected legacy submission"); + await legacy.entry.completion; + expect(setup).toHaveBeenCalledTimes(1); + expect(broadcasts).toEqual(["choice"]); + expect(choiceId).toBeDefined(); + await dispatcher.respondToChoice(choiceId!, false); + expect(callbacks).toBe(1); + expect(context.pendingChoiceRoutes.size).toBe(0); + }); + it.each([ ["${first.data.ids.0}", "source-id"], ["${first.text}", "Parent result"], diff --git a/ts/pnpm-lock.yaml b/ts/pnpm-lock.yaml index caee297dea..46afb2b3d6 100644 --- a/ts/pnpm-lock.yaml +++ b/ts/pnpm-lock.yaml @@ -4696,6 +4696,9 @@ importers: '@types/node': specifier: ^20.10.0 version: 20.19.40 + agent-dispatcher: + specifier: workspace:* + version: link:../dispatcher/dispatcher esbuild: specifier: ^0.28.2 version: 0.28.2