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..64e2e8695b --- /dev/null +++ b/ts/packages/agentServer/client/src/structuredActionClient.ts @@ -0,0 +1,353 @@ +// 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, + ActionIdentity, + 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, + ); + } + + getActionContract(identity: ActionIdentity, signal?: AbortSignal) { + return this.invoke( + (dispatcher) => dispatcher.getActionContract(identity), + 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..13545be0e5 --- /dev/null +++ b/ts/packages/agentServer/client/test/structuredActionClient.spec.ts @@ -0,0 +1,396 @@ +// 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: [], + total: 0, + }), + } 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(); + await client.searchActions(); + 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 five 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 contract = jest.fn< + ConversationDispatcher["dispatcher"]["getActionContract"] + >(async () => ({ + protocolVersion: 1, + scopeId: "scope", + status: "not-found", + })); + 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, + getActionContract: contract, + 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, + fingerprint: "fingerprint", + 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", limit: 2 }); + await client.getActionContract(identity); + 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", limit: 2 }); + expect(contract).toHaveBeenCalledWith(identity); + 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(); + disconnect!(); + expect(client.binding).toEqual({ + conversationId: "public-id", + connected: false, + }); + await client.searchActions(); + 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() + .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(); + disconnect!(); + fake.joinConversation.mockRejectedValue( + new Error(serverMessage), + ); + const outcome = await client + .searchActions() + .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({}, 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", + fingerprint: "fingerprint", + 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(), + first.searchActions(), + first.searchActions(), + ]); + expect(connect).toHaveBeenCalledTimes(1); + expect(fake.joinConversation).toHaveBeenCalledTimes(1); + await second.searchActions(); + 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()).rejects.toThrow("not dispatched"); + await expect(client.searchActions()).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()).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(); + await client.close(); + await client.close(); + await expect(client.searchActions()).rejects.toBeInstanceOf( + StructuredActionClientError, + ); + expect(fake.close).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ts/packages/commandExecutor/README.md b/ts/packages/commandExecutor/README.md index c5b164c78f..43f97537ba 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,24 @@ 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` searches compact action summaries and availability. +2. `get_action_contract` returns one closed contract, its fingerprint, and its + conversation scope. +3. `execute_action` accepts the exact protocol version, scope, identity, + fingerprint, and structured parameters. +4. `continue_action` sends the user's exact response to a pending interaction. +5. `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 +152,27 @@ 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 +that already knows an action may request its contract directly without a +mandatory discovery chain. + +`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..0debebad46 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, + ActionContractResult, 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,12 @@ 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 structured action summaries + * get_action_contract - fetch one closed structured contract + * execute_action - execute an exact contract + * 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 +378,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 +405,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 +556,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 +602,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 + get_action_contract + 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, get its exact contract, gather concrete inputs, then call execute_action with that contract's fingerprint and scope. Reuse a known current contract without rediscovery. 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 +621,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 +660,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 +682,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 +865,58 @@ 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 contractResult = await invokeStructuredAction( + this.structuredActionClient, + (client, requestSignal) => + client.getActionContract( + { 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 contract = contractResult.structuredContent as + | ActionContractResult + | undefined; + if (contract?.status !== "found") { + return contractResult; + } + return invokeStructuredAction( + this.structuredActionClient, + (client, requestSignal) => + client.executeAction( + { + protocolVersion: contract.protocolVersion, + scopeId: contract.scopeId, + schemaName, + actionName, + fingerprint: contract.contract.fingerprint, + 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 +949,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 +989,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 +1092,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..9059c0c4ff --- /dev/null +++ b/ts/packages/commandExecutor/src/structuredActionTools.ts @@ -0,0 +1,307 @@ +// 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 { + ActionSearchRequest, + ActionContractResult, + ActionSearchResult, + CancelActionRequest, + ContinueActionRequest, + ExecuteActionRequest, + StructuredActionExecutionResult, +} from "@typeagent/dispatcher-types"; +import { z } from "zod/v4"; + +export type StructuredActionClient = Pick< + SharedStructuredActionClient, + | "binding" + | "searchActions" + | "getActionContract" + | "executeAction" + | "continueAction" + | "cancelAction" + | "close" +>; + +type StructuredActionResult = + | ActionSearchResult + | ActionContractResult + | 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", "found"].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().optional(), + agentName: z.string().optional(), + schemaName: z.string().optional(), + offset: z.number().int().nonnegative().optional(), + limit: z.number().int().positive().optional(), + }) + .strict(), + description: + "Search compact TypeAgent action summaries and availability. Select an exact schemaName/actionName, then get_action_contract. Skip search when the exact identity is already known. Discovery does not enable agents.", + }, + (request, extra) => + invokeStructuredAction( + client, + (structuredClient, signal) => + structuredClient.searchActions( + request as ActionSearchRequest, + signal, + ), + false, + extra.signal, + ), + ); + + server.registerTool( + "get_action_contract", + { + inputSchema: z.object(identity).strict(), + description: + "Get one closed TypeScript action contract, referenced types, fingerprint, scope, availability, output, and interaction requirements. A known action can be fetched directly; reuse a current contract only in this binding.", + }, + (request, extra) => + invokeStructuredAction( + client, + (structuredClient, signal) => + structuredClient.getActionContract(request, signal), + false, + extra.signal, + ), + ); + + server.registerTool( + "execute_action", + { + inputSchema: z + .object({ + ...envelope, + ...identity, + fingerprint: z.string(), + parameters: z.record(z.string(), z.unknown()).optional(), + }) + .strict(), + description: + "Execute one known action with concrete structured parameters and its exact current contract fingerprint and scope. No natural-language translation, cache training, alias remapping, default bindings, or replay. 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..0dcd2060bf 100644 --- a/ts/packages/commandExecutor/src/workspaceCommandMcpSchema.ts +++ b/ts/packages/commandExecutor/src/workspaceCommandMcpSchema.ts @@ -81,6 +81,40 @@ 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", + "contract_stale", + "unavailable", + "execution_uncertain", + "not-found", + ]) + .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..4159ea33da --- /dev/null +++ b/ts/packages/commandExecutor/test/structuredActionTools.spec.ts @@ -0,0 +1,705 @@ +// 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"), + getActionContract: + implementations.getActionContract ?? missing("getActionContract"), + 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 foundContract( + schemaName: string, + actionName: string, + scopeId = "scope-1", +) { + return { + protocolVersion: 1 as const, + scopeId, + status: "found" as const, + contract: { + schemaName, + actionName, + description: `Contract for ${schemaName}.${actionName}`, + availability: { + state: "available" as const, + schemaEnabled: true, + actionEnabled: true, + schemaActive: true, + actionActive: true, + readiness: { source: "not-checked" as const }, + authorization: "checked-at-execution" as const, + }, + fingerprint: `${actionName}-fingerprint`, + 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], + ["found", false], + ["failed", true], + ["cancelled", true], + ["contract_stale", true], + ["unavailable", true], + ["execution_uncertain", true], + ["not-found", 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 routes get_user_context through contract and structured execution", async () => { + const getActionContract = jest.fn(async () => + foundContract("code", "getActiveEditor", "scope-context"), + ); + 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({ + getActionContract, + 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", + "get_action_contract", + "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(getActionContract).toHaveBeenCalledWith( + { + schemaName: "code", + actionName: "getActiveEditor", + }, + expect.anything(), + ); + expect(executeAction).toHaveBeenCalledWith( + { + protocolVersion: 1, + scopeId: "scope-context", + schemaName: "code", + actionName: "getActiveEditor", + fingerprint: "getActiveEditor-fingerprint", + 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 commandServer = new CommandServer( + "ws://unused.invalid", + createCaller({ + getActionContract: async () => + foundContract( + "code.code-workbench", + "runWorkspaceCommand", + "scope-workspace", + ), + 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", + fingerprint: "runWorkspaceCommand-fingerprint", + 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", + fingerprint: "runWorkspaceCommand-fingerprint", + parameters: { + command: "pnpm test", + executionId: "workspace-2", + }, + }, + contract: foundContract( + "code.code-workbench", + "runWorkspaceCommand", + "scope-workspace", + ).contract, + }, + }; + const continueAction = + jest.fn(); + const commandServer = new CommandServer( + "ws://unused.invalid", + createCaller({ + getActionContract: async () => + foundContract( + "code.code-workbench", + "runWorkspaceCommand", + "scope-workspace", + ), + 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", + "get_action_contract", + "execute_action", + "continue_action", + "cancel_action", + ]); + } finally { + await harness.close(); + } + }); + + test("preserves search, contract, and pending execution results", async () => { + const searchResult = { + protocolVersion: 1 as const, + scopeId: "scope-1", + actions: [], + total: 0, + }; + const contractResult = { + protocolVersion: 1 as const, + scopeId: "scope-1", + status: "not-found" as const, + }; + 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, + getActionContract: async () => contractResult, + 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 contract = asToolResult( + await harness.client.callTool({ + name: "get_action_contract", + arguments: { + schemaName: "email", + actionName: "send", + }, + }), + ); + expect(contract.structuredContent).toEqual(contractResult); + expect(contract.isError).toBe(true); + + const request = { + protocolVersion: 1, + scopeId: "scope-1", + schemaName: "email", + actionName: "send", + fingerprint: "fingerprint-1", + 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("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: {}, + }), + ); + 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", + fingerprint: "fingerprint-1", + 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..596185189b 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,27 @@ 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("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 ae327da728..6ac9bf5e53 100644 --- a/ts/packages/copilot-plugin/README.md +++ b/ts/packages/copilot-plugin/README.md @@ -27,6 +27,157 @@ 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 + +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 summaries -> get selected contract -> execute**. +A known action can skip search; a current contract can be reused within its +binding. `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` | Optional `query`, `agentName`, `schemaName`, `offset`, `limit`; compact summaries and live availability metadata | +| `typeagent-getActionContract` | Separate exact `schemaName` and `actionName`; returns one closed TypeScript contract including nested types, output/interaction shape, policy, fingerprint and scope | +| `typeagent-executeAction` | `protocolVersion`, `scopeId`, `schemaName`, `actionName`, exact `fingerprint`, 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. + +### 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 five 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 five +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 seven distinct statuses: `completed`, `failed`, `cancelled`, +`requires_interaction`, `contract_stale`, `unavailable`, `execution_uncertain`. +Pending interactions are not MCP tool errors: `completed`, `requires_interaction` +and found contracts omit `isError`; unsuccessful terminal results and missing +contracts 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 `contract_stale`, refresh the selected contract and reassess parameters and +consent 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 discovery marks these exact +actions unsupported 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 `contract_stale` 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 five 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 +487,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 +580,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 | --- @@ -452,18 +607,19 @@ The plugin stores config at `%USERPROFILE%\.typeagent-copilot\config.json` (Wind 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` | five structured-action tools | Search summaries, retrieve a contract, 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..989891c0d5 100644 --- a/ts/packages/copilot-plugin/agents/typeagent.agent.md +++ b/ts/packages/copilot-plugin/agents/typeagent.agent.md @@ -5,6 +5,11 @@ tools: - typeagent-processCommand - typeagent-listAgents - typeagent-getStatus + - typeagent-searchActions + - typeagent-getActionContract + - typeagent-executeAction + - typeagent-continueAction + - typeagent-cancelAction infer: true userInvocable: true --- @@ -16,5 +21,52 @@ 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-getActionContract` -> +`typeagent-executeAction`. Skip search for a known identity. Reuse a current +contract in the same binding; there is no mandatory status or schema-list stage. +Supply separate exact `schemaName` and `actionName`, the returned `protocolVersion`, +`scopeId`, and `fingerprint`, and typed `parameters`. Keep lists, IDs, paths, +Unicode, quotes and newlines as data, never command strings or rewritten prose. + +Show the full authoritative result. Preserve all seven states: `completed`, +`failed`, `cancelled`, `requires_interaction`, `contract_stale`, `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 `contract_stale`, retrieve the current selected contract and construct a new +request after reassessing inputs and consent; 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. +Discovery reports 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 ed2efd88b1..e90c27eaf4 100644 --- a/ts/packages/copilot-plugin/package.json +++ b/ts/packages/copilot-plugin/package.json @@ -40,6 +40,7 @@ "zod": "^3.25.0" }, "devDependencies": { + "agent-dispatcher": "workspace:*", "@jest/globals": "^29.7.0", "@types/html-to-text": "^9.0.4", "@types/jest": "^29.5.7", diff --git a/ts/packages/copilot-plugin/src/hooks/hook-direct.ts b/ts/packages/copilot-plugin/src/hooks/hook-direct.ts index 4aac443602..3d70a06c55 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"; @@ -24,7 +25,15 @@ export async function handleDirect(input: HookInput): Promise { 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); }, @@ -76,6 +85,7 @@ export async function handleDirect(input: HookInput): Promise { emitProgress("Processing command...", { temporary: true }); const result = await awaitCommand(dispatcher, input.prompt); + if (pendingPrompts.length > 0) return pendingResult(); if (result?.cancelled) { return {}; } @@ -103,6 +113,7 @@ export async function handleDirect(input: HookInput): Promise { handledBy: "typeagent", }; } catch (error) { + if (pendingPrompts.length > 0) return pendingResult(); console.error("TypeAgent error:", error); return {}; } finally { 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..c21ba9ec22 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-getActionContract -> typeagent-executeAction with concrete typed inputs instead.", + "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 4387296c90..e2aed5b361 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, getActionContract, 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..0bcb45776c --- /dev/null +++ b/ts/packages/copilot-plugin/src/mcp/structuredActionTools.ts @@ -0,0 +1,281 @@ +// 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 { + ActionSearchRequest, + 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" && + data.status !== "found" + ? { 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 compact TypeAgent action summaries and availability. Then get the selected action contract. Skip search if the identity is known; no status/schema-list stage is required. Unresolved references belong on the natural-language path.", + inputSchema: z + .object({ + query: z.string().optional(), + agentName: z.string().optional(), + schemaName: z.string().optional(), + offset: z.number().optional(), + limit: z.number().optional(), + }) + .strict(), + }, + (request, extra) => + invoke( + (dispatcher) => + dispatcher.searchActions( + request as ActionSearchRequest, + extra.signal, + ), + false, + ), + ); + + server.registerTool( + "typeagent-getActionContract", + { + description: + "Get one closed TypeAgent action contract including nested types, effects, availability and interactions. Use exact separate schemaName/actionName. Reuse its fingerprint and scopeId only in the same binding. Refresh contract_stale, but never automatically replay.", + inputSchema: z.object(identity).strict(), + }, + (request, extra) => + invoke( + (dispatcher) => + dispatcher.getActionContract(request, extra.signal), + false, + ), + ); + + server.registerTool( + "typeagent-executeAction", + { + description: + "Execute one Copilot-selected typed action through Dispatcher using its exact current fingerprint, scopeId and concrete parameters. No command strings or NL translation. Unknown/state-changing effects require USER confirmation; selection is not consent. Preserve all seven 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, + fingerprint: z.string(), + 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..b6a5e0d879 100644 --- a/ts/packages/copilot-plugin/src/shared/tool-identities.ts +++ b/ts/packages/copilot-plugin/src/shared/tool-identities.ts @@ -2,6 +2,11 @@ // Licensed under the MIT License. const TYPEAGENT_AGENT_SERVER_TOOLS = [ + "typeagent-searchactions", + "typeagent-getactioncontract", + "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/hookRouter.spec.ts b/ts/packages/copilot-plugin/test/hookRouter.spec.ts index 9bd38215a6..b64688463f 100644 --- a/ts/packages/copilot-plugin/test/hookRouter.spec.ts +++ b/ts/packages/copilot-plugin/test/hookRouter.spec.ts @@ -25,6 +25,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 03ec0fa4d0..785e7971ff 100644 --- a/ts/packages/copilot-plugin/test/pluginArtifact.spec.ts +++ b/ts/packages/copilot-plugin/test/pluginArtifact.spec.ts @@ -15,6 +15,38 @@ interface PluginMcpManifest { } describe("staged plugin artifact", () => { + 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-getActionContract", + "typeagent-executeAction", + "typeagent-continueAction", + "typeagent-cancelAction", + "typeagent-processCommand", + ]), + ); + } 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..dd71112c7e --- /dev/null +++ b/ts/packages/copilot-plugin/test/structuredActionFixture.ts @@ -0,0 +1,333 @@ +// 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"); + }, + 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..c400433012 --- /dev/null +++ b/ts/packages/copilot-plugin/test/structuredActionTools.spec.ts @@ -0,0 +1,530 @@ +// 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 type { + ActionContractResult, + 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", "found"].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("getActionContract", { + schemaName: "fixture", + actionName, + }); + if (found.status !== "found") + throw new Error("Missing fixture contract"); + return { + protocolVersion: 1, + scopeId: found.scopeId, + schemaName: "fixture", + actionName, + fingerprint: found.contract.fingerprint, + ...(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-getActionContract", + "typeagent-executeAction", + "typeagent-continueAction", + "typeagent-cancelAction", + ]), + ); + const summaries = await call("searchActions", { + query: "write", + limit: 1, + }); + expect(summaries.actions).toHaveLength(1); + expect(summaries).toMatchObject({ + binding: { + conversationId: "explicit-public-conversation", + connected: true, + }, + }); + expect(summaries.actions[0]).toMatchObject({ + schemaName: "fixture", + actionName: "write", + }); + const found = await call( + "getActionContract", + { + schemaName: "fixture", + actionName: "write", + }, + ); + if (found.status !== "found") throw new Error("Missing contract"); + expect(found.contract.input.schemaText).toContain("Nested"); + expect(found.contract.input.schemaText).not.toContain("unrelated"); + const input = await request("write"); + 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(["stale", "invalid", "disabled", "readiness", "scope"] as const)( + "%s rejects before any handler or effect", + async (kind) => { + const input = await request(); + if (kind === "stale") input.fingerprint += "stale"; + 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 === "stale" + ? "contract_stale" + : kind === "disabled" || kind === "readiness" + ? "unavailable" + : "failed", + ); + expect(fixture.handlers).toBe(0); + expect(fixture.effects).toBe(0); + }, + ); + + 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", {}); + 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: {}, + }); + 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: {}, + }); + 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: {}, + }); + 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..258f24edc5 100644 --- a/ts/packages/copilot-plugin/test/toolIdentities.spec.ts +++ b/ts/packages/copilot-plugin/test/toolIdentities.spec.ts @@ -5,6 +5,19 @@ import { describe, expect, it } from "@jest/globals"; import { isTypeAgentAgentServerTool } from "../src/shared/tool-identities.js"; describe("TypeAgent MCP tool identity", () => { + it.each([ + "searchActions", + "getActionContract", + "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 220789f5e1..7d2a1e53d1 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 ce138202ff..9c1b96b2b8 100644 --- a/ts/packages/dispatcher/dispatcher/src/structuredAction/discovery.ts +++ b/ts/packages/dispatcher/dispatcher/src/structuredAction/discovery.ts @@ -16,6 +16,7 @@ import { import type { AppAgentManager } from "../context/appAgentManager.js"; import { getAppAgentName } from "../translation/agentTranslators.js"; import { createActionContract } from "./contract.js"; +import { nestedSetupUnavailable } from "./executionFailure.js"; // Host-only policy. Never deserialize this from a discovery/RPC request. // Reuse scope only for the same authorized logical caller/conversation binding, @@ -77,6 +78,7 @@ function validateSearch(request: ActionSearchRequest): void { function getAvailability( agents: AppAgentManager, schemaName: string, + actionName: string, ): ActionAvailability { const agentName = getAppAgentName(schemaName); const readiness = agents.getReadinessSnapshot(agentName); @@ -90,7 +92,14 @@ function getAvailability( authorization: "checked-at-execution", }; const loadError = agents.getLoadError(agentName); - if (agents.isSchemaLoading(schemaName)) { + if ( + schemaName === "system.config" && + (actionName === "toggleAgent" || + actionName === "enterAgentPriorityMode") + ) { + availability.state = "unsupported"; + availability.message = nestedSetupUnavailable; + } else if (agents.isSchemaLoading(schemaName)) { availability.state = "loading"; } else if (loadError !== undefined) { availability.state = "error"; @@ -166,10 +175,6 @@ export class StructuredActionDiscovery { } const schema = this.context.agents.getActionSchemaFileForConfig(config); - const availability = getAvailability( - this.context.agents, - config.schemaName, - ); for (const [actionName, definition] of schema.parsedActionSchema .actionSchemas) { const description = getActionDescription(definition) ?? ""; @@ -185,7 +190,11 @@ export class StructuredActionDiscovery { schemaName: config.schemaName, actionName, description, - availability, + availability: getAvailability( + this.context.agents, + config.schemaName, + actionName, + ), }); } } @@ -262,7 +271,11 @@ export class StructuredActionDiscovery { }, definition, config, - getAvailability(this.context.agents, identity.schemaName), + getAvailability( + this.context.agents, + identity.schemaName, + identity.actionName, + ), ), }; } diff --git a/ts/packages/dispatcher/dispatcher/src/structuredAction/execution.ts b/ts/packages/dispatcher/dispatcher/src/structuredAction/execution.ts index a73c566418..55f5108cf4 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,23 +57,6 @@ type ExecutionRuntime = { getActionContext: typeof getActionContext; }; -type FailureStatus = - | "failed" - | "contract_stale" - | "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 { @@ -194,6 +178,7 @@ class Operation implements StructuredExecutionHooks { private readonly contracts = new Map(); private readonly approved = new WeakSet(); private possibleEffects = false; + private promptFailure: ExecutionFailure | undefined; private promptTail: Promise = Promise.resolve(); private queuedPrompts = 0; private timer: ReturnType; @@ -231,6 +216,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(); @@ -519,6 +505,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); @@ -602,6 +593,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..60f0653f22 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/structuredAction/executionFailure.ts @@ -0,0 +1,22 @@ +// 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" + | "contract_stale" + | "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."; diff --git a/ts/packages/dispatcher/dispatcher/test/structuredActionExecution.spec.ts b/ts/packages/dispatcher/dispatcher/test/structuredActionExecution.spec.ts index 9f1372493b..e9050a5c5e 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: "contract_stale", + status: "contract_stale", + }); case "parallelQuestions": await Promise.all( ["first", "second"].map((message) => @@ -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)), ); @@ -670,6 +693,142 @@ describe("real structured dispatcher execution", () => { expect(entered).toEqual([]); }); + it.each(["contract_stale", "unavailable"] as const)( + "retains %s from a nested typed-flow confirmation", + async (status) => { + 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")), + ); + if (status === "contract_stale") { + const config = context.agents.getActionConfig("guarded"); + config.actionPolicies = { + ...config.actionPolicies, + write: { effects: "state-changing" }, + }; + } else { + readiness = { state: "setup-required" }; + await context.agents.refreshReadiness("guarded"); + } + const result = await answer(prompt, { + type: "confirmation", + approved: true, + }); + expect(result).toMatchObject({ + status, + error: { code: status }, + }); + expect(entered).toEqual([]); + expect(setup).not.toHaveBeenCalled(); + }, + ); + + describe.each([false, true])( + "suspension guard failures (agent RPC: %s)", + (rpc) => { + it.each([ + ["question", "contract_stale"], + ["question", "unavailable"], + ["blockingForm", "contract_stale"], + ["blockingForm", "unavailable"], + ] as const)( + "preserves %s -> %s without post-answer effects", + async (mode, status) => { + if (rpc) await useAgentRpc(); + const prompt = requirePrompt( + await dispatcher.executeAction( + await request("read", mode), + ), + ); + + if (status === "contract_stale") { + const config = + context.agents.getActionConfig("guarded"); + config.actionPolicies = { + ...config.actionPolicies, + read: { effects: "state-changing" }, + }; + } else { + readiness = { state: "setup-required" }; + await context.agents.refreshReadiness("guarded"); + } + const result = await answer( + prompt, + mode === "question" + ? { type: "question", selected: 0 } + : formAnswer, + ); + expect(result).toMatchObject({ + status, + error: { code: status }, + }); + 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(["contract_stale", "unavailable"] as const)( + "does not hide RPC uncertainty when delivering a host %s prompt failure", + async (status) => { + await useAgentRpc(true); + const input = await request("read", "question"); + const prompt = requirePrompt(await dispatcher.executeAction(input)); + if (status === "contract_stale") { + const config = context.agents.getActionConfig("guarded"); + config.actionPolicies = { + ...config.actionPolicies, + read: { effects: "state-changing" }, + }; + } else { + readiness = { state: "setup-required" }; + await context.agents.refreshReadiness("guarded"); + } + 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", @@ -1039,42 +1198,117 @@ 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", - }; - const found = await dispatcher.getActionContract(identity); - if (found.status !== "found") - throw new Error("Expected built-in action"); - const prompt = requirePrompt( - await dispatcher.executeAction({ + 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 found = await dispatcher.getActionContract(identity); + if (found.status !== "found") + throw new Error("Expected built-in action"); + expect(found.contract.availability.state).toBe("unsupported"); + const result = await dispatcher.executeAction({ protocolVersion: found.protocolVersion, scopeId: found.scopeId, ...identity, fingerprint: found.contract.fingerprint, - parameters: { - enable: true, - agentNames: ["review-no-such-agent"], + 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([]); + + const search = await dispatcher.searchActions({ + schemaName: "system.config", + }); + expect( + search.actions.find((a) => a.actionName === "toggleAgent") + ?.availability.state, + ).toBe("unsupported"); + expect( + search.actions.find((a) => a.actionName === "listAgents") + ?.availability.state, + ).toBe("available"); + + const legacy = await dispatcher.submitCommand( + "@config agent review-no-such-agent", + ); + 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"), ); - const result = await answer(prompt, { - type: "confirmation", - approved: true, + expect(result).toMatchObject({ + status: "unavailable", + error: { code: "unavailable" }, }); - 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.output.join("\n")).not.toContain("completed."); + 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 review-no-such-agent", + "@config agent setup guarded", ); if (!legacy.ok) throw new Error("Expected legacy submission"); - expect((await legacy.entry.completion)?.disposition?.status).toBe( - "failed", - ); + 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([ diff --git a/ts/pnpm-lock.yaml b/ts/pnpm-lock.yaml index e6b7e541fe..c11ea4cf37 100644 --- a/ts/pnpm-lock.yaml +++ b/ts/pnpm-lock.yaml @@ -4712,6 +4712,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