From 58c5943562e2401c079f2d6e4573b89f82b4214e Mon Sep 17 00:00:00 2001 From: George Ng Date: Thu, 10 Sep 2026 18:51:17 -0700 Subject: [PATCH 1/3] Add guarded structured action execution and resumable interactions Run typed actions through the existing dispatcher queue with live contract, scope and confirmation guards. Preserve true results, validate and resume interactions without replay, and bind trusted host reconnects to the same logical owner. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ts/packages/agentRpc/src/client.ts | 66 +- ts/packages/agentRpc/src/common.ts | 19 +- ts/packages/agentRpc/src/rpc.ts | 18 +- ts/packages/agentRpc/src/server.ts | 21 +- ts/packages/agentRpc/src/types.ts | 3 + .../agentRpc/test/actionContext.spec.ts | 79 + .../agentRpc/test/channelLogging.spec.ts | 62 + ts/packages/agentSdk/src/agentInterface.ts | 4 + .../agentSdk/src/helpers/choiceManager.ts | 5 + .../agentSdk/test/choiceCancellation.spec.ts | 23 + .../client/src/agentServerClient.ts | 10 +- .../agentServer/client/test/reconnect.spec.ts | 37 +- .../agentServer/protocol/src/protocol.ts | 16 + .../server/src/connectionHandler.ts | 50 +- .../server/src/conversationManager.ts | 50 +- .../server/src/sharedDispatcher.ts | 41 + .../server/src/structuredActionBindings.ts | 165 ++ .../server/test/conversationManager.spec.ts | 62 +- .../test/structuredActionBindings.spec.ts | 232 +++ .../server/test/structuredActionHost.spec.ts | 428 +++++ .../structuredActionJoinLifecycle.spec.ts | 93 ++ .../test/structuredActionJoinRpc.spec.ts | 256 +++ .../src/agent/browserActionHandler.mts | 6 + .../calendar/src/calendarActionHandlerV3.ts | 3 + .../agents/code/src/codeActionHandler.ts | 5 + .../agents/desktop/src/actionHandler.ts | 6 + .../agents/email/src/emailActionHandler.ts | 5 + .../github-cli/src/github-cliActionHandler.ts | 5 + .../agents/list/src/listActionHandler.ts | 5 + .../src/osNotificationsActionHandler.ts | 5 + .../src/screencaptureActionHandler.ts | 5 + .../src/windowsClockActionHandler.ts | 6 + .../dispatcher/src/command/command.ts | 20 +- .../src/context/commandHandlerContext.ts | 3 + .../src/context/dispatcher/dispatcherAgent.ts | 5 + .../src/context/pendingInteractionManager.ts | 7 + .../src/context/system/systemAgent.ts | 5 + .../dispatcher/dispatcher/src/dispatcher.ts | 16 +- .../dispatcher/src/execute/actionContext.ts | 15 +- .../dispatcher/src/execute/actionHandlers.ts | 83 +- .../dispatcher/src/execute/flowInterpreter.ts | 53 +- .../dispatcher/src/execute/pendingActions.ts | 24 +- .../dispatcher/src/execute/sessionContext.ts | 5 +- .../dispatcher/src/queue/requestQueue.ts | 39 +- .../src/structuredAction/discovery.ts | 19 +- .../src/structuredAction/execution.ts | 962 +++++++++++ .../src/structuredAction/executionHooks.ts | 162 ++ .../src/structuredAction/validation.ts | 249 +++ .../src/translation/actionSchemaFileCache.ts | 13 +- .../test/pendingInteractionManager.spec.ts | 14 + .../test/structuredActionExecution.spec.ts | 1407 +++++++++++++++++ .../dispatcher/rpc/src/dispatcherClient.ts | 9 + .../dispatcher/rpc/src/dispatcherServer.ts | 3 + .../dispatcher/rpc/src/dispatcherTypes.ts | 14 + .../dispatcher/rpc/test/dispatcherRpc.spec.ts | 91 ++ .../dispatcher/types/src/dispatcher.ts | 14 + .../dispatcher/types/src/structuredAction.ts | 103 +- 57 files changed, 5018 insertions(+), 108 deletions(-) create mode 100644 ts/packages/agentRpc/test/channelLogging.spec.ts create mode 100644 ts/packages/agentSdk/test/choiceCancellation.spec.ts create mode 100644 ts/packages/agentServer/server/src/structuredActionBindings.ts create mode 100644 ts/packages/agentServer/server/test/structuredActionBindings.spec.ts create mode 100644 ts/packages/agentServer/server/test/structuredActionHost.spec.ts create mode 100644 ts/packages/agentServer/server/test/structuredActionJoinLifecycle.spec.ts create mode 100644 ts/packages/agentServer/server/test/structuredActionJoinRpc.spec.ts create mode 100644 ts/packages/dispatcher/dispatcher/src/structuredAction/execution.ts create mode 100644 ts/packages/dispatcher/dispatcher/src/structuredAction/executionHooks.ts create mode 100644 ts/packages/dispatcher/dispatcher/src/structuredAction/validation.ts create mode 100644 ts/packages/dispatcher/dispatcher/test/structuredActionExecution.spec.ts diff --git a/ts/packages/agentRpc/src/client.ts b/ts/packages/agentRpc/src/client.ts index e42a6e31ad..de26956080 100644 --- a/ts/packages/agentRpc/src/client.ts +++ b/ts/packages/agentRpc/src/client.ts @@ -631,6 +631,28 @@ export async function createAgentRpcClient( // The shim needs to implement all the APIs regardless whether the actual agent // has that API. We remove remove it the one that is not necessary below. + async function invokeWithActionCancellation( + context: ActionContext, + contextParams: ActionContextParams, + invoke: () => Promise, + ): Promise { + const signal = context.abortSignal; + signal?.throwIfAborted(); + const onAbort = () => + rpc.send("cancelAction", { + actionContextId: contextParams.actionContextId, + }); + signal?.addEventListener("abort", onAbort, { once: true }); + try { + const pending = invoke(); + return await (context.waitForCompletionOnAbort + ? pending + : raceWithSignal(pending, signal)); + } finally { + signal?.removeEventListener("abort", onAbort); + } + } + const agent: Required = { initializeAgentContext(settings?: AppAgentInitSettings) { return rpc.invoke("initializeAgentContext", { @@ -653,32 +675,14 @@ export async function createAgentRpcClient( action: TypeAgentAction, context: ActionContext, ) { - return withActionContextAsync(context, (contextParams) => { - const signal = context.abortSignal; - if (signal) { - const onAbort = () => - rpc.send("cancelAction", { - actionContextId: contextParams.actionContextId, - }); - signal.addEventListener("abort", onAbort, { once: true }); - return raceWithSignal( - rpc.invoke("executeAction", { - ...contextParams, - action, - }), - signal, - ).finally(() => { - signal.removeEventListener("abort", onAbort); - }); - } - return raceWithSignal( + return withActionContextAsync(context, (contextParams) => + invokeWithActionCancellation(context, contextParams, () => rpc.invoke("executeAction", { ...contextParams, action, }), - signal, - ); - }); + ), + ); }, validateWildcardMatch( action: AppAction, @@ -809,6 +813,12 @@ export async function createAgentRpcClient( entityTypeName, }); }, + cancelChoice(choiceId: string, context: SessionContext) { + return rpc.invoke("cancelChoice", { + ...getContextParam(context), + choiceId, + }); + }, handleChoice( choiceId: string, response: @@ -819,11 +829,13 @@ export async function createAgentRpcClient( context: ActionContext, ) { return withActionContextAsync(context, (contextParams) => - rpc.invoke("handleChoice", { - ...contextParams, - choiceId, - response, - }), + invokeWithActionCancellation(context, contextParams, () => + rpc.invoke("handleChoice", { + ...contextParams, + choiceId, + response, + }), + ), ); }, getDynamicSchema( diff --git a/ts/packages/agentRpc/src/common.ts b/ts/packages/agentRpc/src/common.ts index 38bc9cbb1c..3e2bffe634 100644 --- a/ts/packages/agentRpc/src/common.ts +++ b/ts/packages/agentRpc/src/common.ts @@ -138,20 +138,27 @@ export function createChannelProvider( return; } if (message.name === undefined) { - debugError( - `Missing channel name in message: ${JSON.stringify(message)}`, - ); + debugError("Missing channel name in message"); return; } const channelAdapter = channelAdapters.get(message.name); if (channelAdapter === undefined) { debugError( - `Invalid channel name ${message.name} in message (available: ${Array.from(channelAdapters.keys()).join(", ")})`, + `Invalid channel name in message (available channels: ${channelAdapters.size})`, ); return; } - const msgType = message.message?.type || "unknown"; - const callId = message.message?.callId ?? "n/a"; + // Remote envelopes may contain capabilities or action parameters. + // Log only recognized routing metadata, never arbitrary wire values. + const type = message.message?.type; + const msgType = + typeof type === "string" && + ["call", "invoke", "invokeResult", "invokeError"].includes(type) + ? type + : "unknown"; + const callId = Number.isSafeInteger(message.message?.callId) + ? message.message.callId + : "n/a"; debug( `routing message to channel: ${message.name} (type=${msgType}, callId=${callId})`, ); diff --git a/ts/packages/agentRpc/src/rpc.ts b/ts/packages/agentRpc/src/rpc.ts index e99b32b5f2..856fa8cf85 100644 --- a/ts/packages/agentRpc/src/rpc.ts +++ b/ts/packages/agentRpc/src/rpc.ts @@ -92,6 +92,14 @@ export type RpcTracingOptions = { ) => RpcCorrelationFields | undefined; }; +/** The transport cannot establish whether an in-flight invocation completed. */ +export class RpcDisconnectedError extends Error { + constructor(message = "Agent channel disconnected") { + super(message); + this.name = "RpcDisconnectedError"; + } +} + export type RpcOptions = { // When true, a disconnect rejects in-flight calls but leaves invoke/send // intact so the rpc can be reattached to a fresh channel via rebind(). @@ -185,11 +193,11 @@ export function createRpc< let connected = true; let bindGeneration = 0; const errorFunc = () => { - throw new Error("Agent channel disconnected"); + throw new RpcDisconnectedError(); }; const rejectAllPending = (reason: string) => { for (const pendingInvoke of pending.values()) { - pendingInvoke.reject(new Error(reason)); + pendingInvoke.reject(new RpcDisconnectedError(reason)); } pending.clear(); }; @@ -565,7 +573,7 @@ export function createRpc< methodName as string, nextCallId++, ); - const error = new Error("Agent channel disconnected"); + const error = new RpcDisconnectedError(); emitStructuredStarted(options?.logger, lifecycle); emitStructuredCompleted(options?.logger, { ...lifecycle, @@ -605,7 +613,7 @@ export function createRpc< }; try { if (!connected) { - throw new Error("Agent channel disconnected"); + throw new RpcDisconnectedError(); } const correlation = getOutboundCorrelation( options?.tracing, @@ -672,7 +680,7 @@ export function createRpc< invoke(methodName, args), send: (methodName: keyof CallTargetFunctions, ...args: any[]) => { if (!connected) { - throw new Error("Agent channel disconnected"); + throw new RpcDisconnectedError(); } out( { diff --git a/ts/packages/agentRpc/src/server.ts b/ts/packages/agentRpc/src/server.ts index 8a19d4110c..5f6c921d7e 100644 --- a/ts/packages/agentRpc/src/server.ts +++ b/ts/packages/agentRpc/src/server.ts @@ -311,15 +311,26 @@ export function createAgentRpcServer( param.entityTypeName, ); }, + async cancelChoice(param) { + await agent.cancelChoice?.( + param.choiceId, + getSessionContextShim(param), + ); + }, async handleChoice(param) { if (agent.handleChoice === undefined) { throw new Error("Invalid invocation of handleChoice"); } - return agent.handleChoice( - param.choiceId, - param.response, - getActionContextShim(param), - ); + try { + return await agent.handleChoice( + param.choiceId, + param.response, + getActionContextShim(param), + ); + } finally { + if (param.actionContextId !== undefined) + actionAbortControllers.delete(param.actionContextId); + } }, async getDynamicSchema(param) { if (agent.getDynamicSchema === undefined) { diff --git a/ts/packages/agentRpc/src/types.ts b/ts/packages/agentRpc/src/types.ts index 9136dd7318..f84236811a 100644 --- a/ts/packages/agentRpc/src/types.ts +++ b/ts/packages/agentRpc/src/types.ts @@ -276,6 +276,9 @@ export type AgentInvokeFunctions = { | QuestionFormResponse; }, ): Promise; + cancelChoice( + param: Partial & { choiceId: string }, + ): Promise; getDynamicSchema( param: Partial & { schemaName: string }, ): Promise; diff --git a/ts/packages/agentRpc/test/actionContext.spec.ts b/ts/packages/agentRpc/test/actionContext.spec.ts index 4c642f8094..bd22ef8389 100644 --- a/ts/packages/agentRpc/test/actionContext.spec.ts +++ b/ts/packages/agentRpc/test/actionContext.spec.ts @@ -12,8 +12,87 @@ import { type ChannelProviderAdapter, } from "../src/common.js"; import { createAgentRpcServer } from "../src/server.js"; +import { + ChoiceManager, + createYesNoChoiceResult, +} from "@typeagent/agent-sdk/helpers/action"; describe("agent action context RPC", () => { + test("cancels a real SDK choice over agent RPC without invoking its callback", async () => { + let clientProvider: ChannelProviderAdapter; + let serverProvider: ChannelProviderAdapter; + clientProvider = createChannelProviderAdapter( + "choice-client", + (message, callback) => { + queueMicrotask(() => + serverProvider.notifyMessage(structuredClone(message)), + ); + callback?.(null); + }, + ); + serverProvider = createChannelProviderAdapter( + "choice-server", + (message, callback) => { + queueMicrotask(() => + clientProvider.notifyMessage(structuredClone(message)), + ); + callback?.(null); + }, + ); + const choices = new ChoiceManager(); + let invoked = 0; + const agent: AppAgent = { + initializeAgentContext: async () => ({}), + executeAction: async () => + createYesNoChoiceResult(choices, "Confirm", async () => { + invoked++; + return undefined; + }), + handleChoice: (id, response, context) => + choices.handleChoice(id, response, context), + cancelChoice: async (id) => { + choices.cancelChoice(id); + }, + }; + const server = createAgentRpcServer("choice", agent, serverProvider); + const client = await createAgentRpcClient( + "choice", + clientProvider, + server.agentInterface, + ); + try { + const agentContext = await client.initializeAgentContext?.(); + const sessionContext = { + agentContext, + sessionContextId: "choice-session", + } as SessionContext; + const actionContext = { + sessionContext, + isFromReasoningLoop: false, + } as ActionContext; + const result = await client.executeAction!( + { schemaName: "choice", actionName: "test" }, + actionContext, + ); + if ( + result === undefined || + result.error !== undefined || + result.pendingChoice === undefined + ) + throw new Error("Expected a pending choice"); + const id = result.pendingChoice.choiceId; + await client.cancelChoice!(id, sessionContext); + await expect( + client.handleChoice!(id, true, actionContext), + ).rejects.toThrow("Choice not found or expired"); + expect(invoked).toBe(0); + } finally { + server.closeFn(); + clientProvider.notifyDisconnected(); + serverProvider.notifyDisconnected(); + } + }); + test("propagates workingDirectory to the out-of-process agent", async () => { let clientProvider: ChannelProviderAdapter; let serverProvider: ChannelProviderAdapter; diff --git a/ts/packages/agentRpc/test/channelLogging.spec.ts b/ts/packages/agentRpc/test/channelLogging.spec.ts new file mode 100644 index 0000000000..508deb0095 --- /dev/null +++ b/ts/packages/agentRpc/test/channelLogging.spec.ts @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import debug from "debug"; +import { format } from "node:util"; +import { createChannelProviderAdapter } from "../src/common.js"; + +describe("channel diagnostic privacy", () => { + it("never logs payloads or untrusted routing fields, including malformed join envelopes", () => { + const previousNamespaces = debug.disable(); + const previousLog = debug.log; + const logs: string[] = []; + const marker = "test-only-private-payload-marker"; + debug.log = (...args: unknown[]) => { + logs.push(format(...args)); + }; + debug.enable("typeagent:channel-redaction:*"); + try { + const sent: unknown[] = []; + const received: unknown[] = []; + const provider = createChannelProviderAdapter( + "channel-redaction", + (message) => { + sent.push(message); + }, + ); + const channel = provider.createChannel("dispatcher"); + channel.on("message", (message) => { + received.push(message); + }); + const payload = { structuredActions: { resumeToken: marker } }; + const invoke = { + type: "invoke", + name: "joinConversation", + callId: 1, + args: [payload], + }; + const result = { type: "invokeResult", callId: 1, result: payload }; + channel.send(invoke); + provider.notifyMessage({ name: "dispatcher", message: invoke }); + provider.notifyMessage({ name: "dispatcher", message: result }); + provider.notifyMessage({ ...payload, message: invoke }); + provider.notifyMessage({ name: marker, message: invoke }); + provider.notifyMessage({ + name: "dispatcher", + message: { type: marker, callId: marker, result: payload }, + }); + provider.notifyDisconnected(); + + expect(sent).toEqual([{ name: "dispatcher", message: invoke }]); + expect(received.slice(0, 2)).toEqual([invoke, result]); + expect(logs.length).toBeGreaterThan(0); + expect(logs.join("\n")).toContain("Missing channel name"); + expect(logs.join("\n")).toContain("type=invoke"); + expect(logs.join("\n")).not.toContain(marker); + expect(logs.join("\n")).not.toContain("resumeToken"); + } finally { + debug.log = previousLog; + debug.enable(previousNamespaces); + } + }); +}); diff --git a/ts/packages/agentSdk/src/agentInterface.ts b/ts/packages/agentSdk/src/agentInterface.ts index 09381157ac..2d66d00c78 100644 --- a/ts/packages/agentSdk/src/agentInterface.ts +++ b/ts/packages/agentSdk/src/agentInterface.ts @@ -197,6 +197,7 @@ export interface AppAgent extends Partial { ): Promise; // Choice (yes/no confirmation, multi-select, or multi-question form) + cancelChoice?(choiceId: string, context: SessionContext): Promise; handleChoice?( choiceId: string, response: @@ -478,6 +479,9 @@ export interface ActionContext { readonly actionIO: ActionIO; readonly sessionContext: SessionContext; readonly abortSignal?: AbortSignal | undefined; + // Hosts retaining shared execution state require transports to await the + // actual handler after forwarding abort, rather than racing its response. + readonly waitForCompletionOnAbort?: boolean; // true when this action was dispatched from within the reasoning loop (via MCP execute_action), // false when dispatched directly from the translator. Agents can use this to decide whether diff --git a/ts/packages/agentSdk/src/helpers/choiceManager.ts b/ts/packages/agentSdk/src/helpers/choiceManager.ts index da61797e0b..37c4b2b943 100644 --- a/ts/packages/agentSdk/src/helpers/choiceManager.ts +++ b/ts/packages/agentSdk/src/helpers/choiceManager.ts @@ -39,6 +39,11 @@ export class ChoiceManager { return id; } + /** Release a suspended callback without fabricating a user answer. */ + cancelChoice(choiceId: string): boolean { + return this.callbacks.delete(choiceId); + } + async handleChoice( choiceId: string, response: ChoiceResponse, diff --git a/ts/packages/agentSdk/test/choiceCancellation.spec.ts b/ts/packages/agentSdk/test/choiceCancellation.spec.ts new file mode 100644 index 0000000000..ecbdf44ca5 --- /dev/null +++ b/ts/packages/agentSdk/test/choiceCancellation.spec.ts @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ActionContext } from "../src/agentInterface.js"; +import { ChoiceManager } from "../src/helpers/choiceManager.js"; + +describe("ChoiceManager cancellation", () => { + it("removes a callback without invoking it with a fabricated default", async () => { + const choices = new ChoiceManager(); + let invoked = 0; + const callback = async () => { + invoked++; + return undefined; + }; + const id = choices.registerChoice(callback); + expect(choices.cancelChoice(id)).toBe(true); + expect(choices.cancelChoice(id)).toBe(false); + await expect( + choices.handleChoice(id, true, {} as ActionContext), + ).rejects.toThrow("Choice not found or expired"); + expect(invoked).toBe(0); + }); +}); diff --git a/ts/packages/agentServer/client/src/agentServerClient.ts b/ts/packages/agentServer/client/src/agentServerClient.ts index 3f42da0c24..d1ca796e2b 100644 --- a/ts/packages/agentServer/client/src/agentServerClient.ts +++ b/ts/packages/agentServer/client/src/agentServerClient.ts @@ -149,6 +149,8 @@ export type ConversationDispatcher = { pendingInteractions?: NonNullable< JoinConversationResult["pendingInteractions"] >; + /** Retain only in trusted memory; pass back on an explicit resumed join. */ + structuredActions?: JoinConversationResult["structuredActions"]; }; export type AgentServerConnection = { @@ -542,6 +544,9 @@ export function createAgentServerConnection( connectionId: result.connectionId, queueSnapshot: result.queueSnapshot, pendingInteractions: result.pendingInteractions ?? [], + ...(result.structuredActions === undefined + ? {} + : { structuredActions: result.structuredActions }), }; }, @@ -832,7 +837,8 @@ export async function connectAgentServer( createChannelProviderAdapter( "agent-server:client", (message: any) => { - debug("Sending message to server:", message); + // Join payloads can carry private resume capabilities. + debug("Sending message to server"); ws.send(JSON.stringify(message)); }, ); @@ -843,7 +849,7 @@ export async function connectAgentServer( settle(channel); }; ws.onmessage = (event: WebSocket.MessageEvent) => { - debug("Received message from server:", event.data); + debug("Received message from server"); channel.notifyMessage(JSON.parse(event.data.toString())); }; ws.onclose = (event: WebSocket.CloseEvent) => { diff --git a/ts/packages/agentServer/client/test/reconnect.spec.ts b/ts/packages/agentServer/client/test/reconnect.spec.ts index c94c729f2d..b8c23cbdc2 100644 --- a/ts/packages/agentServer/client/test/reconnect.spec.ts +++ b/ts/packages/agentServer/client/test/reconnect.spec.ts @@ -9,13 +9,18 @@ import { ConversationInfo, } from "@typeagent/agent-server-protocol"; import WebSocket, { WebSocketServer } from "ws"; +import { jest } from "@jest/globals"; +import registerDebug from "debug"; import { connectAgentServer } from "../src/agentServerClient.js"; import { fakeClientIO } from "./conversation-stubConnection.js"; // Spin up a real ws server that speaks the agent-rpc control channel so the // reconnect/rebind path is exercised over the actual wire format. -async function startStubServer(convs: ConversationInfo[]): Promise<{ +async function startStubServer( + convs: ConversationInfo[], + resumeToken?: string, +): Promise<{ url: string; dropSockets: () => void; liveSocketCount: () => number; @@ -60,6 +65,9 @@ async function startStubServer(convs: ConversationInfo[]): Promise<{ connectionId: "conn-1", name: "Shell", pendingInteractions: [pendingInteraction], + ...(resumeToken === undefined + ? {} + : { structuredActions: { resumeToken } }), }), createConversation: async (name: string) => ({ conversationId: "c-new", @@ -227,3 +235,30 @@ describe("connectAgentServer leaveConversation on a dead channel", () => { } }); }); + +describe("connectAgentServer structured capabilities", () => { + test("does not log private capabilities in either wire direction", async () => { + const token = "private-resume-capability-".padEnd(43, "x"); + const stub = await startStubServer([], token); + const previous = registerDebug.disable(); + const log = jest + .spyOn(registerDebug, "log") + .mockImplementation(() => {}); + registerDebug.enable("*"); + const connection = await connectAgentServer(stub.url); + try { + const joined = await connection.joinConversation(fakeClientIO, { + conversationId: "c1", + structuredActions: { resumeToken: token }, + }); + expect(joined.structuredActions).toEqual({ resumeToken: token }); + expect(log).toHaveBeenCalled(); + expect(JSON.stringify(log.mock.calls)).not.toContain(token); + } finally { + await connection.close(); + await stub.close(); + registerDebug.enable(previous); + log.mockRestore(); + } + }); +}); diff --git a/ts/packages/agentServer/protocol/src/protocol.ts b/ts/packages/agentServer/protocol/src/protocol.ts index 1f8a3fb350..c0f6889812 100644 --- a/ts/packages/agentServer/protocol/src/protocol.ts +++ b/ts/packages/agentServer/protocol/src/protocol.ts @@ -65,6 +65,14 @@ export type DispatcherConnectOptions = { filter?: boolean; // filter to message for own request. Default is false (no filtering) clientType?: "shell" | "extension" | "android"; // identifies the connecting client type conversationId?: string; // join a specific conversation by UUID. If omitted, connects to the default conversation. + /** + * Opt into isolated structured-action ownership. Requires conversationId. + * Resume only with the capability returned by an earlier join of that + * same live conversation. Never substitute a client-supplied identity. + */ + structuredActions?: { + resumeToken?: string; + }; }; /** @@ -153,6 +161,14 @@ export type JoinConversationResult = { /** Server-side queue snapshot at join time. Omitted when idle/empty; * older clients ignore the field. */ queueSnapshot?: QueueSnapshot; + /** + * Private, in-memory resume capability for this logical structured caller. + * Keep it out of logs, model prompts, history, and persisted metadata. + * A resumed join revokes structured access on the previous connection. + */ + structuredActions?: { + resumeToken: string; + }; }; /** diff --git a/ts/packages/agentServer/server/src/connectionHandler.ts b/ts/packages/agentServer/server/src/connectionHandler.ts index 3985364d7c..ffda6621ca 100644 --- a/ts/packages/agentServer/server/src/connectionHandler.ts +++ b/ts/packages/agentServer/server/src/connectionHandler.ts @@ -24,6 +24,7 @@ import type { PortRegistrar } from "agent-dispatcher"; import type { ConversationManager } from "./conversationManager.js"; import { resolveTunnelUrlForDiscovery } from "./tunnelResolver.js"; import { getSpeechToken } from "./speechToken.js"; +import { validateStructuredActionJoin } from "./structuredActionBindings.js"; import registerDebug from "debug"; // Disconnect cleanup is best effort, so a failure cannot be surfaced to anyone: @@ -249,6 +250,8 @@ export function createAgentServerConnectionHandler( string, { dispatcher: Dispatcher; connectionId: string } >(); + const joiningConversations = new Set(); + let disconnected = false; // Client-hosted agents this connection registered, per conversation. // Keyed by instance so disconnect removes only this connection's @@ -326,23 +329,34 @@ export function createAgentServerConnectionHandler( getMacroRun: async (runId) => macroManager.getMacroRun(runId), joinConversation: async (options?: DispatcherConnectOptions) => { + validateStructuredActionJoin(options); + if (disconnected) { + throw new Error("Agent connection is disconnected"); + } // Resolve conversation ID first (may auto-create default) const conversationId = await conversationManager.resolveConversationId( options?.conversationId, ); - if (joinedConversations.has(conversationId)) { + if (disconnected) { + throw new Error("Agent connection is disconnected"); + } + if ( + joinedConversations.has(conversationId) || + joiningConversations.has(conversationId) + ) { throw new Error( `Already joined conversation '${conversationId}'. Call leaveConversation() before joining again.`, ); } - // Create conversation-namespaced channels - const clientIOChannel = channelProvider.createChannel( - getClientIOChannelName(conversationId), - ); + joiningConversations.add(conversationId); + let acquiredConnectionId: string | undefined; try { + const clientIOChannel = channelProvider.createChannel( + getClientIOChannelName(conversationId), + ); const clientIORpcClient = createClientIORpcClient(clientIOChannel); @@ -380,6 +394,10 @@ export function createAgentServerConnectionHandler( }, options, ); + acquiredConnectionId = result.connectionId; + if (disconnected) { + throw new Error("Agent connection is disconnected"); + } const dispatcherChannel = channelProvider.createChannel( getDispatcherChannelName(conversationId), @@ -490,12 +508,27 @@ export function createAgentServerConnectionHandler( if (result.queueSnapshot !== undefined) { joinResult.queueSnapshot = result.queueSnapshot; } + if (result.structuredActions !== undefined) { + joinResult.structuredActions = result.structuredActions; + } return joinResult; } catch (e) { - channelProvider.deleteChannel( - getClientIOChannelName(conversationId), - ); + try { + if (acquiredConnectionId !== undefined) { + await conversationManager.leaveConversation( + conversationId, + acquiredConnectionId, + ); + } + } finally { + joinedConversations.delete(conversationId); + channelProvider.deleteChannel( + getClientIOChannelName(conversationId), + ); + } throw e; + } finally { + joiningConversations.delete(conversationId); } }, @@ -722,6 +755,7 @@ export function createAgentServerConnectionHandler( // Clean up all conversations on disconnect channelProvider.on("disconnect", () => { + disconnected = true; onDisconnect?.(); if (staleNotifier !== undefined) { staleNotifiers.delete(staleNotifier); diff --git a/ts/packages/agentServer/server/src/conversationManager.ts b/ts/packages/agentServer/server/src/conversationManager.ts index 087b738b68..c2c462017c 100644 --- a/ts/packages/agentServer/server/src/conversationManager.ts +++ b/ts/packages/agentServer/server/src/conversationManager.ts @@ -12,6 +12,7 @@ import { ConversationMatch, ConversationContentMatch, ConversationSource, + JoinConversationResult, RenameConversationOptions, } from "@typeagent/agent-server-protocol"; import { @@ -55,6 +56,7 @@ import { type ConversationSummaryTranslator, } from "./conversationSummary.js"; import { lockInstanceDir } from "agent-dispatcher/internal"; +import { validateStructuredActionJoin } from "./structuredActionBindings.js"; import registerDebug from "debug"; const debugConversation = registerDebug("agent-server:conversation"); @@ -182,6 +184,7 @@ export type ConversationManager = { name: string; pendingInteractions: PendingInteractionRequest[]; queueSnapshot?: QueueSnapshot; + structuredActions?: JoinConversationResult["structuredActions"]; }>; leaveConversation( conversationId: string, @@ -1129,21 +1132,46 @@ export async function createConversationManager( name: string; pendingInteractions: PendingInteractionRequest[]; queueSnapshot?: QueueSnapshot; + structuredActions?: JoinConversationResult["structuredActions"]; }> { + validateStructuredActionJoin(options, conversationId); const record = conversations.get(conversationId); if (record === undefined) { throw new Error(`Conversation not found: ${conversationId}`); } + if (options?.structuredActions !== undefined && record.readOnly) { + throw new Error( + "Structured execution is unavailable in a read-only conversation", + ); + } + if ( + options?.structuredActions?.resumeToken !== undefined && + record.sharedDispatcher === undefined + ) { + throw new Error( + "Structured action resume state is unavailable; do not replay an interrupted action", + ); + } cancelIdleTimer(record); const sharedDispatcher = await ensureDispatcher(record); - const dispatcher = sharedDispatcher.join( - clientIO, - closeFn, - options, - ); - touchConversation(conversationId); - await saveMetadata(); + let dispatcher: Dispatcher | undefined; + try { + dispatcher = sharedDispatcher.join(clientIO, closeFn, options); + touchConversation(conversationId); + await saveMetadata(); + } catch (error) { + try { + if (dispatcher?.connectionId !== undefined) { + await sharedDispatcher.leave(dispatcher.connectionId); + } + } finally { + if (sharedDispatcher.clientCount === 0) { + startIdleTimer(record); + } + } + throw error; + } debugConversation( `Client joined conversation "${record.name}" (${conversationId}), clients: ${sharedDispatcher.clientCount}`, @@ -1166,6 +1194,7 @@ export async function createConversationManager( name: string; pendingInteractions: PendingInteractionRequest[]; queueSnapshot?: QueueSnapshot; + structuredActions?: JoinConversationResult["structuredActions"]; } = { dispatcher, connectionId: dispatcher.connectionId!, @@ -1178,6 +1207,13 @@ export async function createConversationManager( if (queueSnapshot !== undefined) { result.queueSnapshot = queueSnapshot; } + const structuredActions = + sharedDispatcher.getStructuredActionBinding( + dispatcher.connectionId!, + ); + if (structuredActions !== undefined) { + result.structuredActions = structuredActions; + } return result; }, diff --git a/ts/packages/agentServer/server/src/sharedDispatcher.ts b/ts/packages/agentServer/server/src/sharedDispatcher.ts index 9abc00a32a..2f0a0ce863 100644 --- a/ts/packages/agentServer/server/src/sharedDispatcher.ts +++ b/ts/packages/agentServer/server/src/sharedDispatcher.ts @@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto"; import { DispatcherConnectOptions, + JoinConversationResult, registerClientType, unregisterClient, } from "@typeagent/agent-server-protocol"; @@ -38,6 +39,11 @@ import { selectWorkingDirectoryProposal, resolveWorkingDirectory, } from "./workingDirectoryPolicy.js"; +import { + StructuredActionBindings, + type StructuredActionLease, + validateStructuredActionJoin, +} from "./structuredActionBindings.js"; import registerDebug from "debug"; const debugConnect = registerDebug("agent-server:connect"); @@ -479,6 +485,10 @@ export async function createSharedDispatcher( ...options, clientIO, }); + const structuredBindings = new StructuredActionBindings( + () => context.session, + ); + const structuredLeases = new Map(); // Intercept display methods on the shared clientIO to mirror display // traffic into the DisplayLog for later replay. Patches context.clientIO @@ -624,6 +634,19 @@ export async function createSharedDispatcher( // so interactions created before disconnect are unroutable after // reconnect. See docs/async-clientio-design.md §Open Questions. const connectionId = (nextConnectionId++).toString(); + const anonymousScope = {}; + validateStructuredActionJoin(options); + const structuredLease = + options?.structuredActions === undefined + ? undefined + : structuredBindings.acquire( + options.conversationId!, + connectionId, + options.structuredActions.resumeToken, + ); + if (structuredLease !== undefined) { + structuredLeases.set(connectionId, structuredLease); + } let selectedWorkingDirectory: string | undefined; const wasEmpty = clients.size === 0; clients.set(connectionId, { @@ -642,6 +665,8 @@ export async function createSharedDispatcher( context, connectionId, async () => { + structuredLease?.release(); + structuredLeases.delete(connectionId); clients.delete(connectionId); dispatchers.delete(connectionId); unregisterClient(connectionId); @@ -677,6 +702,12 @@ export async function createSharedDispatcher( `Client disconnected: ${connectionId} (total clients: ${clients.size})`, ); }, + structuredLease?.access ?? + (() => ({ + scope: anonymousScope, + canDiscoverSchema: () => clients.has(connectionId), + canExecute: false, + })), ); dispatchers.set(connectionId, dispatcher); debugConnect( @@ -820,6 +851,12 @@ export async function createSharedDispatcher( return dispatcher; }, + getStructuredActionBinding(connectionId) { + const lease = structuredLeases.get(connectionId); + return lease === undefined + ? undefined + : { resumeToken: lease.resumeToken }; + }, respondToInteraction(response: PendingInteractionResponse): void { debugInteractionInfo("respondToInteraction", { interactionId: response.interactionId, @@ -938,6 +975,7 @@ export async function createSharedDispatcher( }, async close() { cancelNoClientsGraceTimer(); + structuredBindings.close(); pendingInteractions.cancelAll( new Error("SharedDispatcher closing"), ); @@ -1058,6 +1096,9 @@ export type SharedDispatcher = { closeFn: () => void, options?: DispatcherConnectOptions, ): Dispatcher; + getStructuredActionBinding( + connectionId: string, + ): JoinConversationResult["structuredActions"]; respondToInteraction(response: PendingInteractionResponse): void; cancelInteraction(interactionId: string): void; getPendingInteractions( diff --git a/ts/packages/agentServer/server/src/structuredActionBindings.ts b/ts/packages/agentServer/server/src/structuredActionBindings.ts new file mode 100644 index 0000000000..ad7dd76115 --- /dev/null +++ b/ts/packages/agentServer/server/src/structuredActionBindings.ts @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { randomBytes } from "node:crypto"; +import type { StructuredActionAccess } from "agent-dispatcher/internal"; +import type { DispatcherConnectOptions } from "@typeagent/agent-server-protocol"; + +export const MAX_STRUCTURED_ACTION_BINDINGS = 100; +export const STRUCTURED_ACTION_BINDING_IDLE_MS = 30 * 60 * 1000; + +type Binding = { + conversationId: string; + scope: object; + connectionId: string | undefined; + lastUsed: number; +}; + +export type StructuredActionLease = { + resumeToken: string; + access: StructuredActionAccess; + release(): void; +}; + +export function validateStructuredActionJoin( + options: DispatcherConnectOptions | undefined, + conversationId?: string, +): void { + if (options?.structuredActions === undefined) { + return; + } + const structured = options.structuredActions; + if ( + structured === null || + typeof structured !== "object" || + Array.isArray(structured) || + Object.keys(structured).some((key) => key !== "resumeToken") + ) { + throw new Error("Invalid structured action join options"); + } + if ( + typeof options.conversationId !== "string" || + options.conversationId.trim().length === 0 || + (conversationId !== undefined && + options.conversationId !== conversationId) + ) { + throw new Error( + "Structured actions require the explicit target conversationId", + ); + } + if ( + structured.resumeToken !== undefined && + (typeof structured.resumeToken !== "string" || + !/^[A-Za-z0-9_-]{43}$/.test(structured.resumeToken)) + ) { + throw new Error("Invalid structured action resume capability"); + } +} + +/** + * Resume ownership within the existing local-server transport trust boundary. + * Tokens never enter the dispatcher scope or any persisted/broadcast metadata. + */ +export class StructuredActionBindings { + private readonly bindings = new Map(); + private session: object; + private closed = false; + + public constructor( + private readonly getSession: () => object, + private readonly now: () => number = Date.now, + ) { + this.session = getSession(); + } + + private prune(): void { + const session = this.getSession(); + if (session !== this.session) { + this.bindings.clear(); + this.session = session; + } + const cutoff = this.now() - STRUCTURED_ACTION_BINDING_IDLE_MS; + for (const [token, binding] of this.bindings) { + if (binding.lastUsed <= cutoff) { + this.bindings.delete(token); + } + } + } + + public acquire( + conversationId: string, + connectionId: string, + resumeToken?: string, + ): StructuredActionLease { + if (this.closed) { + throw new Error("Structured action binding is closed"); + } + this.prune(); + let token: string; + let binding: Binding; + if (resumeToken !== undefined) { + const existing = this.bindings.get(resumeToken); + if ( + existing === undefined || + existing.conversationId !== conversationId + ) { + throw new Error( + "Structured action resume state is unavailable; do not replay an interrupted action", + ); + } + token = resumeToken; + binding = existing; + } else { + if (this.bindings.size >= MAX_STRUCTURED_ACTION_BINDINGS) { + throw new Error("Structured action binding capacity reached"); + } + token = randomBytes(32).toString("base64url"); + binding = { + conversationId, + scope: {}, + connectionId, + lastUsed: this.now(), + }; + this.bindings.set(token, binding); + } + // No await between resolving a token and revoking the previous lease. + binding.connectionId = connectionId; + binding.lastUsed = this.now(); + const deniedScope = {}; + let released = false; + const isCurrent = () => { + this.prune(); + return ( + !this.closed && + !released && + this.bindings.get(token) === binding && + binding.connectionId === connectionId + ); + }; + return { + resumeToken: token, + access: () => { + const current = isCurrent(); + if (current) { + binding.lastUsed = this.now(); + } + return { + scope: current ? binding.scope : deniedScope, + canDiscoverSchema: () => isCurrent(), + isActive: isCurrent, + }; + }, + release: () => { + released = true; + if (binding.connectionId === connectionId) { + binding.connectionId = undefined; + } + }, + }; + } + + public close(): void { + this.closed = true; + this.bindings.clear(); + } +} diff --git a/ts/packages/agentServer/server/test/conversationManager.spec.ts b/ts/packages/agentServer/server/test/conversationManager.spec.ts index 5ea6725b0c..2ce7af1a40 100644 --- a/ts/packages/agentServer/server/test/conversationManager.spec.ts +++ b/ts/packages/agentServer/server/test/conversationManager.spec.ts @@ -5,7 +5,7 @@ import { afterEach, describe, expect, test } from "@jest/globals"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import type { DispatcherOptions } from "agent-dispatcher"; +import type { ClientIO, DispatcherOptions } from "agent-dispatcher"; import { createConversationManager } from "../src/conversationManager.js"; const tempDirs: string[] = []; @@ -155,3 +155,63 @@ describe("ConversationManager renameConversation", () => { await manager.close(); }); }); + +describe("ConversationManager structured joins", () => { + test("rejects a read-only conversation before initializing an execution dispatcher", async () => { + const manager = await createConversationManager( + "test-host", + {} as DispatcherOptions, + await createTempDir(), + ); + try { + const imported = await manager.importCopilotMirror({ + sessionId: "copilot-session", + name: "Read-only mirror", + createdAt: new Date(0).toISOString(), + displayLogEntries: [], + lastSyncedTurnIndex: 0, + }); + await expect( + manager.joinConversation( + imported.conversationId, + {} as ClientIO, + () => {}, + { + conversationId: imported.conversationId, + structuredActions: {}, + }, + ), + ).rejects.toThrow( + "Structured execution is unavailable in a read-only conversation", + ); + expect((await manager.listConversations())[0].clientCount).toBe(0); + } finally { + await manager.close(); + } + }); + + test("rejects mismatched explicit targets before restoring a dispatcher", async () => { + const manager = await createConversationManager( + "test-host", + {} as DispatcherOptions, + await createTempDir(), + ); + try { + const conversation = await manager.createConversation("Target"); + await expect( + manager.joinConversation( + conversation.conversationId, + {} as ClientIO, + () => {}, + { + conversationId: "different", + structuredActions: {}, + }, + ), + ).rejects.toThrow("explicit target conversationId"); + expect((await manager.listConversations())[0].clientCount).toBe(0); + } finally { + await manager.close(); + } + }); +}); diff --git a/ts/packages/agentServer/server/test/structuredActionBindings.spec.ts b/ts/packages/agentServer/server/test/structuredActionBindings.spec.ts new file mode 100644 index 0000000000..024a7e24bc --- /dev/null +++ b/ts/packages/agentServer/server/test/structuredActionBindings.spec.ts @@ -0,0 +1,232 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, test } from "@jest/globals"; +import type { DispatcherConnectOptions } from "@typeagent/agent-server-protocol"; +import { + MAX_STRUCTURED_ACTION_BINDINGS, + STRUCTURED_ACTION_BINDING_IDLE_MS, + StructuredActionBindings, + validateStructuredActionJoin, +} from "../src/structuredActionBindings.js"; + +function fixture() { + let session = {}; + let now = 1; + const bindings = new StructuredActionBindings( + () => session, + () => now, + ); + return { + bindings, + replaceSession() { + session = {}; + }, + advance(ms: number) { + now += ms; + }, + }; +} + +describe("structured action logical bindings", () => { + test("issues isolated high-entropy capabilities and hides them from access", () => { + const { bindings } = fixture(); + const first = bindings.acquire("conversation", "one"); + const second = bindings.acquire("conversation", "two"); + expect(first.resumeToken).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(second.resumeToken).not.toBe(first.resumeToken); + expect(first.access().scope).not.toBe(second.access().scope); + expect(first.access().canDiscoverSchema("list")).toBe(true); + expect(JSON.stringify(first.access())).not.toContain(first.resumeToken); + }); + + test("resumes only the same live conversation and revokes the old facade", () => { + const { bindings } = fixture(); + const first = bindings.acquire("conversation", "one"); + const scope = first.access().scope; + expect(() => + bindings.acquire("different", "two", first.resumeToken), + ).toThrow("resume state is unavailable"); + expect(first.access().scope).toBe(scope); + + const second = bindings.acquire( + "conversation", + "two", + first.resumeToken, + ); + expect(second.access().scope).toBe(scope); + expect(first.access().scope).not.toBe(scope); + expect(first.access().canDiscoverSchema("list")).toBe(false); + first.release(); + expect(second.access().canDiscoverSchema("list")).toBe(true); + }); + + test("the most recent takeover wins without resurrecting prior leases", () => { + const { bindings } = fixture(); + const first = bindings.acquire("conversation", "one"); + const scope = first.access().scope; + const second = bindings.acquire( + "conversation", + "two", + first.resumeToken, + ); + const third = bindings.acquire( + "conversation", + "three", + first.resumeToken, + ); + expect(third.access().scope).toBe(scope); + for (const stale of [first, second]) { + expect(stale.access().canDiscoverSchema("list")).toBe(false); + } + third.release(); + expect(third.access().canDiscoverSchema("list")).toBe(false); + const fourth = bindings.acquire( + "conversation", + "four", + first.resumeToken, + ); + expect(fourth.access().scope).toBe(scope); + expect(second.access().canDiscoverSchema("list")).toBe(false); + }); + + test("a policy snapshot also stops disclosing after takeover", () => { + const { bindings } = fixture(); + const first = bindings.acquire("conversation", "one"); + const policy = first.access(); + bindings.acquire("conversation", "two", first.resumeToken); + expect(policy.canDiscoverSchema("list")).toBe(false); + }); + + test("released ownership resumes within its lifetime, not with a guessed identity", () => { + const { bindings } = fixture(); + const first = bindings.acquire("conversation", "one"); + const scope = first.access().scope; + first.release(); + expect(() => + bindings.acquire("conversation", "two", "copilot-session-id"), + ).toThrow("resume state is unavailable"); + const second = bindings.acquire( + "conversation", + "two", + first.resumeToken, + ); + expect(second.access().scope).toBe(scope); + }); + + test("replacing the live Session invalidates old tokens and scope", () => { + const { bindings, replaceSession } = fixture(); + const first = bindings.acquire("conversation", "one"); + const scope = first.access().scope; + replaceSession(); + expect(first.access().scope).not.toBe(scope); + expect(() => + bindings.acquire("conversation", "two", first.resumeToken), + ).toThrow("resume state is unavailable"); + const second = bindings.acquire("conversation", "two"); + expect(second.access().scope).not.toBe(scope); + }); + + test("idle expiry is checked at access and resume, not only when making room", () => { + const { bindings, advance } = fixture(); + const first = bindings.acquire("conversation", "one"); + advance(STRUCTURED_ACTION_BINDING_IDLE_MS); + expect(first.access().canDiscoverSchema("list")).toBe(false); + expect(() => + bindings.acquire("conversation", "two", first.resumeToken), + ).toThrow("resume state is unavailable"); + }); + + test("valid use renews idle lifetime", () => { + const { bindings, advance } = fixture(); + const first = bindings.acquire("conversation", "one"); + advance(STRUCTURED_ACTION_BINDING_IDLE_MS - 1); + expect(first.access().canDiscoverSchema("list")).toBe(true); + advance(2); + expect(first.access().canDiscoverSchema("list")).toBe(true); + }); + + test("capacity never evicts a live binding and expiry frees space", () => { + const { bindings, advance } = fixture(); + const first = bindings.acquire("conversation", "zero"); + for (let i = 1; i < MAX_STRUCTURED_ACTION_BINDINGS; i++) { + bindings.acquire("conversation", String(i)); + } + expect(() => bindings.acquire("conversation", "overflow")).toThrow( + "capacity reached", + ); + expect(first.access().canDiscoverSchema("list")).toBe(true); + const resumed = bindings.acquire( + "conversation", + "new", + first.resumeToken, + ); + expect(resumed.access().canDiscoverSchema("list")).toBe(true); + advance(STRUCTURED_ACTION_BINDING_IDLE_MS); + expect(() => + bindings.acquire("conversation", "replacement"), + ).not.toThrow(); + }); + + test("close and a new server registry cannot resume old operations", () => { + const { bindings } = fixture(); + const first = bindings.acquire("conversation", "one"); + bindings.close(); + expect(first.access().canDiscoverSchema("list")).toBe(false); + expect(() => + bindings.acquire("conversation", "two", first.resumeToken), + ).toThrow("binding is closed"); + expect(() => + fixture().bindings.acquire( + "conversation", + "two", + first.resumeToken, + ), + ).toThrow("resume state is unavailable"); + }); +}); + +describe("structured join validation", () => { + test("legacy joins stay optional; structured joins require explicit conversation", () => { + expect(() => validateStructuredActionJoin(undefined)).not.toThrow(); + expect(() => validateStructuredActionJoin({})).not.toThrow(); + expect(() => + validateStructuredActionJoin({ structuredActions: {} }), + ).toThrow("explicit target conversationId"); + expect(() => + validateStructuredActionJoin({ + conversationId: "conversation", + structuredActions: {}, + }), + ).not.toThrow(); + }); + + test.each([ + null, + [], + "session", + { resumeToken: 1 }, + { resumeToken: "" }, + { resumeToken: "copilot-session-id" }, + { approved: true }, + ])("rejects malformed structured join options %#", (structuredActions) => { + const options = { + conversationId: "conversation", + structuredActions, + } as unknown as DispatcherConnectOptions; + expect(() => validateStructuredActionJoin(options)).toThrow(); + }); + + test("validates the resolved target and accepts only the opaque capability shape", () => { + const options: DispatcherConnectOptions = { + conversationId: "conversation", + structuredActions: { resumeToken: "a".repeat(43) }, + }; + expect(() => + validateStructuredActionJoin(options, "conversation"), + ).not.toThrow(); + expect(() => + validateStructuredActionJoin(options, "different"), + ).toThrow("explicit target conversationId"); + }); +}); diff --git a/ts/packages/agentServer/server/test/structuredActionHost.spec.ts b/ts/packages/agentServer/server/test/structuredActionHost.spec.ts new file mode 100644 index 0000000000..20328594da --- /dev/null +++ b/ts/packages/agentServer/server/test/structuredActionHost.spec.ts @@ -0,0 +1,428 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, test } from "@jest/globals"; +import type { + AppAgent, + AppAgentManifest, + ActionResult, +} from "@typeagent/agent-sdk"; +import { ChoiceManager } from "@typeagent/agent-sdk/helpers/action"; +import type { AppAgentProvider } from "agent-dispatcher"; +import type { + ClientIO, + Dispatcher, + StructuredActionExecutionResult, + StructuredActionResponse, +} from "@typeagent/dispatcher-types"; +import type { DispatcherConnectOptions } from "@typeagent/agent-server-protocol"; +import { + createChannelProviderAdapter, + type ChannelProviderAdapter, +} from "@typeagent/agent-rpc/channel"; +import { createAgentServerConnection } from "@typeagent/agent-server-client"; +import type { MacroManager } from "@typeagent/copilot-macros"; +import type { ConversationManager } from "../src/conversationManager.js"; +import { createAgentServerConnectionHandler } from "../src/connectionHandler.js"; +import { createSharedDispatcher } from "../src/sharedDispatcher.js"; + +const manifest: AppAgentManifest = { + description: "Offline host execution fixture", + emojiChar: "", + schema: { + description: "Host actions", + schemaType: "Actions", + schemaFile: { + format: "ts", + content: ` + export type Actions = Read | Write; + type Params = { mode: "plain" | "question" | "choice" }; + type Read = { actionName: "read"; parameters: Params }; + type Write = { actionName: "write"; parameters: Params }; + `, + }, + actionPolicies: { read: { effects: "read-only" } }, + }, +}; + +function prompt(result: StructuredActionExecutionResult) { + if (result.status !== "requires_interaction") { + throw new Error(`Expected interaction, got ${result.status}`); + } + return result; +} + +async function fixture() { + let entered = 0; + let effects = 0; + const messages: unknown[] = []; + const choices = new ChoiceManager(); + const complete = (): ActionResult => ({ + entities: [{ name: "item", type: ["Item"], uniqueId: "item-1" }], + resultEntity: { name: "item", type: ["Item"], uniqueId: "item-1" }, + resultValue: { ids: ["item-1"] }, + historyText: "Done", + displayContent: { type: "html", content: "Done" }, + }); + const agent: AppAgent = { + checkReadiness: async () => ({ state: "ready" }), + cancelChoice: async (id) => { + choices.cancelChoice(id); + }, + handleChoice: (id, response, context) => + choices.handleChoice(id, response, context), + executeAction: async (action, context) => { + entered++; + if (action.parameters?.mode === "question") { + await context.sessionContext.popupQuestion( + "Allow?", + ["Yes", "No"], + 0, + ); + } else if (action.parameters?.mode === "choice") { + return { + entities: [], + pendingChoice: { + type: "yesNo", + message: "Continue?", + choiceId: choices.registerChoice(async (response) => { + if (response === true) effects++; + return complete(); + }), + }, + }; + } + effects++; + return complete(); + }, + }; + const provider: AppAgentProvider = { + getAppAgentNames: () => ["hostTest"], + getAppAgentManifest: async () => manifest, + loadAppAgent: async () => agent, + unloadAppAgent: async () => {}, + }; + const shared = await createSharedDispatcher("structured-host-test", { + agents: { schemas: ["hostTest"], actions: ["hostTest"] }, + appAgentProviders: [provider], + translation: { enabled: false }, + explainer: { enabled: false }, + cache: { enabled: false }, + collectCommandResult: true, + metrics: true, + conversationMemorySettings: { + requestKnowledgeExtraction: false, + actionResultEntityStorage: false, + actionResultKnowledgeExtraction: false, + }, + }); + const manager = { + async resolveConversationId(id?: string) { + return id ?? "conversation"; + }, + async joinConversation( + _id: string, + io: ClientIO, + close: () => void, + options?: DispatcherConnectOptions, + ) { + const dispatcher = shared.join(io, close, options); + return { + dispatcher, + connectionId: dispatcher.connectionId, + name: "Test", + pendingInteractions: shared.getPendingInteractions( + dispatcher.connectionId!, + true, + ), + structuredActions: shared.getStructuredActionBinding( + dispatcher.connectionId!, + ), + }; + }, + leaveConversation: async (_id: string, connectionId: string) => + shared.leave(connectionId), + } as unknown as ConversationManager; + const { handler } = createAgentServerConnectionHandler({ + conversationManager: manager, + macroManager: {} as MacroManager, + shutdown() {}, + getUserIdentity: () => ({ + username: "test", + displayName: "Test", + initial: "T", + }), + }); + const io: ClientIO = { + clear() {}, + exit() {}, + shutdown() {}, + setUserRequest() {}, + setDisplayInfo() {}, + setDisplay: (message) => messages.push(message), + appendDisplay: (message) => messages.push(message), + appendDiagnosticData: (_request, data) => messages.push(data), + setDynamicDisplay() {}, + question: async () => { + throw new Error("Structured question leaked"); + }, + proposeAction: async () => { + throw new Error("Structured proposal leaked"); + }, + notify() {}, + openLocalView: async () => {}, + closeLocalView: async () => {}, + requestChoice: (...args) => messages.push(args), + requestForm: (...args) => messages.push(args), + requestInteraction: (interaction) => messages.push(interaction), + interactionResolved() {}, + interactionCancelled() {}, + takeAction() {}, + }; + const closeConnections: (() => Promise)[] = []; + return { + shared, + messages, + counts: () => ({ entered, effects }), + async join(structured: { resumeToken?: string } | false = {}) { + let client: ChannelProviderAdapter | undefined; + const server = createChannelProviderAdapter( + "host-test-server", + (message) => client?.notifyMessage(structuredClone(message)), + ); + client = createChannelProviderAdapter( + "host-test-client", + (message) => server.notifyMessage(structuredClone(message)), + ); + const clientAdapter = client; + const disconnect = () => { + server.notifyDisconnected(); + clientAdapter.notifyDisconnected(); + }; + handler(server, disconnect); + const connection = createAgentServerConnection(client, disconnect); + closeConnections.push(() => connection.close()); + const joined = await connection.joinConversation(io, { + conversationId: "conversation", + filter: true, + ...(structured === false + ? {} + : { structuredActions: structured }), + }); + return { ...joined, disconnect, connection }; + }, + async close() { + for (const close of closeConnections) await close(); + await shared.close(); + }, + }; +} + +async function execute( + dispatcher: Dispatcher, + actionName: "write" | "read", + mode = "plain", +) { + const found = await dispatcher.getActionContract({ + schemaName: "hostTest", + actionName, + }); + if (found.status !== "found") throw new Error("Expected action contract"); + return dispatcher.executeAction({ + protocolVersion: found.protocolVersion, + scopeId: found.scopeId, + schemaName: found.contract.schemaName, + actionName: found.contract.actionName, + fingerprint: found.contract.fingerprint, + parameters: { mode }, + }); +} + +function respond( + dispatcher: Dispatcher, + result: StructuredActionExecutionResult, + response: StructuredActionResponse, +) { + const interaction = prompt(result); + return dispatcher.continueAction({ + protocolVersion: interaction.protocolVersion, + scopeId: interaction.scopeId, + operationId: interaction.operationId, + interactionId: interaction.interactionId, + response, + }); +} + +describe("real structured shared host and dispatcher RPC", () => { + test("resumes confirmation after takeover, preserving actual values and private prompts", async () => { + const host = await fixture(); + try { + const first = await host.join(); + const pending = prompt(await execute(first.dispatcher, "write")); + expect(host.counts()).toEqual({ entered: 0, effects: 0 }); + if (first.structuredActions === undefined) + throw new Error("Expected binding"); + const second = await host.join(first.structuredActions); + const stale = await respond(first.dispatcher, pending, { + type: "confirmation", + approved: true, + }); + expect(stale.status).not.toBe("completed"); + expect(host.counts().effects).toBe(0); + const completed = await respond(second.dispatcher, pending, { + type: "confirmation", + approved: true, + }); + expect(completed.status).toBe("completed"); + expect(completed.results[0].result).toMatchObject({ + resultValue: { ids: ["item-1"] }, + resultEntity: { uniqueId: "item-1" }, + }); + expect(host.counts()).toEqual({ entered: 1, effects: 1 }); + expect(host.shared.pendingInteractions.size).toBe(0); + const broadcast = JSON.stringify(host.messages); + expect(broadcast).not.toContain(pending.interactionId); + expect(broadcast).not.toContain( + first.structuredActions?.resumeToken, + ); + } finally { + await host.close(); + } + }); + + test.each(["question", "choice"])( + "resumes the actual %s callback after the originator disconnects", + async (mode) => { + const host = await fixture(); + try { + const first = await host.join(); + const pending = prompt( + await execute(first.dispatcher, "read", mode), + ); + if (first.structuredActions === undefined) + throw new Error("Expected binding"); + first.disconnect(); + const second = await host.join(first.structuredActions); + const completed = await respond( + second.dispatcher, + pending, + mode === "question" + ? { type: "question", selected: 0 } + : { type: "yesNo", value: true }, + ); + expect(completed.status).toBe("completed"); + expect(host.counts()).toEqual({ entered: 1, effects: 1 }); + const repeated = await respond( + second.dispatcher, + pending, + mode === "question" + ? { type: "question", selected: 0 } + : { type: "yesNo", value: true }, + ); + expect(repeated).toEqual(completed); + expect(host.counts().effects).toBe(1); + } finally { + await host.close(); + } + }, + ); + + test("no-client grace cancels a default-affirmative prompt without executing it", async () => { + const host = await fixture(); + try { + host.shared.__testSetNoClientsGraceMs(5); + const first = await host.join(); + const pending = prompt( + await execute(first.dispatcher, "read", "question"), + ); + if (first.structuredActions === undefined) + throw new Error("Expected binding"); + first.disconnect(); + await new Promise((resolve) => setTimeout(resolve, 30)); + const second = await host.join(first.structuredActions); + const cancelled = await respond(second.dispatcher, pending, { + type: "question", + selected: 0, + }); + expect(cancelled.status).toBe("execution_uncertain"); + expect(host.counts()).toEqual({ entered: 1, effects: 0 }); + expect(host.shared.getQueueSnapshot().running).toBeNull(); + } finally { + await host.close(); + } + }); + + test("legacy new-command supersession unblocks structured work without answering", async () => { + const host = await fixture(); + try { + const owner = await host.join(); + const pending = prompt( + await execute(owner.dispatcher, "read", "question"), + ); + const submission = await owner.dispatcher.submitCommand("@help"); + if (!submission.ok) + throw new Error("Expected queued legacy command"); + await submission.entry.completion; + const cancelled = await respond(owner.dispatcher, pending, { + type: "question", + selected: 0, + }); + expect(cancelled.status).toBe("execution_uncertain"); + expect(host.counts().effects).toBe(0); + expect(host.shared.getQueueSnapshot().running).toBeNull(); + } finally { + await host.close(); + } + }); + + test("non-opt-in clients can discover but cannot execute", async () => { + const host = await fixture(); + try { + const legacy = await host.join(false); + expect(legacy.structuredActions).toBeUndefined(); + const denied = await execute(legacy.dispatcher, "read"); + expect(denied.status).toBe("unavailable"); + expect(host.counts()).toEqual({ entered: 0, effects: 0 }); + } finally { + await host.close(); + } + }); + + test("a different logical owner cannot consume or cancel a confirmation", async () => { + const host = await fixture(); + try { + const owner = await host.join(); + const pending = prompt(await execute(owner.dispatcher, "write")); + const outsider = await host.join(); + expect( + ( + await respond(outsider.dispatcher, pending, { + type: "confirmation", + approved: true, + }) + ).status, + ).not.toBe("completed"); + expect( + ( + await outsider.dispatcher.cancelAction({ + protocolVersion: pending.protocolVersion, + scopeId: pending.scopeId, + operationId: pending.operationId, + }) + ).status, + ).toBe("failed"); + expect(host.counts()).toEqual({ entered: 0, effects: 0 }); + expect( + ( + await respond(owner.dispatcher, pending, { + type: "confirmation", + approved: true, + }) + ).status, + ).toBe("completed"); + expect(host.counts()).toEqual({ entered: 1, effects: 1 }); + } finally { + await host.close(); + } + }); +}); diff --git a/ts/packages/agentServer/server/test/structuredActionJoinLifecycle.spec.ts b/ts/packages/agentServer/server/test/structuredActionJoinLifecycle.spec.ts new file mode 100644 index 0000000000..34a9e90bed --- /dev/null +++ b/ts/packages/agentServer/server/test/structuredActionJoinLifecycle.spec.ts @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { afterEach, describe, expect, jest, test } from "@jest/globals"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { ClientIO, DispatcherOptions } from "agent-dispatcher"; + +const closeDispatcher = jest.fn(async () => {}); +const createDispatcher = jest.fn(async () => ({ + clientCount: 0, + join() { + throw new Error("Structured action resume state is unavailable"); + }, + prewarmReasoning() {}, + close: closeDispatcher, +})); +jest.unstable_mockModule("../src/sharedDispatcher.js", () => ({ + createSharedDispatcher: createDispatcher, +})); +const { createConversationManager } = await import( + "../src/conversationManager.js" +); + +const tempDirs: string[] = []; +afterEach(async () => { + jest.useRealTimers(); + jest.clearAllMocks(); + for (const directory of tempDirs.splice(0)) { + await rm(directory, { recursive: true, force: true }); + } +}); + +async function fixture() { + const directory = await mkdtemp(path.join(os.tmpdir(), "structured-join-")); + tempDirs.push(directory); + return createConversationManager( + "test", + {} as DispatcherOptions, + directory, + 100, + true, + ); +} + +describe("structured join manager lifecycle", () => { + test("a rejected resume restores idle cleanup on an already loaded dispatcher", async () => { + const manager = await fixture(); + try { + const conversation = await manager.createConversation("default"); + await manager.prewarmMostRecentConversation(); + jest.useFakeTimers(); + await expect( + manager.joinConversation( + conversation.conversationId, + {} as ClientIO, + () => {}, + { + conversationId: conversation.conversationId, + structuredActions: { resumeToken: "a".repeat(43) }, + }, + ), + ).rejects.toThrow("resume state is unavailable"); + expect(closeDispatcher).not.toHaveBeenCalled(); + await jest.advanceTimersByTimeAsync(100); + expect(closeDispatcher).toHaveBeenCalledTimes(1); + } finally { + await manager.close(); + } + }); + + test("lost resume state does not initialize a replacement dispatcher", async () => { + const manager = await fixture(); + try { + const conversation = await manager.createConversation("target"); + await expect( + manager.joinConversation( + conversation.conversationId, + {} as ClientIO, + () => {}, + { + conversationId: conversation.conversationId, + structuredActions: { resumeToken: "a".repeat(43) }, + }, + ), + ).rejects.toThrow("resume state is unavailable"); + expect(createDispatcher).not.toHaveBeenCalled(); + } finally { + await manager.close(); + } + }); +}); diff --git a/ts/packages/agentServer/server/test/structuredActionJoinRpc.spec.ts b/ts/packages/agentServer/server/test/structuredActionJoinRpc.spec.ts new file mode 100644 index 0000000000..f473876226 --- /dev/null +++ b/ts/packages/agentServer/server/test/structuredActionJoinRpc.spec.ts @@ -0,0 +1,256 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, jest, test } from "@jest/globals"; +import { + createChannelProviderAdapter, + type ChannelProviderAdapter, +} from "@typeagent/agent-rpc/channel"; +import { + createAgentServerConnection, + type AgentServerConnection, +} from "@typeagent/agent-server-client"; +import type { DispatcherConnectOptions } from "@typeagent/agent-server-protocol"; +import type { MacroManager } from "@typeagent/copilot-macros"; +import type { ClientIO, Dispatcher } from "@typeagent/dispatcher-types"; +import type { ConversationManager } from "../src/conversationManager.js"; +import { createAgentServerConnectionHandler } from "../src/connectionHandler.js"; +import { + StructuredActionBindings, + type StructuredActionLease, +} from "../src/structuredActionBindings.js"; + +function deferred() { + let resolve!: () => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +function fixture() { + const session = {}; + const bindings = new StructuredActionBindings(() => session); + let nextConnection = 0; + const joinedOptions: (DispatcherConnectOptions | undefined)[] = []; + const resolveConversationId = jest.fn( + async (id?: string) => id ?? "default", + ); + const leases = new Map(); + const closeCallbacks = new Map void>(); + const left = deferred(); + let pause: + | { + entered: ReturnType; + resume: ReturnType; + } + | undefined; + const leaveConversation = jest.fn( + async (_conversationId: string, connectionId: string) => { + leases.get(connectionId)?.release(); + leases.delete(connectionId); + closeCallbacks.get(connectionId)?.(); + closeCallbacks.delete(connectionId); + left.resolve(); + }, + ); + const manager = { + resolveConversationId, + async joinConversation( + conversationId: string, + _clientIO: ClientIO, + closeFn: () => void, + options?: DispatcherConnectOptions, + ) { + joinedOptions.push(options); + const connectionId = String(++nextConnection); + const lease = + options?.structuredActions === undefined + ? undefined + : bindings.acquire( + conversationId, + connectionId, + options.structuredActions.resumeToken, + ); + if (lease !== undefined) { + leases.set(connectionId, lease); + } + closeCallbacks.set(connectionId, closeFn); + const paused = pause; + pause = undefined; + paused?.entered.resolve(); + await paused?.resume.promise; + return { + dispatcher: {} as Dispatcher, + connectionId, + name: "Test conversation", + pendingInteractions: [], + ...(lease === undefined + ? {} + : { + structuredActions: { resumeToken: lease.resumeToken }, + }), + }; + }, + leaveConversation, + } as unknown as ConversationManager; + const { handler } = createAgentServerConnectionHandler({ + conversationManager: manager, + macroManager: {} as MacroManager, + shutdown() {}, + getUserIdentity: () => ({ + username: "test", + displayName: "test", + initial: "T", + }), + }); + const connections: AgentServerConnection[] = []; + const disconnectors = new Map void>(); + return { + joinedOptions, + resolveConversationId, + leaveConversation, + leases, + left: left.promise, + pauseNextJoin() { + pause = { entered: deferred(), resume: deferred() }; + return { + entered: pause.entered.promise, + resume: pause.resume.resolve, + }; + }, + disconnect(connection: AgentServerConnection) { + disconnectors.get(connection)?.(); + }, + connect() { + let client: ChannelProviderAdapter | undefined; + const server = createChannelProviderAdapter("server", (message) => + client?.notifyMessage(message), + ); + client = createChannelProviderAdapter("client", (message) => + server.notifyMessage(message), + ); + handler(server, () => {}); + const connection = createAgentServerConnection(client, () => {}); + const clientAdapter = client; + disconnectors.set(connection, () => { + server.notifyDisconnected(); + clientAdapter.notifyDisconnected(); + }); + connections.push(connection); + return connection; + }, + async close() { + for (const connection of connections) { + await connection.close(); + } + bindings.close(); + }, + }; +} + +describe("structured join RPC plumbing", () => { + test("returns a private capability to the originator and accepts explicit resume", async () => { + const server = fixture(); + try { + const first = await server + .connect() + .joinConversation({} as ClientIO, { + conversationId: "conversation", + structuredActions: {}, + }); + expect(first.structuredActions?.resumeToken).toMatch( + /^[A-Za-z0-9_-]{43}$/, + ); + if (first.structuredActions === undefined) { + throw new Error("Expected structured binding"); + } + const second = await server + .connect() + .joinConversation({} as ClientIO, { + conversationId: "conversation", + structuredActions: first.structuredActions, + }); + expect(second.structuredActions).toEqual(first.structuredActions); + expect(second.connectionId).not.toBe(first.connectionId); + expect(server.joinedOptions[1]?.structuredActions).toEqual( + first.structuredActions, + ); + expect(first.pendingInteractions).toEqual([]); + expect(first.queueSnapshot).toBeUndefined(); + } finally { + await server.close(); + } + }); + + test("does not allocate a default conversation for a malformed structured join", async () => { + const server = fixture(); + try { + await expect( + server.connect().joinConversation({} as ClientIO, { + structuredActions: {}, + }), + ).rejects.toThrow("explicit target conversationId"); + expect(server.resolveConversationId).not.toHaveBeenCalled(); + expect(server.joinedOptions).toEqual([]); + } finally { + await server.close(); + } + }); + + test("failed resume does not silently create a new capability", async () => { + const server = fixture(); + try { + await expect( + server.connect().joinConversation({} as ClientIO, { + conversationId: "conversation", + structuredActions: { resumeToken: "a".repeat(43) }, + }), + ).rejects.toThrow("resume state is unavailable"); + } finally { + await server.close(); + } + }); + + test("legacy joins do not receive a structured capability", async () => { + const server = fixture(); + try { + const joined = await server + .connect() + .joinConversation({} as ClientIO); + expect(joined.conversationId).toBe("default"); + expect(joined.structuredActions).toBeUndefined(); + } finally { + await server.close(); + } + }); + + test("disconnect during async join releases the acquired ownership", async () => { + const server = fixture(); + const paused = server.pauseNextJoin(); + try { + const connection = server.connect(); + const pending = connection.joinConversation({} as ClientIO, { + conversationId: "conversation", + structuredActions: {}, + }); + const rejected = expect(pending).rejects.toThrow("disconnected"); + await paused.entered; + expect(server.leases.size).toBe(1); + const lease = [...server.leases.values()][0]; + server.disconnect(connection); + await rejected; + paused.resume(); + await server.left; + expect(server.leaveConversation).toHaveBeenCalledWith( + "conversation", + "1", + ); + expect(server.leases.size).toBe(0); + expect(lease.access().canDiscoverSchema("list")).toBe(false); + } finally { + paused.resume(); + await server.close(); + } + }); +}); diff --git a/ts/packages/agents/browser/src/agent/browserActionHandler.mts b/ts/packages/agents/browser/src/agent/browserActionHandler.mts index 6bafadb344..c0833a01e8 100644 --- a/ts/packages/agents/browser/src/agent/browserActionHandler.mts +++ b/ts/packages/agents/browser/src/agent/browserActionHandler.mts @@ -454,6 +454,12 @@ export function instantiate(): AppAgent { content: _webFlowStore.generateDynamicSchemaText(), }; }, + cancelChoice: async ( + choiceId: string, + context: SessionContext, + ) => { + context.agentContext.choiceManager?.cancelChoice(choiceId); + }, handleChoice: async ( choiceId: string, response: boolean | number[], diff --git a/ts/packages/agents/calendar/src/calendarActionHandlerV3.ts b/ts/packages/agents/calendar/src/calendarActionHandlerV3.ts index 0f9f75532f..27ac680b8f 100644 --- a/ts/packages/agents/calendar/src/calendarActionHandlerV3.ts +++ b/ts/packages/agents/calendar/src/calendarActionHandlerV3.ts @@ -1403,6 +1403,9 @@ export function instantiate(): AppAgent { action: AppAction, context: ActionContext, ) => handler.executeAction(action, context), + cancelChoice: async (choiceId: string) => { + handler.choiceManager.cancelChoice(choiceId); + }, handleChoice: ( choiceId: string, response: boolean | number[], diff --git a/ts/packages/agents/code/src/codeActionHandler.ts b/ts/packages/agents/code/src/codeActionHandler.ts index 28df1adcf7..62caab8688 100644 --- a/ts/packages/agents/code/src/codeActionHandler.ts +++ b/ts/packages/agents/code/src/codeActionHandler.ts @@ -170,6 +170,11 @@ export function instantiate(): AppAgent { getKnownCodePort(), ); }, + cancelChoice: async (choiceId, context) => { + ( + context.agentContext as CodeActionContext + ).choiceManager.cancelChoice(choiceId); + }, handleChoice: async (choiceId, response, context) => { const ctx = (context as ActionContext) .sessionContext.agentContext; diff --git a/ts/packages/agents/desktop/src/actionHandler.ts b/ts/packages/agents/desktop/src/actionHandler.ts index c29bccdaca..bad83d994c 100644 --- a/ts/packages/agents/desktop/src/actionHandler.ts +++ b/ts/packages/agents/desktop/src/actionHandler.ts @@ -34,6 +34,12 @@ export function instantiate(): AppAgent { initializeAgentContext: initializeDesktopContext, updateAgentContext: updateDesktopContext, executeAction: executeDesktopAction, + async cancelChoice( + choiceId: string, + context: SessionContext, + ) { + context.agentContext.choiceManager.cancelChoice(choiceId); + }, async handleChoice( choiceId: string, response: boolean | number[], diff --git a/ts/packages/agents/email/src/emailActionHandler.ts b/ts/packages/agents/email/src/emailActionHandler.ts index ae1a5d3077..a1f5b678c1 100644 --- a/ts/packages/agents/email/src/emailActionHandler.ts +++ b/ts/packages/agents/email/src/emailActionHandler.ts @@ -339,6 +339,11 @@ export function instantiate(): AppAgent { executeAction: executeEmailAction, checkReadiness: checkEmailReadiness, setup: setupEmail, + cancelChoice: async (choiceId, context) => { + ( + context.agentContext as EmailActionContext + ).choiceManager.cancelChoice(choiceId); + }, handleChoice: async (choiceId, response, context) => { const ctx = (context as ActionContext) .sessionContext.agentContext; diff --git a/ts/packages/agents/github-cli/src/github-cliActionHandler.ts b/ts/packages/agents/github-cli/src/github-cliActionHandler.ts index 9b963340c6..a71eef413f 100644 --- a/ts/packages/agents/github-cli/src/github-cliActionHandler.ts +++ b/ts/packages/agents/github-cli/src/github-cliActionHandler.ts @@ -72,6 +72,11 @@ export function instantiate(): AppAgent { ), // Routes user yes/no responses (from createYesNoChoiceResult) // back to the registered ChoiceManager callback. + cancelChoice: async (choiceId, context) => { + ( + context.agentContext as GithubCliActionContext + ).choiceManager.cancelChoice(choiceId); + }, handleChoice: async (choiceId, response, context) => { const ctx = (context as ActionContext) .sessionContext.agentContext; diff --git a/ts/packages/agents/list/src/listActionHandler.ts b/ts/packages/agents/list/src/listActionHandler.ts index 13effef5be..6f69cbbf39 100644 --- a/ts/packages/agents/list/src/listActionHandler.ts +++ b/ts/packages/agents/list/src/listActionHandler.ts @@ -24,6 +24,11 @@ export function instantiate(): AppAgent { updateAgentContext: updateListContext, executeAction: executeListAction, validateWildcardMatch: listValidateWildcardMatch, + cancelChoice: async (choiceId, context) => { + ( + context.agentContext as ListActionContext + ).choiceManager.cancelChoice(choiceId); + }, handleChoice: (choiceId, response, context) => ( context as ActionContext diff --git a/ts/packages/agents/osNotifications/src/osNotificationsActionHandler.ts b/ts/packages/agents/osNotifications/src/osNotificationsActionHandler.ts index fb6384df52..6a85a1b26d 100644 --- a/ts/packages/agents/osNotifications/src/osNotificationsActionHandler.ts +++ b/ts/packages/agents/osNotifications/src/osNotificationsActionHandler.ts @@ -434,6 +434,11 @@ export function instantiate(): AppAgent { // new ActionResult which the dispatcher renders. The AppAgent // signature types context as ActionContext; cast to our // agent context to access choiceManager. + cancelChoice: async (choiceId, context) => { + (context.agentContext as AgentContext).choiceManager.cancelChoice( + choiceId, + ); + }, handleChoice: async (choiceId, response, context) => { const ctx = (context as ActionContext).sessionContext .agentContext; diff --git a/ts/packages/agents/screencapture/src/screencaptureActionHandler.ts b/ts/packages/agents/screencapture/src/screencaptureActionHandler.ts index 7cdac68b84..6340004c25 100644 --- a/ts/packages/agents/screencapture/src/screencaptureActionHandler.ts +++ b/ts/packages/agents/screencapture/src/screencaptureActionHandler.ts @@ -85,6 +85,11 @@ export function instantiate(): AppAgent { // the registered ChoiceManager callback — same shape as // osNotifications. The AppAgent signature types context as // ActionContext; cast to access our agent context. + cancelChoice: async (choiceId, context) => { + ( + context.agentContext as ScreencaptureActionContext + ).choiceManager.cancelChoice(choiceId); + }, handleChoice: async (choiceId, response, context) => { const ctx = (context as ActionContext) .sessionContext.agentContext; diff --git a/ts/packages/agents/windowsClock/src/windowsClockActionHandler.ts b/ts/packages/agents/windowsClock/src/windowsClockActionHandler.ts index bfa7c2593b..512d873068 100644 --- a/ts/packages/agents/windowsClock/src/windowsClockActionHandler.ts +++ b/ts/packages/agents/windowsClock/src/windowsClockActionHandler.ts @@ -276,6 +276,12 @@ export function instantiate(): AppAgent { ); } }, + async cancelChoice( + choiceId: string, + context: SessionContext, + ) { + context.agentContext.choiceManager.cancelChoice(choiceId); + }, async handleChoice( choiceId: string, response: boolean | number[], diff --git a/ts/packages/dispatcher/dispatcher/src/command/command.ts b/ts/packages/dispatcher/dispatcher/src/command/command.ts index 4841ec8041..5b5424f940 100644 --- a/ts/packages/dispatcher/dispatcher/src/command/command.ts +++ b/ts/packages/dispatcher/dispatcher/src/command/command.ts @@ -41,6 +41,7 @@ import { } from "@typeagent/dispatcher-types"; import { DispatcherName } from "../context/dispatcher/dispatcherUtils.js"; import { getAppAgentName } from "../internal.js"; +import { getStructuredExecution } from "../structuredAction/executionHooks.js"; import { logCommandException, logRequestCompleted, @@ -438,6 +439,9 @@ export async function processCommandNoLock( request: originalInput, error: e, }); + if (getStructuredExecution(context) !== undefined) { + throw e; + } } } @@ -504,6 +508,7 @@ export async function processCommand( attachments?: string[], options?: ProcessCommandOptions, parentContext?: Context, + work?: { kind: "structured-action"; run(): Promise }, ): Promise { const isCommand = originalInput.trimStart().startsWith("@"); // Create the AbortController *before* acquiring the lock so that a @@ -573,11 +578,16 @@ export async function processCommand( : undefined; context.clientIO.setUserRequest(requestId, originalInput); try { - await processCommandNoLock( - originalInput, - context, - attachments, - ); + if (work !== undefined) { + abortController.signal.throwIfAborted(); + await work.run(); + } else { + await processCommandNoLock( + originalInput, + context, + attachments, + ); + } } catch (e: any) { if (e.name === "AbortError") { const activeSpan = trace.getActiveSpan(); diff --git a/ts/packages/dispatcher/dispatcher/src/context/commandHandlerContext.ts b/ts/packages/dispatcher/dispatcher/src/context/commandHandlerContext.ts index 1aeadeb876..8d62946d18 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/commandHandlerContext.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/commandHandlerContext.ts @@ -139,6 +139,7 @@ import { RequestQueue } from "../queue/requestQueue.js"; import type { QueueExecutionContext } from "../queue/requestQueue.js"; import { createSnapshotCoalescer } from "../queue/snapshotCoalescer.js"; import { processCommand as runProcessCommand } from "../command/command.js"; +import { closeStructuredActions } from "../structuredAction/executionHooks.js"; const debug = registerDebug("typeagent:dispatcher:init"); const debugError = registerDebug("typeagent:dispatcher:init:error"); @@ -1416,6 +1417,7 @@ export async function initializeCommandHandlerContext( qctx.attachments, qctx.options, qctx.traceContext, + qctx.work, ); try { context.displayLog.logCommandResult( @@ -1951,6 +1953,7 @@ function processSetAppAgentStateResult( export async function closeCommandHandlerContext( context: CommandHandlerContext, ) { + closeStructuredActions(context); // Stop accepting exclusive mutations in this closing session. context.appAgentProviderSetController.dispose(); // Tear down any reasoning subagents (spawned command-executor processes and diff --git a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/dispatcherAgent.ts b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/dispatcherAgent.ts index 8bcb746785..0fe70cdbd0 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/dispatcherAgent.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/dispatcherAgent.ts @@ -604,6 +604,11 @@ export const dispatcherManifest: AppAgentManifest = { export const dispatcherAgent: AppAgent = { executeAction: executeDispatcherAction, + cancelChoice: async (choiceId, context) => { + ( + context.agentContext as CommandHandlerContext + ).choiceManager.cancelChoice(choiceId); + }, handleChoice: async (choiceId, response, context) => { const systemContext = (context as ActionContext) .sessionContext.agentContext; diff --git a/ts/packages/dispatcher/dispatcher/src/context/pendingInteractionManager.ts b/ts/packages/dispatcher/dispatcher/src/context/pendingInteractionManager.ts index 858d4689a7..562acec863 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/pendingInteractionManager.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/pendingInteractionManager.ts @@ -8,6 +8,7 @@ import type { } from "@typeagent/dispatcher-types"; type PendingEntry = { + rejectOnCancel?: boolean; type: PendingInteractionType; requestId?: RequestId; resolve: (value: any) => void; @@ -33,6 +34,7 @@ export class PendingInteractionManager { create( request: PendingInteractionRequest, timeoutMs?: number, + options?: { rejectOnCancel?: boolean }, ): Promise { return new Promise((resolve, reject) => { const entry: PendingEntry = { @@ -40,6 +42,7 @@ export class PendingInteractionManager { resolve, reject, request, + ...(options?.rejectOnCancel ? { rejectOnCancel: true } : {}), }; if (request.requestId !== undefined) { @@ -95,6 +98,10 @@ export class PendingInteractionManager { clearTimeout(entry.timeoutTimer); } + if (entry.rejectOnCancel) { + entry.reject(error); + return true; + } // For question, resolve with defaultId if one was explicitly provided; // otherwise reject — no declared safe fallback exists. if (entry.type === "question") { diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/systemAgent.ts b/ts/packages/dispatcher/dispatcher/src/context/system/systemAgent.ts index 68f7ea083d..02248abf64 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/systemAgent.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/systemAgent.ts @@ -369,6 +369,11 @@ export const systemAgent: AppAgent = { getTemplateCompletion: getSystemTemplateCompletion, executeAction: executeSystemAction as unknown as AppAgent["executeAction"], handleChoice: handleSystemChoice, + cancelChoice: async (choiceId, context) => { + ( + context.agentContext as CommandHandlerContext + ).choiceManager.cancelChoice(choiceId); + }, getCommands: commandInterface.getCommands, getCommandCompletion: commandInterface.getCommandCompletion, executeCommand: commandInterface.executeCommand, diff --git a/ts/packages/dispatcher/dispatcher/src/dispatcher.ts b/ts/packages/dispatcher/dispatcher/src/dispatcher.ts index 31996e2cd4..c711648e1e 100644 --- a/ts/packages/dispatcher/dispatcher/src/dispatcher.ts +++ b/ts/packages/dispatcher/dispatcher/src/dispatcher.ts @@ -26,7 +26,7 @@ import { import { getDispatcherStatus, processCommand } from "./command/command.js"; import { getCommandCompletion } from "./command/completion.js"; import { getActionContext } from "./execute/actionContext.js"; -import { emitActionResult } from "./execute/actionHandlers.js"; +import { emitActionResult, executeActions } from "./execute/actionHandlers.js"; import { closeCommandHandlerContext, CommandHandlerContext, @@ -40,6 +40,7 @@ import { StructuredActionDiscovery, type StructuredActionAccess, } from "./structuredAction/discovery.js"; +import { StructuredActionExecution } from "./structuredAction/execution.js"; async function getDynamicDisplay( context: CommandHandlerContext, @@ -210,6 +211,12 @@ export function createDispatcherFromContext( context, structuredActionAccess, ); + const structuredExecution = new StructuredActionExecution( + context, + structuredActions, + { executeActions, getActionContext }, + connectionId, + ); const submitInput = ( command: string, clientRequestId: unknown, @@ -404,6 +411,10 @@ export function createDispatcherFromContext( async getActionContract(identity) { return structuredActions.getActionContract(identity); }, + executeAction: (request) => structuredExecution.executeAction(request), + continueAction: (request) => + structuredExecution.continueAction(request), + cancelAction: (request) => structuredExecution.cancelAction(request), async cancelCommand(requestId: string): Promise { const kind = context.requestQueue.classifyCancel(requestId, "user"); if (kind === "queued") { @@ -584,6 +595,9 @@ export function createDispatcherFromContext( | { selected: number; remember: boolean } | QuestionFormResponse, ) { + if (!context.pendingChoiceRoutes.has(choiceId)) { + throw new Error("Choice not found or expired"); + } return context.commandLock(async () => { const pending = context.pendingChoiceRoutes.get(choiceId); if (!pending) { diff --git a/ts/packages/dispatcher/dispatcher/src/execute/actionContext.ts b/ts/packages/dispatcher/dispatcher/src/execute/actionContext.ts index 1182fbabb3..b52166b48f 100644 --- a/ts/packages/dispatcher/dispatcher/src/execute/actionContext.ts +++ b/ts/packages/dispatcher/dispatcher/src/execute/actionContext.ts @@ -11,6 +11,7 @@ import { import { CommandHandlerContext } from "../context/commandHandlerContext.js"; import { makeClientIOMessage } from "../context/interactiveIO.js"; import { RequestId } from "@typeagent/dispatcher-types"; +import { getStructuredExecution } from "../structuredAction/executionHooks.js"; export type ActionContextWithClose = { actionContext: ActionContext; @@ -36,6 +37,9 @@ export function getActionContext( ); const actionIO: ActionIO = { setDisplay(content: DisplayContent): void { + getStructuredExecution(context, requestId.requestId)?.display( + content, + ); context.displayCount++; context.clientIO.setDisplay( makeClientIOMessage( @@ -55,6 +59,9 @@ export function getActionContext( // it, so an action that only shows a spinner still gets the // synthesized "completed" acknowledgment. if (mode !== "temporary") { + getStructuredExecution(context, requestId.requestId)?.display( + content, + ); context.displayCount++; } context.clientIO.appendDisplay( @@ -77,10 +84,16 @@ export function getActionContext( }; const actionContext: ActionContext = { streamingContext: undefined, - isFromReasoningLoop: context.isInsideReasoningLoop, + waitForCompletionOnAbort: + getStructuredExecution(context, requestId.requestId) !== undefined, + isFromReasoningLoop: + getStructuredExecution(context, requestId.requestId) === + undefined && context.isInsideReasoningLoop, workingDirectory: systemContext.currentOptions?.workingDirectory, activityContext: // Only make activityContext available if the action is from the same agent. + getStructuredExecution(context, requestId.requestId) === + undefined && context.activityContext?.appAgentName === appAgentName ? structuredClone(context.activityContext) : undefined, diff --git a/ts/packages/dispatcher/dispatcher/src/execute/actionHandlers.ts b/ts/packages/dispatcher/dispatcher/src/execute/actionHandlers.ts index b87989eec6..fd6fb7f2a4 100644 --- a/ts/packages/dispatcher/dispatcher/src/execute/actionHandlers.ts +++ b/ts/packages/dispatcher/dispatcher/src/execute/actionHandlers.ts @@ -63,6 +63,8 @@ import { } from "../otel/actionSpan.js"; import { otel } from "@typeagent/telemetry"; import { getActionContext } from "./actionContext.js"; +import { getStructuredExecution } from "../structuredAction/executionHooks.js"; +import { RpcDisconnectedError } from "@typeagent/agent-rpc/rpc"; import { AgentNotReadyError, getErrorDisplayContent, @@ -216,6 +218,11 @@ function rethrowIfActionCancelled( error: unknown, systemContext: CommandHandlerContext, ): void { + if ( + error instanceof RpcDisconnectedError && + getStructuredExecution(systemContext) !== undefined + ) + throw error; if ( (error as { name?: unknown })?.name === "AbortError" || systemContext.currentAbortSignal?.aborted @@ -243,9 +250,13 @@ async function executeFlowForActionSpan( string, unknown >; + const structured = getStructuredExecution(systemContext); + await structured?.guard(executableAction.action, "enter"); + // Entering the interpreter itself has no effects; each step marks its own entry. + structured?.effect(executableAction.action, false); try { const result = await processFlow( - flowDef, + structured === undefined ? flowDef : structuredClone(flowDef), flowParams, context, actionIndex, @@ -291,11 +302,13 @@ async function executeHandlerForActionSpan( let setupResult: ActionResult | undefined; try { - setupResult = await checkAgentReady( - appAgentName, - systemContext, - actionContext, - ); + if (getStructuredExecution(systemContext) === undefined) { + setupResult = await checkAgentReady( + appAgentName, + systemContext, + actionContext, + ); + } } catch (error) { rethrowIfActionCancelled(error, systemContext); recordActionSetupFailure(span, "agent_not_ready"); @@ -314,6 +327,9 @@ async function executeHandlerForActionSpan( } const displayCountBefore = systemContext.displayCount; + const structured = getStructuredExecution(systemContext); + await structured?.guard(executableAction.action, "enter"); + structured?.effect(executableAction.action); try { const handlerResult = await appAgent.executeAction( executableAction.action, @@ -369,6 +385,7 @@ export async function executeAction( sessionCtx._systemContext ?? sessionCtx.agentContext; const appAgentName = getAppAgentName(schemaName); + await getStructuredExecution(systemContext)?.guard(action, "prepare"); const requestId = getRequestId(systemContext); const appAgent = systemContext.agents.getAppAgent(appAgentName); @@ -406,12 +423,14 @@ export async function executeAction( // Reuse the same streaming action context if one is available. const { actionContext, closeActionContext } = - getStreamingActionContext( - appAgentName, - actionIndex, - systemContext, - action, - ) ?? + (getStructuredExecution(systemContext) === undefined + ? getStreamingActionContext( + appAgentName, + actionIndex, + systemContext, + action, + ) + : undefined) ?? getActionContext( appAgentName, systemContext, @@ -474,6 +493,21 @@ export async function executeAction( appAgent, actionContext, }); + const structured = getStructuredExecution(systemContext); + if (structured !== undefined) + outcome.result = structuredClone(outcome.result); + structured?.result(action, outcome.result); + if ( + structured !== undefined && + outcome.result.error === undefined && + outcome.result.pendingChoice !== undefined + ) { + outcome.result = await structured.choice( + action, + outcome.result, + actionContext, + ); + } // If the agent ran to completion but a cancel arrived while it was executing, // discard the result and treat this as a cancellation. systemContext.currentAbortSignal?.throwIfAborted(); @@ -502,7 +536,6 @@ export async function executeAction( success: outcome.result.error === undefined, elapsedMs: Date.now() - actionStartedAt, }); - closeActionContext(); return outcome.result; } catch (error) { logActionCompleted(systemContext.logger, { @@ -516,6 +549,8 @@ export async function executeAction( error, }); throw error; + } finally { + closeActionContext(); } }, // The same signal the completion event above uses, so the span and @@ -655,7 +690,10 @@ export function emitActionResult( result.dynamicDisplayNextRefreshMs!, ); } - if (result.pendingChoice !== undefined) { + if ( + result.pendingChoice !== undefined && + getStructuredExecution(systemContext, requestId.requestId) === undefined + ) { const pc = result.pendingChoice; systemContext.pendingChoiceRoutes.set(pc.choiceId, { agentName: appAgentName, @@ -778,11 +816,16 @@ export async function executeActions( actions: ExecutableAction[], entities: PromptEntity[] | undefined, context: ActionContext, + observeResult?: (action: ExecutableAction, result: ActionResult) => void, + startActionIndex = 0, ): Promise { const sessionCtx = context.sessionContext as any; const systemContext: CommandHandlerContext = sessionCtx._systemContext ?? sessionCtx.agentContext; const commandResult = getCommandResult(systemContext); + const structured = getStructuredExecution(systemContext); + for (const { action } of actions) + await structured?.guard(action, "prepare"); if (commandResult !== undefined) { commandResult.actions = actions.map(({ action }) => action); } @@ -798,7 +841,7 @@ export async function executeActions( return; } - let actionIndex = 0; + let actionIndex = startActionIndex; while (actionQueue.length !== 0) { systemContext.currentAbortSignal?.throwIfAborted(); const pending = actionQueue.shift()!; @@ -807,6 +850,10 @@ export async function executeActions( const action = executableAction.action; if (isPendingRequestAction(action)) { + if (structured !== undefined) + throw new Error( + "Structured execution cannot translate pending requests", + ); const translationResult = await translatePendingRequestAction( action, context, @@ -845,6 +892,7 @@ export async function executeActions( context, actionIndex, ); + observeResult?.(executableAction, result); // add the action result to memory whether it has error or not. if ( @@ -950,7 +998,9 @@ export async function executeActions( if (result.additionalActions !== undefined) { try { const actions = getAdditionalExecutableActions( - result.additionalActions, + structured === undefined + ? result.additionalActions + : structuredClone(result.additionalActions), action.schemaName, systemContext, ); @@ -959,6 +1009,7 @@ export async function executeActions( ...(await toPendingActions(context, actions, undefined)), ); } catch (e) { + if (structured !== undefined) throw e; throw new Error( `${action.schemaName}.${action.actionName} returned an invalid action: ${e}`, ); diff --git a/ts/packages/dispatcher/dispatcher/src/execute/flowInterpreter.ts b/ts/packages/dispatcher/dispatcher/src/execute/flowInterpreter.ts index 563e22ccad..e82ca4b131 100644 --- a/ts/packages/dispatcher/dispatcher/src/execute/flowInterpreter.ts +++ b/ts/packages/dispatcher/dispatcher/src/execute/flowInterpreter.ts @@ -8,9 +8,10 @@ import { } from "@typeagent/agent-sdk/helpers/action"; import { displayStatus } from "@typeagent/agent-sdk/helpers/display"; import { type CommandHandlerContext } from "../context/commandHandlerContext.js"; -import { executeAction } from "./actionHandlers.js"; +import { executeAction, executeActions } from "./actionHandlers.js"; import { toExecutableActions } from "@typeagent/agent-cache"; import type { FullAction, ParamObjectType } from "@typeagent/agent-cache"; +import { getStructuredExecution } from "../structuredAction/executionHooks.js"; // ── Flow definition types ──────────────────────────────────────────────────── @@ -289,6 +290,11 @@ export async function processFlow( let result: ActionResult; if (step.type === "script") { + if (getStructuredExecution(systemContext) !== undefined) { + throw new Error( + "Structured flow script steps require a discoverable action contract", + ); + } // Script step — execute via PowerShell runner displayStatus( `[flow:${flowDef.name}] ${step.id}: powershell script`, @@ -310,11 +316,52 @@ export async function processFlow( }; const [executableAction] = toExecutableActions([action]); - result = await executeAction(executableAction, context, stepIndex); + if (getStructuredExecution(systemContext) !== undefined) { + let observed: ActionResult | undefined; + const error = await executeActions( + [executableAction], + undefined, + context, + (observedAction, actionResult) => { + if (observedAction === executableAction) { + observed = actionResult; + } + }, + stepIndex, + ); + result = + error !== undefined + ? createActionResultFromError(error.error) + : (observed ?? + createActionResultFromError( + "Flow step was not executed", + )); + if ( + result.error === undefined && + result.additionalActions !== undefined + ) { + // Descendants already ran inside executeActions. Returning + // them from the flow would schedule them a second time. + const settled = { ...result }; + delete settled.additionalActions; + result = settled; + } + } else { + result = await executeAction( + executableAction, + context, + stepIndex, + ); + } } const text = extractText(result); - const data = tryParseJson(text) ?? text; + const data = + getStructuredExecution(systemContext) !== undefined && + result.error === undefined && + result.resultValue !== undefined + ? result.resultValue + : (tryParseJson(text) ?? text); stepResults.set(step.id, { actionResult: result, text, data }); if (result.error !== undefined) { diff --git a/ts/packages/dispatcher/dispatcher/src/execute/pendingActions.ts b/ts/packages/dispatcher/dispatcher/src/execute/pendingActions.ts index 4c6b41ccae..bdb9c94e83 100644 --- a/ts/packages/dispatcher/dispatcher/src/execute/pendingActions.ts +++ b/ts/packages/dispatcher/dispatcher/src/execute/pendingActions.ts @@ -42,6 +42,7 @@ import { getObjectProperty } from "@typeagent/common-utils"; import { ActionSchemaFile } from "../translation/actionConfigProvider.js"; import { tryGetActionParametersType } from "../translation/actionSchemaUtils.js"; import { isPendingRequestAction } from "../translation/pendingRequest.js"; +import { getStructuredExecution } from "../structuredAction/executionHooks.js"; const debugEntities = registerDebug("typeagent:dispatcher:actions:entities"); @@ -716,6 +717,7 @@ async function resolveEntityWithAgent( `Resolving ${type} entity with agent ${appAgentName}: ${value}`, ); displayStatus(`Resolving ${type}: ${value}`, context); + getStructuredExecution(context.sessionContext.agentContext)?.effect(); const result = await agent.resolveEntity( type, value, @@ -792,7 +794,10 @@ function createParameterEntityResolver( ): ParameterEntityResolver { const agentContext = context.sessionContext.agentContext; const agents = agentContext.agents; - const conversationMemory = agentContext.conversationMemory; + const conversationMemory = + getStructuredExecution(agentContext) === undefined + ? agentContext.conversationMemory + : undefined; const resultEntityMap = new Set(); const clarifyEntities: ClarifyResolvedEntity[] = []; const promptEntityMap = toPromptEntityMap(entities); @@ -883,6 +888,10 @@ function createParameterEntityResolver( } } + await getStructuredExecution(agentContext)?.guard( + action, + "enter", + ); resolveEntityResult = await resolveEntityWithAgent( agents, appAgentName, @@ -890,6 +899,10 @@ function createParameterEntityResolver( value, context, ); + await getStructuredExecution(agentContext)?.guard( + action, + "enter", + ); if (resolveEntityResult !== undefined) { return processResolvedEntityResult( @@ -1079,14 +1092,18 @@ export async function toPendingActions( let resultEntityResolver: EntityResolver | undefined; const systemContext = context.sessionContext.agentContext; const agents = systemContext.agents; + const structured = getStructuredExecution(systemContext); const entityResolver = createParameterEntityResolver( context, - entities, - systemContext.session.getConfig().translation.entity, + structured === undefined ? entities : undefined, + structured === undefined + ? systemContext.session.getConfig().translation.entity + : { resolve: true, clarify: false, filter: false }, ); const pendingActions: PendingAction[] = []; for (const executableAction of actions) { + await structured?.guard(executableAction.action, "prepare"); if (isPendingRequestAction(executableAction.action)) { // Pending request action is an internal action. It doesn't have any entities. continue; @@ -1096,6 +1113,7 @@ export async function toPendingActions( executableAction.action, entityResolver, ); + await structured?.guard(executableAction.action, "enter"); if (entityResolver.clarifyResolvedEntities.length > 0) { const clarifyEntityAction: TypeAgentAction = { diff --git a/ts/packages/dispatcher/dispatcher/src/execute/sessionContext.ts b/ts/packages/dispatcher/dispatcher/src/execute/sessionContext.ts index 7d7b55af05..4202fce9e4 100644 --- a/ts/packages/dispatcher/dispatcher/src/execute/sessionContext.ts +++ b/ts/packages/dispatcher/dispatcher/src/execute/sessionContext.ts @@ -19,6 +19,7 @@ import { IndexData } from "@typeagent/image-memory"; import { IndexManager } from "../context/indexManager.js"; import { validateGrammarPatternsImpl } from "../validation/grammarValidationService.mjs"; import registerDebug from "debug"; +import { getStructuredExecution } from "../structuredAction/executionHooks.js"; const debug = registerDebug("typeagent:dispatcher:sessionContext"); const debugClientCountWarn = registerDebug( @@ -228,7 +229,9 @@ export function createSessionContext( defaultId?: number, ): Promise { return context.clientIO.question( - undefined, + getStructuredExecution(context) === undefined + ? undefined + : context.currentRequestId, message, choices, defaultId, diff --git a/ts/packages/dispatcher/dispatcher/src/queue/requestQueue.ts b/ts/packages/dispatcher/dispatcher/src/queue/requestQueue.ts index c60255f6e5..b373d1d0e9 100644 --- a/ts/packages/dispatcher/dispatcher/src/queue/requestQueue.ts +++ b/ts/packages/dispatcher/dispatcher/src/queue/requestQueue.ts @@ -59,6 +59,8 @@ export interface QueueLogger { * broadcast redaction happens elsewhere. */ export interface QueueExecutionContext { + /** Typed work runs inside the common request lifecycle, never the parser. */ + work?: { kind: "structured-action"; run(): Promise }; requestId: string; originatorConnectionId: string; text: string; @@ -74,6 +76,7 @@ export type InnerProcessCommand = ( /** Inputs accepted by `RequestQueue.submit`. */ export interface QueueSubmitInput { + work?: QueueExecutionContext["work"]; text: string; originatorConnectionId: string; attachments?: string[]; @@ -90,6 +93,7 @@ export interface QueueSubmitInput { * loop resolves on terminal state. */ interface InternalEntry extends QueuedRequest { + work?: QueueExecutionContext["work"]; traceContext?: Context; completion: Promise; resolveCompletion: (result: CommandResult | undefined) => void; @@ -508,6 +512,7 @@ export class RequestQueue { if (input.traceContext !== undefined) { entry.traceContext = input.traceContext; } + if (input.work !== undefined) entry.work = input.work; return entry; } @@ -528,6 +533,7 @@ export class RequestQueue { cancelReason: _cr, blockedOn: _bo, traceContext: _tc, + work: _work, ...pub } = entry; const out: QueuedRequest = { ...pub }; @@ -590,6 +596,23 @@ export class RequestQueue { ); } + private executionContext(entry: InternalEntry): QueueExecutionContext { + const context: QueueExecutionContext = { + requestId: entry.requestId, + originatorConnectionId: entry.originatorConnectionId, + text: entry.text, + }; + if (entry.clientRequestId !== undefined) + context.clientRequestId = entry.clientRequestId; + if (entry.attachments !== undefined) + context.attachments = entry.attachments; + if (entry.options !== undefined) context.options = entry.options; + if (entry.traceContext !== undefined) + context.traceContext = entry.traceContext; + if (entry.work !== undefined) context.work = entry.work; + return context; + } + private async processEntry(entry: InternalEntry): Promise { let result: CommandResult | undefined; let error: unknown = undefined; @@ -600,19 +623,9 @@ export class RequestQueue { entry.error = `cancelled:${entry.cancelReason}`; result = { cancelled: true }; } else { - const ctx: QueueExecutionContext = { - requestId: entry.requestId, - originatorConnectionId: entry.originatorConnectionId, - text: entry.text, - }; - if (entry.clientRequestId !== undefined) - ctx.clientRequestId = entry.clientRequestId; - if (entry.attachments !== undefined) - ctx.attachments = entry.attachments; - if (entry.options !== undefined) ctx.options = entry.options; - if (entry.traceContext !== undefined) - ctx.traceContext = entry.traceContext; - result = await this.innerProcessCommand(ctx); + result = await this.innerProcessCommand( + this.executionContext(entry), + ); if (result?.cancelled) { state = "cancelled"; if (entry.error === undefined) { diff --git a/ts/packages/dispatcher/dispatcher/src/structuredAction/discovery.ts b/ts/packages/dispatcher/dispatcher/src/structuredAction/discovery.ts index 5e640b51d8..ce138202ff 100644 --- a/ts/packages/dispatcher/dispatcher/src/structuredAction/discovery.ts +++ b/ts/packages/dispatcher/dispatcher/src/structuredAction/discovery.ts @@ -23,6 +23,10 @@ import { createActionContract } from "./contract.js"; export type StructuredActionAccess = () => { scope: object; canDiscoverSchema(schemaName: string): boolean; + // Discovery-only facades deny execution without hiding contracts. + // Omitted for existing/direct callers, which retain execution access. + canExecute?: boolean; + isActive?(): boolean; }; type DiscoveryContext = { @@ -117,8 +121,14 @@ export class StructuredActionDiscovery { private readonly access?: StructuredActionAccess, ) {} - private bindScope() { + public bindScope() { const policy = this.access?.(); + if ( + policy?.isActive?.() === false || + (this.access !== undefined && policy === undefined) + ) { + throw new Error("Structured action access has been revoked"); + } const permissionScope = policy?.scope ?? this.anonymousScope; let scopes = sessionScopes.get(this.context.session); if (scopes === undefined) { @@ -208,6 +218,13 @@ export class StructuredActionDiscovery { public async getActionContract( identity: ActionIdentity, ): Promise { + return this.getActionContractSnapshot(identity); + } + + /** Synchronous final gate: no event-loop turn between checking and handler entry. */ + public getActionContractSnapshot( + identity: ActionIdentity, + ): ActionContractResult { if ( identity === null || typeof identity !== "object" || diff --git a/ts/packages/dispatcher/dispatcher/src/structuredAction/execution.ts b/ts/packages/dispatcher/dispatcher/src/structuredAction/execution.ts new file mode 100644 index 0000000000..a73c566418 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/structuredAction/execution.ts @@ -0,0 +1,962 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { randomUUID } from "node:crypto"; +import { context as otelContext } from "@opentelemetry/api"; +import type { FullAction } from "@typeagent/agent-cache"; +import type { + ActionContext, + ActionResult, + DisplayContent, +} from "@typeagent/agent-sdk"; +import { getStructuredFallback } from "@typeagent/agent-sdk/helpers/display"; +import { convert as htmlToText } from "html-to-text"; +import { createActionResultNoDisplay } from "@typeagent/agent-sdk/helpers/action"; +import { validateAction } from "@typeagent/action-schema"; +import { RpcDisconnectedError } from "@typeagent/agent-rpc/rpc"; +import { + QueueFullError, + ServerStoppingError, + type ExecuteActionRequest, + type ContinueActionRequest, + type CancelActionRequest, + type StructuredActionExecutionResult, + type StructuredActionError, + type StructuredActionPrompt, + type StructuredActionResponse, + type ActionContract, +} from "@typeagent/dispatcher-types"; +import type { CommandHandlerContext } from "../context/commandHandlerContext.js"; +import type { executeActions } from "../execute/actionHandlers.js"; +import type { getActionContext } from "../execute/actionContext.js"; +import { getAppAgentName } from "../translation/agentTranslators.js"; +import type { StructuredActionDiscovery } from "./discovery.js"; +import { + installStructuredInteractionRouting, + registerStructuredActionCleanup, + runStructuredExecution, + type StructuredExecutionHooks, +} from "./executionHooks.js"; +import { + immutable, + keys, + nonempty, + object, + validateJson, + validateResponse, +} from "./validation.js"; + +const OPERATION_TTL = 10 * 60_000; +const MAX_OPERATIONS = 100; + +// The facade supplies its existing engine entry points. Keeping the state +// machine independent of engine imports avoids a dispatcher/barrel cycle. +type ExecutionRuntime = { + executeActions: typeof executeActions; + 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 { + current = discovery.bindScope(); + } catch { + throw new ExecutionFailure( + "invalid_scope", + "Structured action access is no longer active", + ); + } + if (current.policy?.canExecute === false) { + throw new ExecutionFailure( + "unavailable", + "Structured action execution is not enabled for this connection", + "unavailable", + ); + } + return current; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +type PendingPrompt = { + id: string; + prompt: StructuredActionPrompt; + answer: ReturnType>; +}; + +type Registry = { + closed: boolean; + disposed: ReturnType>; + live: Map; + terminal: Map< + string, + { operation: Operation; timer: ReturnType } + >; +}; +const registries = new WeakMap(); + +function registryFor(context: CommandHandlerContext): Registry { + let registry = registries.get(context); + if (registry === undefined) { + registry = { + closed: false, + disposed: deferred(), + live: new Map(), + terminal: new Map(), + }; + registries.set(context, registry); + const current = registry; + registerStructuredActionCleanup(context, () => { + current.closed = true; + for (const operation of current.live.values()) + operation.cancel("Dispatcher closed"); + for (const record of current.terminal.values()) + clearTimeout(record.timer); + current.terminal.clear(); + current.disposed.resolve(); + }); + } + return registry; +} + +function requestEnvelope( + value: unknown, + allowed: string[], +): asserts value is Record { + validateJson(value); + object(value); + keys(value, ["protocolVersion", "scopeId", ...allowed]); + if (value.protocolVersion !== 1) + throw new Error("Unsupported structured action protocol version"); + nonempty(value.scopeId, "scopeId"); +} + +function failure( + scopeId: string, + operationId: string, + error: unknown, +): StructuredActionExecutionResult { + const cause = + error instanceof ExecutionFailure + ? error + : new ExecutionFailure( + "invalid_request", + error instanceof Error ? error.message : String(error), + ); + return { + protocolVersion: 1, + scopeId, + operationId, + status: cause.status, + error: { code: cause.code, message: cause.message }, + output: [], + results: [], + }; +} + +class Operation implements StructuredExecutionHooks { + readonly id = randomUUID(); + readonly expiresAt = Date.now() + OPERATION_TTL; + readonly session: object; + readonly scope: object; + readonly request: ExecuteActionRequest; + readonly results: StructuredActionExecutionResult["results"] = []; + readonly output: string[] = []; + pending: PendingPrompt | undefined; + terminal: StructuredActionExecutionResult | undefined; + discovery: StructuredActionDiscovery; + private delivery = deferred(); + private readonly contracts = new Map(); + private readonly approved = new WeakSet(); + private possibleEffects = false; + private promptTail: Promise = Promise.resolve(); + private queuedPrompts = 0; + private timer: ReturnType; + + constructor( + readonly context: CommandHandlerContext, + discovery: StructuredActionDiscovery, + request: ExecuteActionRequest, + private readonly registry: Registry, + private readonly runtime: ExecutionRuntime, + ) { + this.discovery = discovery; + this.request = immutable(request); + this.session = context.session; + this.scope = binding(discovery).policy?.scope ?? discovery; + this.timer = setTimeout( + () => this.cancel("Operation expired", "interaction_expired"), + OPERATION_TTL, + ); + this.timer.unref(); + } + + authorize(discovery = this.discovery): void { + const current = binding(discovery); + if ( + this.context.session !== this.session || + current.envelope.scopeId !== this.request.scopeId || + (current.policy?.scope ?? discovery) !== this.scope + ) { + throw new ExecutionFailure( + "invalid_scope", + "Operation belongs to a different scope or Session", + ); + } + } + + private checkLive(): void { + if (this.terminal !== undefined) + throw new DOMException("Operation ended", "AbortError"); + this.context.currentAbortSignal?.throwIfAborted(); + this.authorize(); + if (Date.now() >= this.expiresAt) { + this.cancel("Operation expired", "interaction_expired"); + throw new DOMException("Operation expired", "AbortError"); + } + } + + private contract(action: FullAction): ActionContract { + this.checkLive(); + // Meta actions invoke translation, reasoning, or implicit context binding. + if (getAppAgentName(action.schemaName) === "dispatcher") { + throw new ExecutionFailure( + "unavailable", + "Dispatcher meta actions are not structured executable actions", + "unavailable", + ); + } + const result = this.discovery.getActionContractSnapshot(action); + this.checkLive(); + if (result.status !== "found") + throw new ExecutionFailure( + "unavailable", + "Action is unavailable", + "unavailable", + ); + const contract = result.contract; + const key = `${action.schemaName}\0${action.actionName}`; + const expected = + this.contracts.get(key)?.fingerprint ?? + (action.schemaName === this.request.schemaName && + action.actionName === this.request.actionName + ? this.request.fingerprint + : contract.fingerprint); + if ( + result.scopeId !== this.request.scopeId || + contract.fingerprint !== expected + ) { + throw new ExecutionFailure( + "contract_stale", + "Action contract changed; discover and submit a new action", + "contract_stale", + ); + } + const availability = contract.availability; + if (availability.state !== "available") { + throw new ExecutionFailure( + "unavailable", + availability.message ?? + `Action is ${availability.state}. Configure or refresh '${getAppAgentName(action.schemaName)}' before retrying.`, + "unavailable", + ); + } + if ( + this.context.agents + .getFlow(action.schemaName, action.actionName) + ?.steps.some((step) => step.type === "script") + ) { + throw new ExecutionFailure( + "unavailable", + "Flow contains a script step without a discoverable action contract", + "unavailable", + ); + } + const config = this.context.agents.tryGetActionConfig( + action.schemaName, + ); + const definition = + config === undefined + ? undefined + : this.context.agents + .getActionSchemaFileForConfig(config) + .parsedActionSchema.actionSchemas.get(action.actionName); + if (definition === undefined) + throw new ExecutionFailure( + "unavailable", + "Action schema is unavailable", + "unavailable", + ); + const { entities: _entities, ...input } = action as FullAction & { + entities?: unknown; + }; + validateAction(definition, input); + this.contracts.set(key, immutable(contract)); + return contract; + } + + async guard( + action: FullAction, + _phase: "prepare" | "enter", + ): Promise { + const contract = this.contract(action); + if (!this.approved.has(action)) { + if ( + contract.policy.effects !== "read-only" || + contract.policy.confirmation === "required" + ) { + const response = await this.prompt({ + type: "confirmation", + action: { + protocolVersion: 1, + scopeId: this.request.scopeId, + schemaName: action.schemaName, + actionName: action.actionName, + fingerprint: contract.fingerprint, + ...(action.parameters === undefined + ? {} + : { parameters: action.parameters }), + }, + contract, + }); + if (response.type !== "confirmation" || !response.approved) { + this.cancel("Action was not approved"); + throw new DOMException( + "Action was not approved", + "AbortError", + ); + } + this.contract(action); + } + this.approved.add(action); + } + this.checkLive(); + } + + effect(action?: FullAction, possibleEffects = true): void { + this.checkLive(); + this.revalidate(); + // Internal result references may survive preparation, but never entry. + if (action?.parameters !== undefined) + validateJson(action.parameters, true); + this.possibleEffects ||= possibleEffects; + } + + result(action: FullAction, result: ActionResult): void { + if (this.terminal !== undefined) return; + this.results.push({ + action: structuredClone(action), + result: structuredClone(result), + }); + if (result.error !== undefined) this.display(result.error); + else if (result.historyText !== undefined) + this.display(result.historyText); + else if (result.displayContent !== undefined) + this.display(result.displayContent); + } + + display(content: DisplayContent): void { + if (this.terminal !== undefined) return; + let text: string; + if (typeof content === "string") text = content; + else if (Array.isArray(content)) + text = content + .map((item) => (Array.isArray(item) ? item.join(" ") : item)) + .join("\n"); + else if (content.type === "structured") { + this.display(getStructuredFallback(content, "text")); + return; + } else { + const alternate = content.alternates?.find( + (item) => item.type === "text" || item.type === "markdown", + ); + const value = alternate?.content ?? content.content; + text = + typeof value === "string" + ? value + : value + .map((item) => + Array.isArray(item) ? item.join(" ") : item, + ) + .join("\n"); + if (alternate === undefined && content.type === "html") + text = htmlToText(text); + } + if (text.length > 0 && this.output[this.output.length - 1] !== text) + this.output.push(text); + } + + async choice( + action: FullAction, + initial: ActionResult, + actionContext: ActionContext, + ): Promise { + let result = initial; + const agentName = getAppAgentName(action.schemaName); + const agent = this.context.agents.getAppAgent(agentName); + const additionalActions = + initial.error === undefined + ? [...(initial.additionalActions ?? [])] + : []; + while ( + result.error === undefined && + result.pendingChoice !== undefined + ) { + const { choiceId, ...prompt } = result.pendingChoice; + try { + const response = await this.prompt(prompt); + await this.guard(action, "enter"); + if (agent.handleChoice === undefined) + throw new Error( + "Agent does not support choice continuation", + ); + const answer = choiceAnswer(response); + this.effect(action); + result = structuredClone( + (await agent.handleChoice( + choiceId, + answer, + actionContext, + )) ?? createActionResultNoDisplay("Choice completed."), + ); + if ( + this.terminal !== undefined && + result.error === undefined && + result.pendingChoice !== undefined + ) { + await agent.cancelChoice?.( + result.pendingChoice.choiceId, + actionContext.sessionContext, + ); + } + this.checkLive(); + this.result(action, result); + if (result.error === undefined) + additionalActions.push(...(result.additionalActions ?? [])); + } finally { + await agent.cancelChoice?.( + choiceId, + actionContext.sessionContext, + ); + } + } + return result.error === undefined && additionalActions.length > 0 + ? { ...result, additionalActions } + : result; + } + + async prompt( + prompt: StructuredActionPrompt, + ): Promise { + this.checkLive(); + if (this.queuedPrompts >= MAX_OPERATIONS) + throw new ExecutionFailure( + "queue_full", + "Too many pending interactions", + "unavailable", + ); + const snapshot = immutable(prompt); + this.queuedPrompts++; + const answer = this.promptTail.then(() => this.showPrompt(snapshot)); + this.promptTail = answer.then( + () => undefined, + () => undefined, + ); + try { + return await answer; + } finally { + this.queuedPrompts--; + } + } + + private async showPrompt( + prompt: StructuredActionPrompt, + ): Promise { + this.checkLive(); + if (this.pending !== undefined) + throw new Error("An operation already has a pending interaction"); + const pending: PendingPrompt = { + id: randomUUID(), + prompt: immutable(prompt), + answer: deferred(), + }; + this.pending = pending; + this.context.requestQueue.markBlocked(this.id, "interaction"); + this.publish({ + ...this.envelope(), + status: "requires_interaction", + interactionId: pending.id, + expiresAt: this.expiresAt, + prompt: pending.prompt, + }); + try { + const response = await pending.answer.promise; + this.checkLive(); + this.revalidate(); + return response; + } finally { + if (this.pending === pending) this.pending = undefined; + this.context.requestQueue.markUnblocked(this.id); + } + } + + private revalidate(): void { + for (const saved of this.contracts.values()) { + this.checkLive(); + const current = this.discovery.getActionContractSnapshot(saved); + this.checkLive(); + if ( + current.status !== "found" || + current.contract.fingerprint !== saved.fingerprint + ) { + throw new ExecutionFailure( + "contract_stale", + "Contract changed while awaiting an interaction", + "contract_stale", + ); + } + if (current.contract.availability.state !== "available") { + throw new ExecutionFailure( + "unavailable", + "Action is no longer available", + "unavailable", + ); + } + } + } + + continue( + request: ContinueActionRequest, + discovery: StructuredActionDiscovery, + ): Promise { + this.authorize(discovery); + if (this.terminal !== undefined) + return Promise.resolve(structuredClone(this.terminal)); + const pending = this.pending; + if (pending === undefined || pending.id !== request.interactionId) { + throw new ExecutionFailure( + "interaction_consumed", + "Interaction is missing or already consumed", + ); + } + if (Date.now() >= this.expiresAt) { + this.cancel("Operation expired", "interaction_expired"); + return this.wait(); + } + validateResponse(pending.prompt, request.response); + const answer = immutable(request.response); + // Claim synchronously, after validation and before any await. + this.discovery = discovery; + this.pending = undefined; + const next = this.wait(); + pending.answer.resolve(answer); + return next; + } + + wait(): Promise { + return this.terminal === undefined + ? this.delivery.promise.then((result) => structuredClone(result)) + : Promise.resolve(structuredClone(this.terminal)); + } + + private envelope() { + return { + protocolVersion: 1 as const, + scopeId: this.request.scopeId, + operationId: this.id, + output: structuredClone(this.output), + results: structuredClone(this.results), + }; + } + + private publish(result: StructuredActionExecutionResult): void { + const delivery = this.delivery; + this.delivery = deferred(); + delivery.resolve(result); + } + + finish(error?: unknown): void { + if (this.terminal !== undefined) return; + if ( + error === undefined && + this.context.currentRequestId?.requestId === this.id && + this.context.commandResult?.disposition?.status === "failed" + ) { + error = new ExecutionFailure( + "execution_failed", + this.context.commandResult.lastError ?? + "A nested command failed", + ); + } + const result = + error === undefined + ? { ...this.envelope(), status: "completed" as const } + : { + ...failure( + this.request.scopeId, + this.id, + error instanceof ExecutionFailure + ? error + : new ExecutionFailure( + "execution_failed", + error instanceof Error + ? error.message + : String(error), + ), + ), + output: structuredClone(this.output), + results: structuredClone(this.results), + }; + this.terminal = result; + if (this.context.currentRequestId?.requestId === this.id) { + const command = (this.context.commandResult ??= {}); + command.disposition = + result.status === "completed" + ? { status: "handled", path: "action" } + : { + status: "failed", + path: "action", + mayHaveSideEffects: this.possibleEffects, + }; + if ( + result.status === "cancelled" || + result.status === "execution_uncertain" + ) + command.cancelled = true; + if ("error" in result) command.lastError = result.error.message; + } + clearTimeout(this.timer); + this.pending?.answer.reject( + new DOMException("Operation ended", "AbortError"), + ); + this.pending = undefined; + this.publish(result); + } + + cancel( + message: string, + code: StructuredActionError["code"] = "cancelled", + ): void { + if (this.terminal !== undefined) return; + this.finish( + new ExecutionFailure( + code, + message, + this.possibleEffects ? "execution_uncertain" : "cancelled", + ), + ); + const queue = this.context.requestQueue; + if (!queue.cancelQueued(this.id, "user")) { + queue.cancelRunning(this.id, "user"); + this.context.activeRequests.get(this.id)?.abort(); + } + } + + retire(): void { + this.registry.live.delete(this.id); + if (this.registry.closed) return; + const timer = setTimeout( + () => this.registry.terminal.delete(this.id), + OPERATION_TTL, + ); + timer.unref(); + this.registry.terminal.set(this.id, { operation: this, timer }); + while (this.registry.terminal.size > MAX_OPERATIONS) { + const oldest = this.registry.terminal.entries().next().value!; + clearTimeout(oldest[1].timer); + this.registry.terminal.delete(oldest[0]); + } + } + + async run(): Promise { + if (this.terminal !== undefined) return; + const signal = this.context.currentAbortSignal; + const aborted = () => this.cancel("Execution cancelled"); + signal?.addEventListener("abort", aborted, { once: true }); + try { + await runStructuredExecution( + this.context, + this.id, + this, + async () => { + const action = { + schemaName: this.request.schemaName, + actionName: this.request.actionName, + ...(this.request.parameters === undefined + ? {} + : { + parameters: structuredClone( + this.request.parameters, + ), + }), + } as FullAction; + // Validate before even creating an ActionContext or resolving entities. + await this.guard(action, "prepare"); + const { actionContext, closeActionContext } = + this.runtime.getActionContext( + "dispatcher", + this.context, + { requestId: this.id }, + 0, + ); + try { + const error = await this.runtime.executeActions( + [{ action }], + undefined, + actionContext as ActionContext, + ); + this.checkLive(); + if (error !== undefined) + throw new ExecutionFailure( + "execution_failed", + error.error, + ); + if (this.results.length === 0) + throw new ExecutionFailure( + "execution_failed", + "Action handler was not entered", + ); + this.finish(); + } finally { + closeActionContext(); + } + }, + ); + } catch (error) { + if (error instanceof RpcDisconnectedError) { + this.finish( + new ExecutionFailure( + "execution_state_lost", + "Agent transport was lost; completion is unknown. Close this dispatcher before starting new work; do not replay.", + "execution_uncertain", + ), + ); + // A disconnected worker may still be executing. Stop admission + // and retain the shared lock until this context is destroyed. + void this.context.requestQueue.drainAndStop(); + await this.registry.disposed.promise; + } else if (signal?.aborted) this.cancel("Execution cancelled"); + else this.finish(error); + } finally { + signal?.removeEventListener("abort", aborted); + } + } +} + +function choiceAnswer(response: StructuredActionResponse) { + switch (response.type) { + case "yesNo": + return response.value; + case "multiChoice": + return response.selected; + case "pickRemember": + return { selected: response.selected, remember: response.remember }; + case "form": + return response.value; + default: + throw new Error("Invalid SDK choice response"); + } +} + +export class StructuredActionExecution { + private readonly registry: Registry; + constructor( + private readonly context: CommandHandlerContext, + private readonly discovery: StructuredActionDiscovery, + private readonly runtime: ExecutionRuntime, + private readonly connectionId?: string, + ) { + this.registry = registryFor(context); + } + + async executeAction( + input: ExecuteActionRequest, + ): Promise { + let operation: Operation | undefined; + try { + requestEnvelope(input, [ + "schemaName", + "actionName", + "fingerprint", + "parameters", + ]); + nonempty(input.schemaName, "schemaName"); + nonempty(input.actionName, "actionName"); + nonempty(input.fingerprint, "fingerprint"); + if (input.parameters !== undefined) { + object(input.parameters); + validateJson(input.parameters, true); + } + const request = immutable(input); + if (this.registry.closed) throw new ServerStoppingError(); + if (binding(this.discovery).envelope.scopeId !== request.scopeId) + throw new ExecutionFailure( + "invalid_scope", + "Invalid structured action scope", + ); + installStructuredInteractionRouting(this.context); + if (this.registry.live.size >= MAX_OPERATIONS) + throw new QueueFullError(MAX_OPERATIONS); + operation = new Operation( + this.context, + this.discovery, + request, + this.registry, + this.runtime, + ); + this.registry.live.set(operation.id, operation); + const response = operation.wait(); + const current = operation; + const entry = this.context.requestQueue.submit({ + text: `Structured action: ${request.schemaName}.${request.actionName}`, + requestId: operation.id, + originatorConnectionId: this.connectionId ?? "", + options: { noReasoning: true }, + ...(this.context.telemetryOptions.joinActiveTrace + ? { traceContext: otelContext.active() } + : {}), + work: { kind: "structured-action", run: () => current.run() }, + }); + void entry.completion.then( + (result) => { + if (result?.cancelled) + current.cancel("Execution cancelled"); + else current.finish(); + current.retire(); + }, + (error: unknown) => { + current.finish(error); + current.retire(); + }, + ); + return await response; + } catch (error) { + const cause = + error instanceof QueueFullError + ? new ExecutionFailure( + "queue_full", + "Structured action queue is full", + "unavailable", + ) + : error instanceof ServerStoppingError + ? new ExecutionFailure( + "server_stopping", + "Dispatcher is stopping", + "unavailable", + ) + : error; + if (operation !== undefined) { + operation.finish(cause); + operation.retire(); + return operation.wait(); + } + return failure( + typeof input?.scopeId === "string" ? input.scopeId : "", + "", + cause, + ); + } + } + + async continueAction( + input: ContinueActionRequest, + ): Promise { + try { + requestEnvelope(input, [ + "operationId", + "interactionId", + "response", + ]); + nonempty(input.operationId, "operationId"); + nonempty(input.interactionId, "interactionId"); + const request = immutable(input); + return await this.find(request).continue(request, this.discovery); + } catch (error) { + return failure( + typeof input?.scopeId === "string" ? input.scopeId : "", + typeof input?.operationId === "string" ? input.operationId : "", + error instanceof ExecutionFailure + ? error + : new ExecutionFailure( + "invalid_response", + error instanceof Error + ? error.message + : String(error), + ), + ); + } + } + + async cancelAction( + input: CancelActionRequest, + ): Promise { + try { + requestEnvelope(input, ["operationId", "interactionId"]); + nonempty(input.operationId, "operationId"); + if (input.interactionId !== undefined) + nonempty(input.interactionId, "interactionId"); + const operation = this.find(input); + if ( + input.interactionId !== undefined && + operation.pending?.id !== input.interactionId + ) { + throw new ExecutionFailure( + "interaction_consumed", + "Interaction is missing or already consumed", + ); + } + operation.cancel("Execution cancelled"); + return await operation.wait(); + } catch (error) { + return failure( + typeof input?.scopeId === "string" ? input.scopeId : "", + typeof input?.operationId === "string" ? input.operationId : "", + error, + ); + } + } + + private find(input: { scopeId: string; operationId: string }): Operation { + if (binding(this.discovery).envelope.scopeId !== input.scopeId) + throw new ExecutionFailure( + "invalid_scope", + "Invalid structured action scope", + ); + const operation = + this.registry.live.get(input.operationId) ?? + this.registry.terminal.get(input.operationId)?.operation; + if (operation === undefined) + throw new ExecutionFailure( + "execution_state_lost", + "Execution state is unavailable; do not replay the action", + "execution_uncertain", + ); + operation.authorize(this.discovery); + return operation; + } +} diff --git a/ts/packages/dispatcher/dispatcher/src/structuredAction/executionHooks.ts b/ts/packages/dispatcher/dispatcher/src/structuredAction/executionHooks.ts new file mode 100644 index 0000000000..1e70d1e51b --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/structuredAction/executionHooks.ts @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { AsyncLocalStorage } from "node:async_hooks"; +import type { FullAction } from "@typeagent/agent-cache"; +import type { + ActionContext, + ActionResult, + DisplayContent, +} from "@typeagent/agent-sdk"; +import type { CommandHandlerContext } from "../context/commandHandlerContext.js"; +import type { + StructuredActionPrompt, + StructuredActionResponse, +} from "@typeagent/dispatcher-types"; + +export interface StructuredExecutionHooks { + guard(action: FullAction, phase: "prepare" | "enter"): Promise; + effect(action?: FullAction, possibleEffects?: boolean): void; + display(content: DisplayContent): void; + result(action: FullAction, result: ActionResult): void; + choice( + action: FullAction, + result: ActionResult, + context: ActionContext, + ): Promise; + prompt(prompt: StructuredActionPrompt): Promise; +} + +const requests = new WeakMap< + CommandHandlerContext, + Map +>(); +const active = new AsyncLocalStorage(); +const cleanup = new WeakMap void>(); + +export function registerStructuredActionCleanup( + context: CommandHandlerContext, + close: () => void, +) { + cleanup.set(context, close); +} + +export function closeStructuredActions(context: CommandHandlerContext) { + cleanup.get(context)?.(); + cleanup.delete(context); +} + +export function getStructuredExecution( + context: CommandHandlerContext, + requestId = context.currentRequestId?.requestId, +) { + return requestId === undefined + ? undefined + : requests.get(context)?.get(requestId); +} + +export async function runStructuredExecution( + context: CommandHandlerContext, + requestId: string, + hooks: StructuredExecutionHooks, + run: () => Promise, +) { + let registry = requests.get(context); + if (registry === undefined) { + registry = new Map(); + requests.set(context, registry); + } + registry.set(requestId, hooks); + try { + await active.run(hooks, run); + } finally { + registry.delete(requestId); + } +} + +const routed = new WeakSet(); + +/** Install once, outside requests. Prompts never reach legacy broadcast/ID registries. */ +export function installStructuredInteractionRouting( + context: CommandHandlerContext, +) { + if (routed.has(context)) return; + routed.add(context); + const client = context.clientIO; + const route = (requestId?: { requestId: string }) => + requestId === undefined + ? active.getStore() + : getStructuredExecution(context, requestId.requestId); + context.clientIO = new Proxy(client, { + get(target, key, receiver) { + if (key === "question") { + return async (...args: Parameters) => { + const hooks = route(args[0]); + if (hooks === undefined) return client.question(...args); + const response = await hooks.prompt({ + type: "question", + message: args[1], + choices: args[2], + ...(args[3] === undefined + ? {} + : { defaultId: args[3] }), + }); + if (response.type !== "question") + throw new Error("Invalid question response"); + return response.selected; + }; + } + if (key === "askForm") { + return async ( + ...args: Parameters> + ) => { + const hooks = route(args[0]); + if (hooks === undefined) { + if (client.askForm === undefined) + throw new Error("Forms are not supported"); + return client.askForm(...args); + } + const response = await hooks.prompt({ + type: "form", + ...args[1], + }); + if (response.type !== "form") + throw new Error("Invalid form response"); + return response.value; + }; + } + if (key === "proposeAction") { + return async ( + ...args: Parameters + ) => { + const hooks = route(args[0]); + if (hooks === undefined) + return client.proposeAction(...args); + const template = args[1]; + const response = await hooks.prompt({ + type: "proposal", + templateAgentName: template.templateAgentName, + templateName: template.templateName, + schema: template.defaultTemplate, + data: template.templateData, + templates: template, + }); + if (response.type !== "proposal") + throw new Error("Invalid proposal response"); + return response.accepted ? response.data : undefined; + }; + } + if (key === "getUserContext") { + return ( + ...args: Parameters< + NonNullable + > + ) => + route(args[0]) === undefined + ? client.getUserContext?.(...args) + : Promise.resolve(undefined); + } + return Reflect.get(target, key, receiver); + }, + }); +} diff --git a/ts/packages/dispatcher/dispatcher/src/structuredAction/validation.ts b/ts/packages/dispatcher/dispatcher/src/structuredAction/validation.ts new file mode 100644 index 0000000000..9c7523de96 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/structuredAction/validation.ts @@ -0,0 +1,249 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { + StructuredActionPrompt, + StructuredActionResponse, +} from "@typeagent/dispatcher-types"; +import type { QuestionForm, TemplateSchema } from "@typeagent/agent-sdk"; + +export function object( + value: unknown, +): asserts value is Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Expected an object"); + } +} + +export function keys( + value: Record, + allowed: readonly string[], +) { + for (const key of Object.keys(value)) { + if (!allowed.includes(key)) + throw new Error(`Unexpected field '${key}'`); + } +} + +export function nonempty( + value: unknown, + name: string, +): asserts value is string { + if (typeof value !== "string" || value.trim().length === 0) + throw new Error(`${name} must be a nonempty string`); +} + +/** Reject non-wire values and externally supplied implicit binding syntax. */ +export function validateJson( + value: unknown, + bindings = false, + depth = 0, +): void { + if (depth > 64) throw new Error("Input nesting exceeds 64 levels"); + if (typeof value === "string") { + if (bindings && /\$\{(?:entity|result)-/.test(value)) + throw new Error( + "External entity/result references are not supported", + ); + return; + } + if (value === null || typeof value === "boolean") return; + if (typeof value === "number" && Number.isFinite(value)) return; + if (Array.isArray(value)) { + for (const item of value) validateJson(item, bindings, depth + 1); + return; + } + object(value); + // structuredClone and embedding hosts can supply objects from another realm. + const prototype = Object.getPrototypeOf(value); + if (prototype !== null && Object.getPrototypeOf(prototype) !== null) + throw new Error("Expected plain JSON data"); + for (const [key, descriptor] of Object.entries( + Object.getOwnPropertyDescriptors(value), + )) { + if (!("value" in descriptor)) + throw new Error("Accessors are not supported"); + if ( + key === "__proto__" || + key === "constructor" || + key === "prototype" || + (bindings && key === "$result") + ) + throw new Error(`Unsupported field '${key}'`); + validateJson(descriptor.value, bindings, depth + 1); + } +} + +export function immutable(value: T): T { + const copy = structuredClone(value); + const freeze = (item: unknown): void => { + if (item !== null && typeof item === "object") { + for (const child of Object.values(item)) freeze(child); + Object.freeze(item); + } + }; + freeze(copy); + return copy; +} + +function index(value: unknown, count: number): asserts value is number { + if ( + !Number.isSafeInteger(value) || + (value as number) < 0 || + (value as number) >= count + ) + throw new Error("Choice index is out of range"); +} + +function indexes(value: unknown, count: number): void { + if (!Array.isArray(value)) throw new Error("Expected choice indexes"); + if (new Set(value).size !== value.length) + throw new Error("Duplicate choice indexes"); + for (const selected of value) index(selected, count); +} + +function bool(value: unknown): void { + if (typeof value !== "boolean") + throw new Error("Expected a boolean response"); +} + +function validateForm(form: QuestionForm, value: unknown): void { + object(value); + keys(value, ["answers", "cancelled"]); + if (value.cancelled !== undefined && value.cancelled !== false) + throw new Error("Use cancelAction to cancel a form"); + object(value.answers); + keys( + value.answers, + form.fields.map((field) => field.id), + ); + for (const field of form.fields) { + const answer = value.answers[field.id]; + object(answer); + if (answer.kind !== field.kind) + throw new Error(`Invalid answer kind for '${field.id}'`); + if (field.kind === "yesNo") { + keys(answer, ["kind", "value"]); + bool(answer.value); + continue; + } + keys(answer, ["kind", "selected", "text"]); + if (answer.text !== undefined) { + if (!field.allowFreeText) + throw new Error("Free text is not permitted"); + nonempty(answer.text, "text"); + } + if (field.kind === "pick") { + if (answer.selected === -1) { + if (!field.allowFreeText) + throw new Error("A choice is required"); + nonempty(answer.text, "text"); + } else { + index(answer.selected, field.choices.length); + if (answer.text !== undefined) + throw new Error("Free text requires selected -1"); + } + } else { + indexes(answer.selected, field.choices.length); + } + } +} + +export function validateResponse( + prompt: StructuredActionPrompt, + value: unknown, +): asserts value is StructuredActionResponse { + object(value); + if (value.type !== prompt.type) + throw new Error("Response does not match the pending prompt"); + switch (prompt.type) { + case "confirmation": + keys(value, ["type", "approved"]); + bool(value.approved); + break; + case "question": + keys(value, ["type", "selected"]); + index(value.selected, prompt.choices.length); + break; + case "yesNo": + keys(value, ["type", "value"]); + bool(value.value); + break; + case "multiChoice": + keys(value, ["type", "selected"]); + indexes(value.selected, prompt.choices.length); + break; + case "pickRemember": + keys(value, ["type", "selected", "remember"]); + index(value.selected, prompt.choices.length); + bool(value.remember); + break; + case "form": + keys(value, ["type", "value"]); + validateForm(prompt, value.value); + break; + case "proposal": + keys(value, ["type", "accepted", "data"]); + bool(value.accepted); + if (value.accepted) { + validateJson(value.data, true); + const templates = prompt.templates.templateData; + if (Array.isArray(templates)) { + if (!Array.isArray(value.data) || value.data.length > 100) + throw new Error( + "Expected at most 100 proposed actions", + ); + for (let i = 0; i < value.data.length; i++) { + validateTemplate( + templates[i]?.schema ?? prompt.schema, + value.data[i], + ); + } + } else { + validateTemplate(templates.schema, value.data); + } + } else if (value.data !== undefined) + throw new Error("Rejected proposal cannot contain data"); + break; + } +} + +function validateTemplate(schema: TemplateSchema, value: unknown): void { + // Implemented against the SDK's declarative template fields, not a model. + validateTemplateField(schema, value); +} + +function validateTemplateField(schema: unknown, value: unknown): void { + object(schema); + const field = schema as unknown as Record; + if (field.type === "object") { + object(value); + object(field.fields); + keys(value, Object.keys(field.fields)); + for (const [name, child] of Object.entries(field.fields)) { + object(child); + if (value[name] === undefined && child.optional === true) continue; + validateTemplateField(child.type, value[name]); + } + } else if (field.type === "array") { + if (!Array.isArray(value)) throw new Error("Expected an array"); + for (const item of value) + validateTemplateField(field.elementType, item); + } else if (field.type === "string-union") { + if ( + typeof value !== "string" || + !Array.isArray(field.typeEnum) || + !field.typeEnum.includes(value) + ) + throw new Error("Invalid proposal discriminator"); + } else if ( + field.type === "string" || + field.type === "number" || + field.type === "boolean" + ) { + if (typeof value !== field.type) + throw new Error(`Expected ${field.type}`); + } else { + throw new Error("Unsupported proposal field schema"); + } +} diff --git a/ts/packages/dispatcher/dispatcher/src/translation/actionSchemaFileCache.ts b/ts/packages/dispatcher/dispatcher/src/translation/actionSchemaFileCache.ts index 31ec9797bc..be8d19bff4 100644 --- a/ts/packages/dispatcher/dispatcher/src/translation/actionSchemaFileCache.ts +++ b/ts/packages/dispatcher/dispatcher/src/translation/actionSchemaFileCache.ts @@ -221,7 +221,18 @@ export class ActionSchemaFileCache { parsedActionSchema: parseActionSchemaSource( source, actionConfig.schemaName, - actionConfig.schemaType, + typeof actionConfig.schemaType === "string" + ? actionConfig.schemaType + : { + ...actionConfig.schemaType, + ...(actionConfig.schemaType.entities === + undefined + ? {} + : { + entity: actionConfig.schemaType + .entities, + }), + }, fullPath, config ? JSON.parse(config) : undefined, true, diff --git a/ts/packages/dispatcher/dispatcher/test/pendingInteractionManager.spec.ts b/ts/packages/dispatcher/dispatcher/test/pendingInteractionManager.spec.ts index 63d20c9c72..a90f735ac6 100644 --- a/ts/packages/dispatcher/dispatcher/test/pendingInteractionManager.spec.ts +++ b/ts/packages/dispatcher/dispatcher/test/pendingInteractionManager.spec.ts @@ -64,6 +64,20 @@ describe("PendingInteractionManager", () => { manager = new PendingInteractionManager(); }); + it("strict cancellation rejects even an affirmative default and removes the entry", async () => { + const request = makeQuestionRequest({ + interactionId: "strict", + defaultId: 0, + }); + const result = manager.create(request, undefined, { + rejectOnCancel: true, + }); + const rejected = expect(result).rejects.toThrow("cancelled"); + manager.cancel(request.interactionId, new Error("cancelled")); + await rejected; + expect(manager.size).toBe(0); + }); + // --------------------------------------------------------------- // 1. create + resolve: resolves promise with the given value // --------------------------------------------------------------- diff --git a/ts/packages/dispatcher/dispatcher/test/structuredActionExecution.spec.ts b/ts/packages/dispatcher/dispatcher/test/structuredActionExecution.spec.ts new file mode 100644 index 0000000000..f53d4bd5c0 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/test/structuredActionExecution.spec.ts @@ -0,0 +1,1407 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { jest } from "@jest/globals"; +import type { + ActionContext, + ActionResult, + AppAgent, + AppAgentManifest, + PendingChoice, + ReadinessReport, +} from "@typeagent/agent-sdk"; +import { ChoiceManager } from "@typeagent/agent-sdk/helpers/action"; +import type { + Dispatcher, + ExecuteActionRequest, + StructuredActionExecutionResult, + StructuredActionResponse, +} from "@typeagent/dispatcher-types"; +import { createDispatcherFromContext } from "../src/dispatcher.js"; +import { + initializeCommandHandlerContext, + closeCommandHandlerContext, + type CommandHandlerContext, +} from "../src/context/commandHandlerContext.js"; +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 { createAgentRpcClient } from "@typeagent/agent-rpc/client"; +import { createAgentRpcServer } from "@typeagent/agent-rpc/server"; +import { + createChannelProviderAdapter, + type ChannelProviderAdapter, +} from "@typeagent/agent-rpc/channel"; + +const manifest: AppAgentManifest = { + description: "Offline structured execution fixture", + emojiChar: "", + schema: { + description: "Guarded actions", + schemaType: { action: "Actions", entities: "Entities" }, + schemaFile: { + format: "ts", + content: ` + export type Actions = Write | Read | ConfirmedRead | Resolve; + export type Entities = Item; + type Item = string; + type Resolve = { actionName: "resolve"; parameters: { value: Item; mode?: string } }; + type Write = { actionName: "write"; parameters: { value: string; mode?: string } }; + type Read = { actionName: "read"; parameters: { value: string; mode?: string } }; + type ConfirmedRead = { actionName: "confirmedRead"; parameters: { value: string; mode?: string } }; + `, + }, + actionPolicies: { + read: { effects: "read-only" }, + confirmedRead: { effects: "read-only", confirmation: "required" }, + }, + }, +}; + +function requirePrompt(result: StructuredActionExecutionResult) { + if (result.status !== "requires_interaction") + throw new Error(`Expected prompt: ${JSON.stringify(result)}`); + return result; +} + +describe("real structured dispatcher execution", () => { + let context: CommandHandlerContext; + let dispatcher: Dispatcher; + let choices: ChoiceManager; + let readiness: ReadinessReport; + let entered: string[]; + let callbacks: number; + let resolutions: number; + let holdResolution: boolean; + let liveContext: ActionContext | undefined; + let release: (() => void) | undefined; + let held: Promise; + let scope: object; + let active: boolean; + let executionAllowed: boolean | undefined; + let closeRpc: (() => void) | undefined; + const broadcasts: string[] = []; + const setup = jest.fn>(); + + const complete = (): ActionResult => ({ + entities: [{ name: "saved", type: ["Item"], uniqueId: "stable-1" }], + resultEntity: { name: "saved", type: ["Item"], uniqueId: "stable-1" }, + resultValue: { ids: ["stable-1"], count: 42 }, + historyText: "Saved item", + displayContent: { type: "html", content: "Saved" }, + }); + const form = { + message: "Every field matters", + paged: true, + fields: [ + { + id: "pick", + kind: "pick" as const, + prompt: "Pick", + choices: ["a", "b"], + allowFreeText: true, + }, + { + id: "many", + kind: "multiChoice" as const, + prompt: "Many", + choices: ["a", "b"], + allowFreeText: true, + }, + { + id: "yes", + kind: "yesNo" as const, + prompt: "Yes?", + defaultValue: true, + }, + ], + }; + const formAnswer: StructuredActionResponse = { + type: "form", + value: { + answers: { + pick: { kind: "pick", selected: -1, text: "other" }, + many: { kind: "multiChoice", selected: [0, 1] }, + yes: { kind: "yesNo", value: false }, + }, + }, + }; + + beforeEach(async () => { + choices = new ChoiceManager(); + readiness = { state: "ready" }; + entered = []; + callbacks = 0; + resolutions = 0; + holdResolution = false; + active = true; + executionAllowed = undefined; + scope = {}; + broadcasts.length = 0; + setup.mockClear(); + held = new Promise((resolve) => { + release = resolve; + }); + const agent: AppAgent = { + checkReadiness: async () => readiness, + setup, + resolveEntity: async (type, name) => { + resolutions++; + if (holdResolution) await held; + return { + match: "exact", + entities: [{ name, type: [type], uniqueId: "resolved-id" }], + }; + }, + cancelChoice: async (id) => { + choices.cancelChoice(id); + }, + handleChoice: (id, response, actionContext) => + choices.handleChoice(id, response, actionContext), + executeAction: async (action, actionContext) => { + liveContext = actionContext; + const params = action.parameters as { + value: string; + mode?: string; + }; + entered.push(params.value); + expect(actionContext.activityContext).toBeUndefined(); + switch (params.mode) { + case "parallelQuestions": + await Promise.all( + ["first", "second"].map((message) => + actionContext.sessionContext.popupQuestion( + message, + ["yes", "no"], + 0, + ), + ), + ); + callbacks++; + return complete(); + case "choiceChild": + return { + entities: [], + additionalActions: [ + { + actionName: "write", + parameters: { value: "initial-child" }, + }, + ], + pendingChoice: { + type: "yesNo", + message: "Continue", + choiceId: choices.registerChoice(async () => { + callbacks++; + return { + ...complete(), + additionalActions: [ + { + actionName: "write", + parameters: { + value: "callback-child", + }, + }, + ], + }; + }), + }, + }; + case "hold": + await held; + return complete(); + case "throw": + throw new Error("handler failed"); + case "fallback": + return { + error: "No model retry", + fallbackToReasoning: true, + } as ActionResult; + case "empty": + return undefined; + case "parentChild": + return { + ...complete(), + resultValue: { ids: ["source-id"] }, + historyText: "Parent result", + additionalActions: [ + { + actionName: "write", + parameters: { + value: "child", + mode: "distinctChild", + }, + }, + ], + }; + case "distinctChild": + return { + ...complete(), + resultValue: { ids: ["child-id"] }, + historyText: "Child result", + }; + case "question": { + const selected = + await actionContext.sessionContext.popupQuestion( + "Choose", + ["yes", "no"], + 0, + ); + callbacks += selected === 0 ? 1 : 10; + return complete(); + } + case "blockingForm": + await context.clientIO.askForm!( + context.currentRequestId, + form, + "guarded", + ); + callbacks++; + return complete(); + case "proposal": + await context.clientIO.proposeAction( + context.currentRequestId!, + { + templateAgentName: "guarded", + templateName: "edit", + defaultTemplate: { + type: "object", + fields: { + value: { type: { type: "string" } }, + }, + }, + templateData: { + schema: { + type: "object", + fields: { + value: { type: { type: "string" } }, + }, + }, + data: { value: "old" }, + }, + }, + "guarded", + ); + callbacks++; + return complete(); + case "child": + return { + ...complete(), + additionalActions: [ + { + actionName: "write", + parameters: { value: "child" }, + }, + ], + }; + case "reason": + return { + ...complete(), + additionalActions: [ + { + schemaName: "dispatcher", + actionName: "reasoningAction", + parameters: { request: "do not run" }, + }, + ], + }; + } + if ( + ["yesNo", "multiChoice", "pickRemember", "form"].includes( + params.mode ?? "", + ) + ) { + const choiceId = choices.registerChoice( + async (_response, callbackContext) => { + callbacks++; + callbackContext.actionIO.appendDisplay( + "callback output", + ); + return complete(); + }, + ); + const pendingChoice: PendingChoice = + params.mode === "form" + ? { type: "form", choiceId, ...form } + : params.mode === "yesNo" + ? { type: "yesNo", choiceId, message: "Really?" } + : params.mode === "pickRemember" + ? { + type: "pickRemember", + choiceId, + message: "Pick", + choices: ["a", "b"], + checkboxLabel: "Remember", + } + : { + type: "multiChoice", + choiceId, + message: "Many", + choices: ["a", "b"], + }; + return { entities: [], pendingChoice }; + } + return complete(); + }, + }; + const provider: AppAgentProvider = { + getAppAgentNames: () => ["guarded"], + getAppAgentManifest: async () => manifest, + loadAppAgent: async () => agent, + unloadAppAgent: async () => {}, + }; + context = await initializeCommandHandlerContext( + "structured-execution-test", + { + agents: { + schemas: ["guarded", "system.config"], + actions: ["guarded", "system.config"], + }, + translation: { enabled: false }, + explainer: { enabled: false }, + cache: { enabled: false }, + appAgentProviders: [provider], + collectCommandResult: true, + metrics: true, + conversationMemorySettings: { + requestKnowledgeExtraction: false, + actionResultEntityStorage: false, + actionResultKnowledgeExtraction: false, + }, + clientIO: { + ...nullClientIO, + question: async () => { + broadcasts.push("question"); + return 0; + }, + askForm: async () => { + broadcasts.push("form"); + return { answers: {} }; + }, + proposeAction: async () => { + broadcasts.push("proposal"); + }, + requestChoice: () => { + broadcasts.push("choice"); + }, + requestForm: () => { + broadcasts.push("form"); + }, + requestInteraction: () => { + broadcasts.push("interaction"); + }, + }, + }, + ); + dispatcher = createDispatcherFromContext( + context, + "owner", + undefined, + () => ({ + scope, + canDiscoverSchema: () => true, + ...(executionAllowed === undefined + ? {} + : { canExecute: executionAllowed }), + isActive: () => active, + }), + ); + }); + + afterEach(async () => { + release?.(); + await closeCommandHandlerContext(context); + closeRpc?.(); + closeRpc = undefined; + }); + + async function useAgentRpc() { + let clientProvider: ChannelProviderAdapter; + let serverProvider: ChannelProviderAdapter; + clientProvider = createChannelProviderAdapter( + "client", + (message, callback) => { + setImmediate(() => + serverProvider.notifyMessage(structuredClone(message)), + ); + callback?.(null); + }, + ); + serverProvider = createChannelProviderAdapter( + "server", + (message, callback) => { + setImmediate(() => + clientProvider.notifyMessage(structuredClone(message)), + ); + callback?.(null); + }, + ); + const original = context.agents.getAppAgent.bind(context.agents); + const server = createAgentRpcServer( + "guarded", + original("guarded"), + serverProvider, + ); + const client = await createAgentRpcClient( + "guarded", + clientProvider, + server.agentInterface, + ); + const spy = jest + .spyOn(context.agents, "getAppAgent") + .mockImplementation((name) => + name === "guarded" ? client : original(name), + ); + closeRpc = () => { + spy.mockRestore(); + server.closeFn(); + clientProvider.notifyDisconnected(); + serverProvider.notifyDisconnected(); + }; + return () => { + clientProvider.notifyDisconnected(); + serverProvider.notifyDisconnected(); + }; + } + + async function request( + actionName = "write", + mode?: string, + ): Promise { + const found = await dispatcher.getActionContract({ + schemaName: "guarded", + actionName, + }); + if (found.status !== "found") + throw new Error("Missing fixture contract"); + return { + protocolVersion: 1, + scopeId: found.scopeId, + schemaName: "guarded", + actionName, + fingerprint: found.contract.fingerprint, + parameters: { + value: "original", + ...(mode === undefined ? {} : { mode }), + }, + }; + } + + async function answer( + result: StructuredActionExecutionResult, + response: StructuredActionResponse, + ) { + const prompt = requirePrompt(result); + return dispatcher.continueAction({ + protocolVersion: 1, + scopeId: prompt.scopeId, + operationId: prompt.operationId, + interactionId: prompt.interactionId, + response, + }); + } + + it("confirms an immutable action, preserves real result data, and never broadcasts prompts", async () => { + const input = await request(); + const pending = dispatcher.executeAction(input); + input.parameters!.value = "mutated"; + const prompt = requirePrompt(await pending); + expect(prompt.prompt).toMatchObject({ + type: "confirmation", + action: { parameters: { value: "original" } }, + }); + expect(entered).toEqual([]); + expect(context.currentRequestId?.requestId).toBe(prompt.operationId); + expect(context.activeRequests.has(prompt.operationId)).toBe(true); + expect(context.currentAbortSignal).toBeDefined(); + expect((await dispatcher.getQueueSnapshot()).running?.blockedOn).toBe( + "interaction", + ); + const result = await answer(prompt, { + type: "confirmation", + approved: true, + }); + expect(result.status).toBe("completed"); + expect(entered).toEqual(["original"]); + expect(result.results[0].result).toMatchObject(complete()); + expect(result.output).toContain("Saved item"); + expect(broadcasts).toEqual([]); + }); + + it.each([ + "stale", + "scope", + "parameter", + "approval", + "reference", + "resultReference", + ])("rejects %s before any agent effect", async (kind) => { + const input = await request(); + if (kind === "stale") input.fingerprint = "stale"; + if (kind === "scope") input.scopeId = "wrong"; + if (kind === "parameter") input.parameters = { value: 1 }; + if (kind === "approval") Object.assign(input, { approved: true }); + if (kind === "reference") input.parameters!.value = "${entity-1}"; + if (kind === "resultReference") + input.parameters!.value = { $result: "1" }; + const result = await dispatcher.executeAction(input); + expect(result.status).not.toBe("completed"); + expect(result.status).not.toBe("requires_interaction"); + expect(entered).toEqual([]); + expect(setup).not.toHaveBeenCalled(); + expect(resolutions).toBe(0); + }); + + it.each(["read", "write", "resolve"])( + "allows discovery but denies %s execution without opt-in", + async (actionName) => { + executionAllowed = false; + const input = await request(actionName); + const result = await dispatcher.executeAction(input); + expect(result).toMatchObject({ + status: "unavailable", + operationId: "", + error: { code: "unavailable" }, + }); + expect(context.requestQueue.getSnapshot().running).toBeNull(); + expect(entered).toEqual([]); + expect(resolutions).toBe(0); + expect(setup).not.toHaveBeenCalled(); + }, + ); + + it("denies continuation without consuming the prompt when execution access is revoked", async () => { + const prompt = requirePrompt( + await dispatcher.executeAction(await request()), + ); + executionAllowed = false; + expect( + (await answer(prompt, { type: "confirmation", approved: true })) + .status, + ).toBe("unavailable"); + expect( + ( + await dispatcher.cancelAction({ + protocolVersion: 1, + scopeId: prompt.scopeId, + operationId: prompt.operationId, + }) + ).status, + ).toBe("unavailable"); + expect(entered).toEqual([]); + executionAllowed = true; + expect( + (await answer(prompt, { type: "confirmation", approved: true })) + .status, + ).toBe("completed"); + expect(entered).toEqual(["original"]); + }); + + it("rechecks execution access after asynchronous entity preparation", async () => { + holdResolution = true; + const prompt = requirePrompt( + await dispatcher.executeAction(await request("resolve")), + ); + const result = answer(prompt, { type: "confirmation", approved: true }); + for (let ticks = 0; resolutions === 0 && ticks < 20; ticks++) + await new Promise((resolve) => setImmediate(resolve)); + expect(resolutions).toBe(1); + executionAllowed = false; + release!(); + expect((await result).status).toBe("unavailable"); + expect(entered).toEqual([]); + expect(setup).not.toHaveBeenCalled(); + }); + + it("does not execute or run setup while unready", async () => { + const input = await request(); + readiness = { state: "setup-required", message: "Configure fixture" }; + await context.agents.refreshReadiness("guarded"); + const result = await dispatcher.executeAction(input); + expect(result.status).toBe("unavailable"); + expect(entered).toEqual([]); + expect(setup).not.toHaveBeenCalled(); + }); + + it("requires explicit read-only policy and honors required confirmation", async () => { + expect( + (await dispatcher.executeAction(await request("read"))).status, + ).toBe("completed"); + const prompt = await dispatcher.executeAction( + await request("confirmedRead"), + ); + expect(requirePrompt(prompt).prompt.type).toBe("confirmation"); + await dispatcher.cancelAction({ + protocolVersion: 1, + scopeId: prompt.scopeId, + operationId: prompt.operationId, + }); + expect(entered).toEqual(["original"]); + }); + + it("does not consume invalid or duplicate concurrent responses", async () => { + const prompt = requirePrompt( + await dispatcher.executeAction(await request()), + ); + expect( + (await answer(prompt, { type: "yesNo", value: true })).status, + ).toBe("failed"); + const [first, second] = await Promise.all([ + answer(prompt, { type: "confirmation", approved: true }), + answer(prompt, { type: "confirmation", approved: true }), + ]); + expect(first.status).toBe("completed"); + expect(second.status).toBe("failed"); + expect(entered).toEqual(["original"]); + }); + + it("rechecks changed contracts after waiting without invoking the handler", async () => { + const prompt = await dispatcher.executeAction(await request()); + const config = context.agents.getActionConfig("guarded"); + config.actionPolicies = { + ...config.actionPolicies, + write: { effects: "state-changing" }, + }; + expect( + (await answer(prompt, { type: "confirmation", approved: true })) + .status, + ).toBe("contract_stale"); + expect(entered).toEqual([]); + }); + + it.each([ + "yesNo", + "multiChoice", + "pickRemember", + "form", + "question", + "blockingForm", + "proposal", + ])( + "resumes the same %s operation without rerunning the action", + async (mode) => { + const prompt = requirePrompt( + await dispatcher.executeAction(await request("read", mode)), + ); + const responses: Record = { + yesNo: { type: "yesNo", value: true }, + multiChoice: { type: "multiChoice", selected: [0, 1] }, + pickRemember: { + type: "pickRemember", + selected: 1, + remember: false, + }, + form: formAnswer, + blockingForm: formAnswer, + question: { type: "question", selected: 0 }, + proposal: { + type: "proposal", + accepted: true, + data: { value: "edited" }, + }, + }; + expect( + (await answer(prompt, { type: "question", selected: 99 })) + .status, + ).toBe("failed"); + const result = await answer(prompt, responses[mode]); + if (result.status !== "completed") + throw new Error(JSON.stringify(result)); + expect(result).toMatchObject({ status: "completed" }); + expect(entered).toEqual(["original"]); + expect(callbacks).toBe(1); + expect(broadcasts).toEqual([]); + expect(() => liveContext!.actionIO).toThrow("Context is closed"); + }, + ); + + it("requires every form answer and rejects duplicates without consuming the prompt", async () => { + const prompt = await dispatcher.executeAction( + await request("read", "form"), + ); + expect( + (await answer(prompt, { type: "form", value: { answers: {} } })) + .status, + ).toBe("failed"); + const invalid = structuredClone(formAnswer); + if (invalid.type === "form") + invalid.value.answers.many = { + kind: "multiChoice", + selected: [0, 0], + }; + expect((await answer(prompt, invalid)).status).toBe("failed"); + expect((await answer(prompt, formAnswer)).status).toBe("completed"); + expect(callbacks).toBe(1); + }); + + it.each(["write", "question", "yesNo"])( + "cancellation of %s never chooses a default", + async (mode) => { + const prompt = requirePrompt( + await dispatcher.executeAction( + await request(mode === "write" ? "write" : "read", mode), + ), + ); + const result = await dispatcher.cancelAction({ + protocolVersion: 1, + scopeId: prompt.scopeId, + operationId: prompt.operationId, + }); + expect(result.status).toBe( + mode === "write" ? "cancelled" : "execution_uncertain", + ); + await new Promise((resolve) => setImmediate(resolve)); + expect(callbacks).toBe(0); + expect( + (choices as unknown as { callbacks: Map }) + .callbacks.size, + ).toBe(0); + }, + ); + + it("guards additional actions independently", async () => { + const prompt = requirePrompt( + await dispatcher.executeAction(await request("read", "child")), + ); + expect(prompt.prompt).toMatchObject({ + type: "confirmation", + action: { parameters: { value: "child" } }, + }); + expect(entered).toEqual(["original"]); + expect( + (await answer(prompt, { type: "confirmation", approved: true })) + .status, + ).toBe("completed"); + expect(entered).toEqual(["original", "child"]); + }); + + it("serializes concurrent blocking prompts without dropping an answer", async () => { + const first = requirePrompt( + await dispatcher.executeAction( + await request("read", "parallelQuestions"), + ), + ); + expect(first.prompt).toMatchObject({ + type: "question", + message: "first", + }); + const second = requirePrompt( + await answer(first, { type: "question", selected: 1 }), + ); + expect(second.prompt).toMatchObject({ + type: "question", + message: "second", + }); + expect( + (await answer(second, { type: "question", selected: 0 })).status, + ).toBe("completed"); + expect(callbacks).toBe(1); + expect(broadcasts).toEqual([]); + }); + + it("preserves additional actions from both a pending choice and its callback", async () => { + const choice = requirePrompt( + await dispatcher.executeAction( + await request("read", "choiceChild"), + ), + ); + const first = requirePrompt( + await answer(choice, { type: "yesNo", value: true }), + ); + expect(first.prompt).toMatchObject({ + action: { parameters: { value: "initial-child" } }, + }); + const second = requirePrompt( + await answer(first, { type: "confirmation", approved: true }), + ); + expect(second.prompt).toMatchObject({ + action: { parameters: { value: "callback-child" } }, + }); + expect( + (await answer(second, { type: "confirmation", approved: true })) + .status, + ).toBe("completed"); + expect(entered).toEqual([ + "original", + "initial-child", + "callback-child", + ]); + expect(callbacks).toBe(1); + }); + + it.each(["question", "yesNo"])( + "resumes a real agent-RPC %s without legacy broadcasting", + async (mode) => { + await useAgentRpc(); + const prompt = requirePrompt( + await dispatcher.executeAction(await request("read", mode)), + ); + expect(broadcasts).toEqual([]); + const result = await answer( + prompt, + mode === "question" + ? { type: "question", selected: 0 } + : { type: "yesNo", value: true }, + ); + expect(result.status).toBe("completed"); + expect(callbacks).toBe(1); + expect(entered).toEqual(["original"]); + expect(result.results.at(-1)?.result).toMatchObject({ + resultValue: { ids: ["stable-1"], count: 42 }, + }); + }, + ); + + it("retains the queue lock after aborting an uncooperative agent-RPC handler", async () => { + await useAgentRpc(); + const input = await request("read", "hold"); + const running = dispatcher.executeAction(input); + for (let ticks = 0; entered.length === 0 && ticks < 50; ticks++) + await new Promise((resolve) => setImmediate(resolve)); + expect(entered).toEqual(["original"]); + const cancelled = await dispatcher.cancelAction({ + protocolVersion: 1, + scopeId: input.scopeId, + operationId: context.currentRequestId!.requestId, + }); + expect(cancelled.status).toBe("execution_uncertain"); + expect((await running).status).toBe("execution_uncertain"); + const next = dispatcher.executeAction(await request("read")); + for (let ticks = 0; ticks < 10; ticks++) + await new Promise((resolve) => setImmediate(resolve)); + expect(entered).toHaveLength(1); + release!(); + expect((await next).status).toBe("completed"); + expect(entered).toHaveLength(2); + }); + + it("returns uncertainty and stops admission when the agent transport is lost", async () => { + const disconnect = await useAgentRpc(); + const input = await request("read", "hold"); + const running = dispatcher.executeAction(input); + for (let ticks = 0; entered.length === 0 && ticks < 50; ticks++) + await new Promise((resolve) => setImmediate(resolve)); + expect(entered).toEqual(["original"]); + disconnect(); + expect((await running).status).toBe("execution_uncertain"); + expect((await dispatcher.executeAction(input)).status).toBe( + "unavailable", + ); + expect(context.currentRequestId).toBeDefined(); + }); + + it("chains same-operation resultValue through guarded flow actions and their children", async () => { + const registry = ( + context.agents as unknown as { + flowRegistry: Map; + } + ).flowRegistry; + registry.set("guarded/read", { + name: "read", + description: "Chained flow", + parameters: {}, + steps: [ + { + id: "first", + schemaName: "guarded", + actionName: "write", + parameters: { value: "first" }, + }, + { + id: "second", + schemaName: "guarded", + actionName: "write", + parameters: { value: "${first.data.ids.0}", mode: "child" }, + }, + ], + }); + const first = requirePrompt( + await dispatcher.executeAction(await request("read")), + ); + const second = requirePrompt( + await answer(first, { type: "confirmation", approved: true }), + ); + expect(second.prompt).toMatchObject({ + action: { parameters: { value: "stable-1" } }, + }); + const child = requirePrompt( + await answer(second, { type: "confirmation", approved: true }), + ); + expect(child.prompt).toMatchObject({ + action: { parameters: { value: "child" } }, + }); + expect( + (await answer(child, { type: "confirmation", approved: true })) + .status, + ).toBe("completed"); + expect(entered).toEqual(["first", "stable-1", "child"]); + }); + + it("guards each nested flow step, including entity preparation", async () => { + const registry = ( + context.agents as unknown as { + flowRegistry: Map; + } + ).flowRegistry; + registry.set("guarded/read", { + name: "read", + description: "Flow fixture", + parameters: {}, + steps: [ + { + id: "resolve", + schemaName: "guarded", + actionName: "resolve", + parameters: { value: "${value}" }, + }, + ], + }); + const prompt = requirePrompt( + await dispatcher.executeAction(await request("read")), + ); + expect(prompt.prompt).toMatchObject({ + type: "confirmation", + action: { actionName: "resolve" }, + }); + expect(entered).toEqual([]); + expect(resolutions).toBe(0); + expect( + (await answer(prompt, { type: "confirmation", approved: true })) + .status, + ).toBe("completed"); + expect(entered).toEqual(["original"]); + expect(resolutions).toBe(1); + }); + + it.each(["disabled", "inactive"])( + "does not enter a %s schema", + async (state) => { + const input = await request("resolve"); + if (state === "disabled") { + const agents = context.agents as unknown as { + agents: Map }>; + }; + agents.agents.get("guarded")!.actions.delete("guarded"); + } else { + const agents = context.agents as unknown as { + transientAgents: Record; + }; + agents.transientAgents.guarded = false; + } + expect((await dispatcher.executeAction(input)).status).toBe( + "unavailable", + ); + expect(entered).toEqual([]); + expect(resolutions).toBe(0); + expect(setup).not.toHaveBeenCalled(); + }, + ); + + it("does not let legacy choice responses consume a structured SDK choice", async () => { + const prompt = requirePrompt( + await dispatcher.executeAction(await request("read", "yesNo")), + ); + const choiceResult = prompt.results[0].result; + if ( + choiceResult.error !== undefined || + choiceResult.pendingChoice === undefined + ) + throw new Error("Missing SDK choice"); + await expect( + dispatcher.respondToChoice( + choiceResult.pendingChoice.choiceId, + true, + ), + ).rejects.toThrow("Choice not found or expired"); + expect(callbacks).toBe(0); + expect( + (await answer(prompt, { type: "yesNo", value: true })).status, + ).toBe("completed"); + expect(callbacks).toBe(1); + }); + + it.each(["throw", "fallback", "reason"])( + "returns %s failures without model retry", + async (mode) => { + const result = await dispatcher.executeAction( + await request("read", mode), + ); + expect(result.status).toBe("failed"); + expect(entered).toEqual(["original"]); + }, + ); + + it("accepts an empty handler result as completion", async () => { + expect( + (await dispatcher.executeAction(await request("read", "empty"))) + .status, + ).toBe("completed"); + }); + + it("returns nested built-in command errors instead of synthesized success", async () => { + const identity = { + schemaName: "system.config", + 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({ + protocolVersion: found.protocolVersion, + scopeId: found.scopeId, + ...identity, + fingerprint: found.contract.fingerprint, + parameters: { + enable: true, + agentNames: ["review-no-such-agent"], + }, + }), + ); + const result = await answer(prompt, { + type: "confirmation", + approved: true, + }); + expect(result.status).toBe("failed"); + expect(result.results[0].result.error).toContain("Invalid agent name"); + expect(result.output.join("\n")).toContain("review-no-such-agent"); + expect(result.output.join("\n")).not.toContain("completed."); + + 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("binds a flow step's own result and never replays its additional actions", async () => { + const registry = ( + context.agents as unknown as { + flowRegistry: Map; + } + ).flowRegistry; + registry.set("guarded/read", { + name: "read", + description: "Distinct parent and child results", + parameters: {}, + steps: [ + { + id: "first", + schemaName: "guarded", + actionName: "write", + parameters: { value: "first", mode: "parentChild" }, + }, + { + id: "last", + schemaName: "guarded", + actionName: "write", + parameters: { + value: "${first.data.ids.0}", + mode: "parentChild", + }, + }, + ], + }); + let result = await dispatcher.executeAction(await request("read")); + for (const value of ["first", "child", "source-id", "child"]) { + const current = requirePrompt(result); + expect(current.prompt).toMatchObject({ + type: "confirmation", + action: { parameters: { value } }, + }); + result = await answer(current, { + type: "confirmation", + approved: true, + }); + } + expect(result.status).toBe("completed"); + expect(entered).toEqual(["first", "child", "source-id", "child"]); + const root = result.results.find( + ({ action }) => action.actionName === "read", + ); + expect(root?.result).toMatchObject({ + resultValue: { ids: ["source-id"] }, + }); + expect(root?.result).not.toHaveProperty("additionalActions"); + }); + + it("retains uncertainty and serialization while an uncooperative handler runs", async () => { + const input = await request("read", "hold"); + const running = dispatcher.executeAction(input); + while (entered.length === 0) + await new Promise((resolve) => setImmediate(resolve)); + const id = context.currentRequestId!.requestId; + const result = await dispatcher.cancelAction({ + protocolVersion: 1, + scopeId: input.scopeId, + operationId: id, + }); + expect(result.status).toBe("execution_uncertain"); + expect((await running).status).toBe("execution_uncertain"); + const next = dispatcher.executeAction(await request("read")); + await new Promise((resolve) => setImmediate(resolve)); + expect(entered).toHaveLength(1); + release!(); + expect((await next).status).toBe("completed"); + expect(entered).toHaveLength(2); + expect( + ( + await dispatcher.cancelAction({ + protocolVersion: 1, + scopeId: input.scopeId, + operationId: id, + }) + ).status, + ).toBe("execution_uncertain"); + }); + + it("supports trusted reconnect takeover but rejects stale and foreign facades", async () => { + const prompt = requirePrompt( + await dispatcher.executeAction(await request()), + ); + const ownerScope = scope; + const resumed = createDispatcherFromContext( + context, + "new-owner", + undefined, + () => ({ scope: ownerScope, canDiscoverSchema: () => true }), + ); + const foreign = createDispatcherFromContext( + context, + "foreign", + undefined, + () => ({ scope: {}, canDiscoverSchema: () => true }), + ); + const input = { + protocolVersion: 1 as const, + scopeId: prompt.scopeId, + operationId: prompt.operationId, + interactionId: prompt.interactionId, + response: { type: "confirmation" as const, approved: true }, + }; + active = false; + expect((await dispatcher.continueAction(input)).status).toBe("failed"); + expect((await foreign.continueAction(input)).status).toBe("failed"); + expect((await resumed.continueAction(input)).status).toBe("completed"); + expect(entered).toEqual(["original"]); + }); + + it("rebinds a denied-scope facade to the current lease before resuming each wait", async () => { + const logicalScope = scope; + const deniedScope = {}; + let current = true; + const original = createDispatcherFromContext( + context, + "old-lease", + undefined, + () => ({ + scope: current ? logicalScope : deniedScope, + canDiscoverSchema: () => current, + }), + ); + const prompt = requirePrompt( + await original.executeAction(await request("write", "yesNo")), + ); + current = false; + const incoming = createDispatcherFromContext( + context, + "new-lease", + undefined, + () => ({ + scope: logicalScope, + canDiscoverSchema: () => !current, + }), + ); + const continuation = { + protocolVersion: 1 as const, + scopeId: prompt.scopeId, + operationId: prompt.operationId, + interactionId: prompt.interactionId, + response: { type: "confirmation" as const, approved: true }, + }; + expect((await original.continueAction(continuation)).status).toBe( + "failed", + ); + const choice = requirePrompt( + await incoming.continueAction(continuation), + ); + expect(choice.prompt.type).toBe("yesNo"); + expect( + ( + await incoming.continueAction({ + ...continuation, + interactionId: choice.interactionId, + response: { type: "yesNo", value: true }, + }) + ).status, + ).toBe("completed"); + expect(entered).toEqual(["original"]); + expect(callbacks).toBe(1); + }); + + it.each([ + ["write", undefined, "user", "cancelled"], + ["read", "question", "no_clients", "execution_uncertain"], + ["read", "yesNo", "no_clients", "execution_uncertain"], + ] as const)( + "host cancellation handles %s/%s without legacy interaction entries", + async (action, mode, reason, status) => { + const prompt = requirePrompt( + await dispatcher.executeAction(await request(action, mode)), + ); + expect(context.requestQueue.getSnapshot().running).toMatchObject({ + requestId: prompt.operationId, + blockedOn: "interaction", + }); + // The host's existing supersession path performs these two steps + // even when its legacy interaction manager has no matching entry. + expect( + context.requestQueue.cancelRunning(prompt.operationId, reason), + ).toBe(true); + const controller = context.activeRequests.get(prompt.operationId); + expect(controller).toBeDefined(); + controller!.abort(); + expect( + context.requestQueue.cancelRunning(prompt.operationId, reason), + ).toBe(false); + const result = await dispatcher.cancelAction({ + protocolVersion: 1, + scopeId: prompt.scopeId, + operationId: prompt.operationId, + }); + expect(result.status).toBe(status); + expect(callbacks).toBe(0); + expect(broadcasts).toEqual([]); + expect( + (await dispatcher.executeAction(await request("read"))).status, + ).toBe("completed"); + }, + ); + + it("uses concrete owning-agent resolution without conversation memory or prior activity", async () => { + const memory = context.conversationMemory; + const search = + memory === undefined + ? undefined + : jest.spyOn(memory, "searchKnowledge"); + context.activityContext = { + appAgentName: "guarded", + activityName: "old", + context: { value: "past" }, + } as unknown as NonNullable; + const prompt = await dispatcher.executeAction(await request("resolve")); + expect(resolutions).toBe(0); + const result = await answer(prompt, { + type: "confirmation", + approved: true, + }); + expect(result.status).toBe("completed"); + expect(resolutions).toBe(1); + expect(entered).toEqual(["original"]); + expect(search?.mock.calls ?? []).toEqual([]); + search?.mockRestore(); + }); + + it("rechecks the contract after asynchronous entity preparation", async () => { + holdResolution = true; + const prompt = await dispatcher.executeAction(await request("resolve")); + const result = answer(prompt, { type: "confirmation", approved: true }); + for (let i = 0; i < 20 && resolutions === 0; i++) + await new Promise((resolve) => setImmediate(resolve)); + expect(resolutions).toBe(1); + const config = context.agents.getActionConfig("guarded"); + config.actionPolicies = { + ...config.actionPolicies, + resolve: { effects: "state-changing" }, + }; + release!(); + expect((await result).status).toBe("contract_stale"); + expect(entered).toEqual([]); + }); + + it("honors normal queue/controller cancellation of a blocked structured request", async () => { + const prompt = await dispatcher.executeAction(await request()); + await dispatcher.cancelCommand(prompt.operationId); + expect( + ( + await dispatcher.cancelAction({ + protocolVersion: 1, + scopeId: prompt.scopeId, + operationId: prompt.operationId, + }) + ).status, + ).toBe("cancelled"); + expect(entered).toEqual([]); + expect( + (await dispatcher.executeAction(await request("read"))).status, + ).toBe("completed"); + }); + + it("expires a prompt without selecting its affirmative default", async () => { + jest.useFakeTimers({ doNotFake: ["nextTick", "setImmediate"] }); + try { + const prompt = requirePrompt( + await dispatcher.executeAction( + await request("read", "question"), + ), + ); + await jest.advanceTimersByTimeAsync(10 * 60_000); + const result = await dispatcher.cancelAction({ + protocolVersion: 1, + scopeId: prompt.scopeId, + operationId: prompt.operationId, + }); + expect(result.status).toBe("execution_uncertain"); + expect(callbacks).toBe(0); + closeStructuredActions(context); + } finally { + jest.useRealTimers(); + } + }); + + it("bounds live operations at the existing queue capacity without evicting a prompt", async () => { + const input = await request(); + const first = requirePrompt(await dispatcher.executeAction(input)); + const queued = Array.from({ length: 99 }, () => + dispatcher.executeAction(input), + ); + expect((await dispatcher.executeAction(input)).status).toBe( + "unavailable", + ); + expect((await dispatcher.getQueueSnapshot()).queued).toHaveLength(99); + expect(entered).toEqual([]); + closeStructuredActions(context); + expect( + (await Promise.all(queued)).every( + (result) => result.status === "cancelled", + ), + ).toBe(true); + expect( + ( + await dispatcher.cancelAction({ + protocolVersion: 1, + scopeId: first.scopeId, + operationId: first.operationId, + }) + ).status, + ).not.toBe("completed"); + }); + + it("retains only the latest 100 terminal outcomes and never replays lost state", async () => { + const input = await request("read"); + const first = await dispatcher.executeAction(input); + for (let i = 0; i < 101; i++) await dispatcher.executeAction(input); + const result = await dispatcher.cancelAction({ + protocolVersion: 1, + scopeId: first.scopeId, + operationId: first.operationId, + }); + expect(result.status).toBe("execution_uncertain"); + if (result.status === "execution_uncertain") + expect(result.error.code).toBe("execution_state_lost"); + expect(entered).toHaveLength(102); + }); +}); diff --git a/ts/packages/dispatcher/rpc/src/dispatcherClient.ts b/ts/packages/dispatcher/rpc/src/dispatcherClient.ts index 3b0fcee870..8ab28babd4 100644 --- a/ts/packages/dispatcher/rpc/src/dispatcherClient.ts +++ b/ts/packages/dispatcher/rpc/src/dispatcherClient.ts @@ -217,6 +217,15 @@ export function createDispatcherRpcClient( async getActionContract(...args) { return rpc.invoke("getActionContract", ...args); }, + async executeAction(...args) { + return rpc.invoke("executeAction", ...args); + }, + async continueAction(...args) { + return rpc.invoke("continueAction", ...args); + }, + async cancelAction(...args) { + return rpc.invoke("cancelAction", ...args); + }, async respondToChoice(...args) { return rpc.invoke("respondToChoice", ...args); }, diff --git a/ts/packages/dispatcher/rpc/src/dispatcherServer.ts b/ts/packages/dispatcher/rpc/src/dispatcherServer.ts index 23a6cf663f..3915562a20 100644 --- a/ts/packages/dispatcher/rpc/src/dispatcherServer.ts +++ b/ts/packages/dispatcher/rpc/src/dispatcherServer.ts @@ -103,6 +103,9 @@ export function createDispatcherRpcServer( getActionContract: async (...args) => { return dispatcher.getActionContract(...args); }, + executeAction: async (...args) => dispatcher.executeAction(...args), + continueAction: async (...args) => dispatcher.continueAction(...args), + cancelAction: async (...args) => dispatcher.cancelAction(...args), respondToChoice: async (...args) => { return dispatcher.respondToChoice(...args); }, diff --git a/ts/packages/dispatcher/rpc/src/dispatcherTypes.ts b/ts/packages/dispatcher/rpc/src/dispatcherTypes.ts index 77480c7c5c..ea225caf35 100644 --- a/ts/packages/dispatcher/rpc/src/dispatcherTypes.ts +++ b/ts/packages/dispatcher/rpc/src/dispatcherTypes.ts @@ -13,6 +13,10 @@ import type { ActionIdentity, ActionSearchRequest, ActionSearchResult, + ExecuteActionRequest, + ContinueActionRequest, + CancelActionRequest, + StructuredActionExecutionResult, CancelResult, CommandCompletionResult, CommandResult, @@ -107,6 +111,16 @@ export type DispatcherInvokeFunctions = { getActionContract(identity: ActionIdentity): Promise; + executeAction( + request: ExecuteActionRequest, + ): Promise; + continueAction( + request: ContinueActionRequest, + ): Promise; + cancelAction( + request: CancelActionRequest, + ): Promise; + respondToChoice( choiceId: string, response: diff --git a/ts/packages/dispatcher/rpc/test/dispatcherRpc.spec.ts b/ts/packages/dispatcher/rpc/test/dispatcherRpc.spec.ts index 728a951a22..adec4ed970 100644 --- a/ts/packages/dispatcher/rpc/test/dispatcherRpc.spec.ts +++ b/ts/packages/dispatcher/rpc/test/dispatcherRpc.spec.ts @@ -12,6 +12,7 @@ import type { Dispatcher, QueuedRequest, SubmitResult, + StructuredActionExecutionResult, } from "@typeagent/dispatcher-types"; import { ServerStoppingError } from "@typeagent/dispatcher-types"; import type { PendingInteractionResponse } from "@typeagent/dispatcher-types"; @@ -76,6 +77,9 @@ function makeStubDispatcher(overrides: Partial = {}): Dispatcher & { getAgentSchemas: notImplemented("getAgentSchemas") as any, searchActions: notImplemented("searchActions"), getActionContract: notImplemented("getActionContract"), + executeAction: notImplemented("executeAction"), + continueAction: notImplemented("continueAction"), + cancelAction: notImplemented("cancelAction"), respondToChoice: notImplemented("respondToChoice") as any, getDisplayHistory: notImplemented("getDisplayHistory") as any, async cancelCommand(...args) { @@ -154,6 +158,93 @@ describe("dispatcher RPC lifecycle options", () => { }); describe("dispatcher RPC structured discovery", () => { + it("roundtrips structured execution, continuation and cancellation without losing result data", async () => { + const { serverChannel, clientChannel } = createChannelPair(); + const seen: unknown[] = []; + const result: StructuredActionExecutionResult = { + protocolVersion: 1, + scopeId: "scope", + operationId: "operation", + status: "completed", + output: ["Saved"], + results: [ + { + action: { + schemaName: "test", + actionName: "save", + parameters: { name: "item" }, + }, + result: { + displayContent: { + type: "html", + content: "Saved", + }, + entities: [ + { + name: "item", + type: ["Item"], + uniqueId: "stable", + }, + ], + resultEntity: { + name: "item", + type: ["Item"], + uniqueId: "stable", + }, + resultValue: { + songs: [{ id: "stable", title: "song" }], + }, + }, + }, + ], + }; + createDispatcherRpcServer( + makeStubDispatcher({ + executeAction: async (request) => { + seen.push(request); + return result; + }, + continueAction: async (request) => { + seen.push(request); + return result; + }, + cancelAction: async (request) => { + seen.push(request); + return result; + }, + }), + serverChannel, + ); + const { dispatcher } = createDispatcherRpcClient( + clientChannel, + undefined, + ); + const request = { + protocolVersion: 1 as const, + scopeId: "scope", + schemaName: "test", + actionName: "save", + fingerprint: "exact", + parameters: { name: "item" }, + }; + const continuation = { + protocolVersion: 1 as const, + scopeId: "scope", + operationId: "operation", + interactionId: "opaque", + response: { type: "confirmation" as const, approved: true }, + }; + const cancellation = { + protocolVersion: 1 as const, + scopeId: "scope", + operationId: "operation", + }; + expect(await dispatcher.executeAction(request)).toEqual(result); + expect(await dispatcher.continueAction(continuation)).toEqual(result); + expect(await dispatcher.cancelAction(cancellation)).toEqual(result); + expect(seen).toEqual([request, continuation, cancellation]); + }); + it("forwards exact identities and the complete versioned contract", async () => { const identity = { schemaName: "test.sub", actionName: "select" }; const summary = { diff --git a/ts/packages/dispatcher/types/src/dispatcher.ts b/ts/packages/dispatcher/types/src/dispatcher.ts index 40eb68dac7..3e58ff053b 100644 --- a/ts/packages/dispatcher/types/src/dispatcher.ts +++ b/ts/packages/dispatcher/types/src/dispatcher.ts @@ -23,6 +23,10 @@ import type { ActionIdentity, ActionSearchRequest, ActionSearchResult, + ExecuteActionRequest, + ContinueActionRequest, + CancelActionRequest, + StructuredActionExecutionResult, } from "./structuredAction.js"; export const DispatcherName = "dispatcher"; @@ -518,6 +522,16 @@ export interface Dispatcher { getActionContract(identity: ActionIdentity): Promise; + executeAction( + request: ExecuteActionRequest, + ): Promise; + continueAction( + request: ContinueActionRequest, + ): Promise; + cancelAction( + request: CancelActionRequest, + ): Promise; + /** * Respond to a pending choice from an agent. * @param choiceId the choice ID returned from ChoiceManager.registerChoice diff --git a/ts/packages/dispatcher/types/src/structuredAction.ts b/ts/packages/dispatcher/types/src/structuredAction.ts index 958995e5af..6534de57e7 100644 --- a/ts/packages/dispatcher/types/src/structuredAction.ts +++ b/ts/packages/dispatcher/types/src/structuredAction.ts @@ -1,7 +1,17 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { ActionEffect, ReadinessReport } from "@typeagent/agent-sdk"; +import type { + ActionEffect, + ActionResult, + PendingChoice, + QuestionForm, + QuestionFormResponse, + ReadinessReport, + TemplateSchema, + TypeAgentAction, +} from "@typeagent/agent-sdk"; +import type { TemplateEditConfig } from "./clientIO.js"; export const structuredActionProtocolVersion = 1; @@ -95,3 +105,94 @@ export type ActionContractResult = StructuredActionEnvelope & // Deliberately does not distinguish absent and unauthorized identities. | { status: "not-found" } ); + +export type ExecuteActionRequest = StructuredActionEnvelope & + ActionIdentity & { + fingerprint: string; + parameters?: Record; + }; + +export type StructuredActionPrompt = + | { + type: "confirmation"; + action: ExecuteActionRequest; + contract: ActionContract; + } + | { + type: "question"; + message: string; + choices: string[]; + defaultId?: number; + } + | Omit, "choiceId"> + | Omit, "choiceId"> + | Omit, "choiceId"> + | ({ type: "form" } & QuestionForm) + | { + type: "proposal"; + templateAgentName: string; + templateName: string; + schema: TemplateSchema; + data: unknown; + templates: TemplateEditConfig; + }; + +export type StructuredActionResponse = + | { type: "confirmation"; approved: boolean } + | { type: "question"; selected: number } + | { type: "yesNo"; value: boolean } + | { type: "multiChoice"; selected: number[] } + | { type: "pickRemember"; selected: number; remember: boolean } + | { type: "form"; value: QuestionFormResponse } + | { type: "proposal"; accepted: boolean; data?: unknown }; + +export type ContinueActionRequest = StructuredActionEnvelope & { + operationId: string; + interactionId: string; + response: StructuredActionResponse; +}; + +export type CancelActionRequest = StructuredActionEnvelope & { + operationId: string; + interactionId?: string; +}; + +export type StructuredActionError = { + code: + | "invalid_request" + | "invalid_response" + | "invalid_scope" + | "contract_stale" + | "unavailable" + | "interaction_consumed" + | "interaction_expired" + | "execution_state_lost" + | "queue_full" + | "server_stopping" + | "cancelled" + | "execution_failed"; + message: string; +}; + +export type StructuredActionExecutionResult = StructuredActionEnvelope & { + operationId: string; + output: string[]; + results: { action: TypeAgentAction; result: ActionResult }[]; +} & ( + | { + status: "requires_interaction"; + interactionId: string; + expiresAt: number; + prompt: StructuredActionPrompt; + } + | { status: "completed" } + | { + status: + | "failed" + | "cancelled" + | "contract_stale" + | "unavailable" + | "execution_uncertain"; + error: StructuredActionError; + } + ); From ca5607bb362400236a7946051cb25706fe165db2 Mon Sep 17 00:00:00 2001 From: George Ng Date: Thu, 10 Sep 2026 18:55:11 -0700 Subject: [PATCH 2/3] Cover parent flow text and data bindings without child replay Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../test/structuredActionExecution.spec.ts | 100 ++++++++++-------- 1 file changed, 53 insertions(+), 47 deletions(-) diff --git a/ts/packages/dispatcher/dispatcher/test/structuredActionExecution.spec.ts b/ts/packages/dispatcher/dispatcher/test/structuredActionExecution.spec.ts index f53d4bd5c0..9f1372493b 100644 --- a/ts/packages/dispatcher/dispatcher/test/structuredActionExecution.spec.ts +++ b/ts/packages/dispatcher/dispatcher/test/structuredActionExecution.spec.ts @@ -1077,56 +1077,62 @@ describe("real structured dispatcher execution", () => { ); }); - it("binds a flow step's own result and never replays its additional actions", async () => { - const registry = ( - context.agents as unknown as { - flowRegistry: Map; - } - ).flowRegistry; - registry.set("guarded/read", { - name: "read", - description: "Distinct parent and child results", - parameters: {}, - steps: [ - { - id: "first", - schemaName: "guarded", - actionName: "write", - parameters: { value: "first", mode: "parentChild" }, - }, - { - id: "last", - schemaName: "guarded", - actionName: "write", - parameters: { - value: "${first.data.ids.0}", - mode: "parentChild", + it.each([ + ["${first.data.ids.0}", "source-id"], + ["${first.text}", "Parent result"], + ])( + "binds a flow step's own %s without replaying its additional actions", + async (binding, expected) => { + const registry = ( + context.agents as unknown as { + flowRegistry: Map; + } + ).flowRegistry; + registry.set("guarded/read", { + name: "read", + description: "Distinct parent and child results", + parameters: {}, + steps: [ + { + id: "first", + schemaName: "guarded", + actionName: "write", + parameters: { value: "first", mode: "parentChild" }, }, - }, - ], - }); - let result = await dispatcher.executeAction(await request("read")); - for (const value of ["first", "child", "source-id", "child"]) { - const current = requirePrompt(result); - expect(current.prompt).toMatchObject({ - type: "confirmation", - action: { parameters: { value } }, + { + id: "last", + schemaName: "guarded", + actionName: "write", + parameters: { + value: binding, + mode: "parentChild", + }, + }, + ], }); - result = await answer(current, { - type: "confirmation", - approved: true, + let result = await dispatcher.executeAction(await request("read")); + for (const value of ["first", "child", expected, "child"]) { + const current = requirePrompt(result); + expect(current.prompt).toMatchObject({ + type: "confirmation", + action: { parameters: { value } }, + }); + result = await answer(current, { + type: "confirmation", + approved: true, + }); + } + expect(result.status).toBe("completed"); + expect(entered).toEqual(["first", "child", expected, "child"]); + const root = result.results.find( + ({ action }) => action.actionName === "read", + ); + expect(root?.result).toMatchObject({ + resultValue: { ids: ["source-id"] }, }); - } - expect(result.status).toBe("completed"); - expect(entered).toEqual(["first", "child", "source-id", "child"]); - const root = result.results.find( - ({ action }) => action.actionName === "read", - ); - expect(root?.result).toMatchObject({ - resultValue: { ids: ["source-id"] }, - }); - expect(root?.result).not.toHaveProperty("additionalActions"); - }); + expect(root?.result).not.toHaveProperty("additionalActions"); + }, + ); it("retains uncertainty and serialization while an uncooperative handler runs", async () => { const input = await request("read", "hold"); From eff2a883c63fb96f58909f64657eafefa4bc83eb Mon Sep 17 00:00:00 2001 From: George Ng Date: Thu, 10 Sep 2026 21:02:14 -0700 Subject: [PATCH 3/3] Stabilize process emit exports before Jest module evaluation Materialize inherited process.emit before signal-exit registration can add an enumerable export during Jest ESM linking. Cover inherited and instrumented own emit properties, wrapper cleanup and real signal-exit callback behavior without host-process side effects. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ts/tools/scripts/jestSetupSignalExit.cjs | 16 +++- .../scripts/test/jestSetupSignalExit.spec.mjs | 86 +++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 ts/tools/scripts/test/jestSetupSignalExit.spec.mjs diff --git a/ts/tools/scripts/jestSetupSignalExit.cjs b/ts/tools/scripts/jestSetupSignalExit.cjs index c0988fc9c1..37035a8a3e 100644 --- a/ts/tools/scripts/jestSetupSignalExit.cjs +++ b/ts/tools/scripts/jestSetupSignalExit.cjs @@ -22,7 +22,7 @@ // Pre-installing the emitter as a NON-enumerable property before any test // module loads makes signal-exit reuse it (its `if (process.__signal_exit_emitter__)` // branch) instead of creating an enumerable one. Because it is never -// enumerable, `Object.keys(process)` stays stable and the race cannot happen. +// enumerable, it cannot change the synthetic module's export names. const { EventEmitter } = require("node:events"); @@ -39,3 +39,17 @@ if (!process.__signal_exit_emitter__) { configurable: true, }); } + +// Registering a signal-exit callback also wraps process.emit. It is normally +// inherited from EventEmitter, so that assignment adds an enumerable own key. +// Materialize it before Jest snapshots the exports; keep it writable so real +// signal-exit registration and cleanup still work. Preserve an existing own +// property (including any instrumentation and its descriptor). +if (!Object.prototype.hasOwnProperty.call(process, "emit")) { + Object.defineProperty(process, "emit", { + value: process.emit, + enumerable: true, + writable: true, + configurable: true, + }); +} diff --git a/ts/tools/scripts/test/jestSetupSignalExit.spec.mjs b/ts/tools/scripts/test/jestSetupSignalExit.spec.mjs new file mode 100644 index 0000000000..0e6ee39b02 --- /dev/null +++ b/ts/tools/scripts/test/jestSetupSignalExit.spec.mjs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import test from "node:test"; +import { runInNewContext } from "node:vm"; + +const setupSource = readFileSync( + new URL("../jestSetupSignalExit.cjs", import.meta.url), + "utf8", +); +const dispatcherRequire = createRequire( + new URL( + "../../../packages/dispatcher/dispatcher/package.json", + import.meta.url, + ), +); +const lockfileRequire = createRequire( + dispatcherRequire.resolve("proper-lockfile"), +); +const signalExitPath = lockfileRequire.resolve("signal-exit"); +const signalExitSource = readFileSync(signalExitPath, "utf8"); +const signalExitRequire = createRequire(signalExitPath); + +for (const ownEmit of [false, true]) { + test(`keeps process exports stable with ${ownEmit ? "own" : "inherited"} emit`, () => { + // Never install signal handlers on the host process or invoke real exit. + const fakeProcess = Object.assign(new EventEmitter(), { + platform: process.platform, + pid: 1, + kill() { + assert.fail("Unexpected signal"); + }, + reallyExit() { + assert.fail("Unexpected exit"); + }, + }); + if (ownEmit) { + fakeProcess.emit = fakeProcess.emit; + } + const originalEmit = fakeProcess.emit; + const originalReallyExit = fakeProcess.reallyExit; + const sandbox = { + process: fakeProcess, + require: signalExitRequire, + module: { exports: {} }, + }; + sandbox.global = sandbox; + const setup = () => + runInNewContext(`(function () {\n${setupSource}\n})();`, sandbox); + setup(); + const emitter = fakeProcess.__signal_exit_emitter__; + const exportNames = Object.keys(fakeProcess); + const assertStableExports = () => + assert.deepEqual(Object.keys(fakeProcess), exportNames); + + runInNewContext(signalExitSource, sandbox); + let exitCalls = 0; + const remove = sandbox.module.exports(() => exitCalls++); + assert.notEqual(fakeProcess.emit, originalEmit); + assertStableExports(); + + // Loading setup again must preserve installed wrappers and the emitter. + const wrappedEmit = fakeProcess.emit; + setup(); + assert.equal(fakeProcess.emit, wrappedEmit); + assert.equal(fakeProcess.__signal_exit_emitter__, emitter); + assertStableExports(); + + let forwarded; + fakeProcess.on("fixture", (value) => (forwarded = value)); + fakeProcess.emit("fixture", 42); + assert.equal(forwarded, 42); + assert.equal(exitCalls, 0); + fakeProcess.emit("exit"); + assert.equal(exitCalls, 1); + remove(); + assert.equal(fakeProcess.emit, originalEmit); + assert.equal(fakeProcess.reallyExit, originalReallyExit); + assert.equal(emitter.count, 0); + assertStableExports(); + }); +}