Implement ClientIO interaction support on Agent-Server#2178
Implement ClientIO interaction support on Agent-Server#2178GeorgeNgMsft merged 14 commits intomainfrom
Conversation
Proposes converting the blocking askYesNo, proposeAction, and popupQuestion callbacks to a non-blocking deferred-promise pattern modeled on the existing requestChoice/respondToChoice mechanism. Covers protocol changes, DisplayLog schema extensions, pending interaction management, replay/reconnection strategy, and a phased implementation plan. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…cks (Phases 1-8) Convert askYesNo, proposeAction, and popupQuestion from blocking RPC callbacks to a non-blocking deferred-promise pattern with correlation IDs, enabling multi-client broadcast and resilient disconnect handling. - Define PendingInteractionRequest/Response types and discriminated union for askYesNo, proposeAction, popupQuestion interaction types - Add PendingInteractionManager with create/resolve/cancel lifecycle, per-connection cleanup, and timeout support - Extend DisplayLog with logPendingInteraction and logInteractionResolved for interaction persistence and replay - Add requestInteraction/interactionResolved fire-and-forget methods to ClientIO interface and wire through RPC client/server layers - Add respondToInteraction to Dispatcher interface with RPC support - Rewrite SharedDispatcher to use deferred promises instead of blocking callbacks, with broadcast to all clients and first-responder resolution - Add requestInteraction/interactionResolved stubs to all ClientIO implementations (shell, CLI, browser extension, console, MCP, tests) - Add @typeagent/dispatcher-types dependency to agent-server and protocol Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…b.com/microsoft/TypeAgent into dev/georgeng/async-clientio-callbacks
There was a problem hiding this comment.
Pull request overview
This PR introduces an async “deferred interaction” mechanism for ClientIO prompts in agent-server mode, so server-side command execution is no longer coupled to a single blocking RPC invoke call and can survive disconnect/reconnect scenarios.
Changes:
- Adds new dispatcher-types for pending interaction requests/responses and extends
ClientIO/DispatcherAPIs to supportrequestInteraction+respondToInteraction. - Implements server-side tracking/logging of pending interactions via
PendingInteractionManagerand newDisplayLogentry types. - Wires new RPC surface area and propagates pending interactions to clients on session join.
Reviewed changes
Copilot reviewed 29 out of 30 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| ts/pnpm-lock.yaml | Adds workspace dependency links for @typeagent/dispatcher-types. |
| ts/packages/shell/src/renderer/src/main.ts | Adds ClientIO stubs for deferred interaction events (currently no-op). |
| ts/packages/dispatcher/types/src/pendingInteraction.ts | New shared types for pending interaction request/response payloads. |
| ts/packages/dispatcher/types/src/index.ts | Re-exports pending interaction types. |
| ts/packages/dispatcher/types/src/displayLogEntry.ts | Adds pending-interaction and interaction-resolved log entry types. |
| ts/packages/dispatcher/types/src/dispatcher.ts | Adds respondToInteraction to the Dispatcher interface. |
| ts/packages/dispatcher/types/src/clientIO.ts | Adds requestInteraction and interactionResolved to ClientIO. |
| ts/packages/dispatcher/rpc/src/dispatcherTypes.ts | Exposes respondToInteraction through dispatcher RPC invoke functions. |
| ts/packages/dispatcher/rpc/src/dispatcherServer.ts | Implements RPC server handler for respondToInteraction. |
| ts/packages/dispatcher/rpc/src/dispatcherClient.ts | Implements RPC client call for respondToInteraction. |
| ts/packages/dispatcher/rpc/src/clientIOTypes.ts | Adds new ClientIO call-function typings for deferred interactions. |
| ts/packages/dispatcher/rpc/src/clientIOServer.ts | Wires new ClientIO call handlers on the RPC server side. |
| ts/packages/dispatcher/rpc/src/clientIOClient.ts | Wires new ClientIO call senders on the RPC client side. |
| ts/packages/dispatcher/nodeProviders/test/provider.spec.ts | Updates test ClientIO stub to satisfy the extended interface. |
| ts/packages/dispatcher/dispatcher/src/internal.ts | Exposes PendingInteractionManager via internal exports. |
| ts/packages/dispatcher/dispatcher/src/helpers/console.ts | Adds no-op stubs for deferred interactions in console ClientIO. |
| ts/packages/dispatcher/dispatcher/src/displayLog.ts | Adds logging helpers for pending interactions and their resolution. |
| ts/packages/dispatcher/dispatcher/src/dispatcher.ts | Adds base respondToInteraction method (throws by default). |
| ts/packages/dispatcher/dispatcher/src/context/pendingInteractionManager.ts | New manager to store deferred promises and handle cancel/resolve. |
| ts/packages/dispatcher/dispatcher/src/context/interactiveIO.ts | Updates nullClientIO to include the new methods. |
| ts/packages/commandExecutor/src/commandServer.ts | Updates MCP ClientIO stub to include the new methods. |
| ts/packages/cli/src/enhancedConsole.ts | Adds no-op stubs for deferred interactions in CLI ClientIO. |
| ts/packages/agentServer/server/src/sharedDispatcher.ts | Implements deferred interaction flow in server shared dispatcher. |
| ts/packages/agentServer/server/src/sessionManager.ts | Returns pending interactions on session join. |
| ts/packages/agentServer/server/src/server.ts | Includes pending interactions in join-session response payload. |
| ts/packages/agentServer/server/package.json | Adds dependency on @typeagent/dispatcher-types. |
| ts/packages/agentServer/protocol/src/protocol.ts | Extends JoinSessionResult with optional pendingInteractions. |
| ts/packages/agentServer/protocol/package.json | Adds dependency on @typeagent/dispatcher-types. |
| ts/packages/agentServer/docs/async-clientio-design.md | Adds design documentation for the deferred interaction pattern. |
| ts/packages/agents/browser/src/extension/serviceWorker/dispatcherConnection.ts | Adds no-op stubs for deferred interactions in extension ClientIO. |
Files not reviewed (1)
- ts/pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…b.com/microsoft/TypeAgent into dev/georgeng/async-clientio-callbacks
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 31 out of 32 changed files in this pull request and generated 10 comments.
Files not reviewed (1)
- ts/pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } | ||
| }); | ||
| }, | ||
| async respondToInteraction( |
There was a problem hiding this comment.
I am wondering if this needs to be exposed in the API, can it all be hidden by the protocol?
There was a problem hiding this comment.
Technically it could, if we moved respondToInteraction to use rpc.invoke (await) instead of rpc.send (fire and forget). However, the reason I chose this is because with the PendingInteractionManager and use of the FOF requestInteraction, we can actually extend functionality to allow "stashing" of interactions in the future.
As per our offline discussion, if the server asks the user a question, the next response can be interpreted by the LLM to determine if the user is responding to the question (and making a call to respondToInteraction) versus implicitly ignoring the question for now (sending a new command or following a different path of thought). These are things that happen naturally in a conversation such as coming back to a question later.
Having this respondToInteraction() helps partially enable the scenario above, though it would take significantly more work to design and implement that interaction described above. I think it's a great future work item though
| connectionId: result.connectionId, | ||
| sessionId, | ||
| name: result.name, | ||
| pendingInteractions: |
There was a problem hiding this comment.
Doesn't the display log replay sufficient?
There was a problem hiding this comment.
Technically, you could retrieve the last question asked from the previous message in display log. However, that's adding additional (potentially fragile) extraction logic on an ordered stream of events. In addition, potential future capabilities like interruptions or moving operations into the background could make extracting from display log much more difficult.
I think it's cleaner to just store pending interactions in this array (which is also already filtered for the specific connection). No need to run any parsing logic.
| if (notified === 0) { | ||
| // No clients to handle it — resolve immediately without logging | ||
| // a pending entry (which would have no corresponding resolution). | ||
| return defaultValue ?? false; |
There was a problem hiding this comment.
That would cause intermittent disconnection to miss some interactions? Probably want to keep it pending until either user dismiss or the dispatcher session is closing down?
There was a problem hiding this comment.
That is a great callout, this scenario will come into play if the user sends a command, disconnects, and the server sends a question back. The user isn't currently able to reconnect and respond.
In a scenario where the user was connected when the server sends the questions, but then disconnects, they will preserve the ability to respond until the timeout.
I will match the behavior in both scenarios so that the user can always respond until the timeout. I think setting a timeout is good policy for now, and something we can change later if we want to allow a question to hang indefinitely (Claude, I believe operates on a timeout as well).
| context.displayLog.logPendingInteraction(request); | ||
| context.displayLog.saveQueued(); | ||
|
|
||
| return pendingInteractions.create<unknown>( |
There was a problem hiding this comment.
Instead of a timeout, at the server level, I think letting both the user or the requester (i.e. agent) able to explicit cancel will give them more control over the experience.
There was a problem hiding this comment.
The user can always cancel by simply responding, I think that part is fine. Though exposing an api to allow the agent to explicitly cancel (in other potential scenarios) is definitely useful.
I prefer keeping a configurable timeout because it is good to guarantee completion of an interaction and resource cleanup.
Both aren't mutually exclusive capabilities and we can tune them as we go.
| } | ||
|
|
||
| // Log only after we know at least one client was notified | ||
| context.displayLog.logPendingInteraction(request); |
There was a problem hiding this comment.
we don't log the message if it is not notified? Wouldn't that be missed on replay?
There was a problem hiding this comment.
This has been revaluated in the follow up PR along with not broadcasting on 0 users.
| // respond via respondToInteraction resolves the promise. | ||
|
|
||
| askYesNo: async (requestId, message, defaultValue?) => { | ||
| const interactionId = randomUUID(); |
There was a problem hiding this comment.
if we log in up front, can we just use the sequence number?
There was a problem hiding this comment.
I think this goes back to the conversation about having PendingInteractionRequests versus parsing the logs. I think maintaining a queue of PendingInteractionRequests is better structure that allows for more extensibility than log extraction (especially for the long run).
Resolved conflicts in favour of explicit cancelInteraction approach over the auto-cancel-on-disconnect (cancelByConnection) approach from #2178. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This PR addresses comments in #2178 **Changes :** Explicit client-initiated cancellation (cancelInteraction) instead of auto-cancelling interactions on client disconnect. PR #2178 (already in main) implemented the async deferred-promise pattern with cancelByConnection — when a client disconnects, all its pending interactions are immediately cancelled. This branch replaces that with: - Interactions survive disconnect — they remain pending until timeout (10 min) or an explicit call, allowing a reconnecting client to still respond - New cancelInteraction(interactionId) method on Dispatcher — clients cancel explicitly (e.g. user dismisses UI), not implicitly on disconnect - Always-log invariant — logPendingInteraction is called unconditionally, even when no clients are connected at broadcast time, so the interaction appears in DisplayLog for session replay --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Async ClientIO Callbacks: Non-Blocking Deferred-Promise Pattern
Three ClientIO methods — askYesNo, proposeAction, and popupQuestion — were previously implemented as blocking RPC invoke calls in the agent server. This meant the server-side command processing was tied to a specific client connection: a disconnect would permanently hang the operation, reconnecting clients couldn't recover pending questions, and popupQuestion was entirely disabled in server mode.
This PR converts all three to a non-blocking deferred-promise pattern:
The server generates a unique interactionId, logs the pending interaction to the DisplayLog, stores a deferred Promise in a new PendingInteractionManager, and sends a fire-and-forget requestInteraction notification to the client(s). Command processing suspends at await as before, but is no longer coupled to the connection.
The client responds via a new respondToInteraction dispatcher method, which resolves the deferred promise and resumes processing exactly where it left off.
On client disconnect, pending interactions owned by that connection are cancelled (rejecting their promises), and all callers treat this as a cancellation of the operation.
popupQuestion is now fully supported in server mode via broadcast to all connected clients, with first-responder resolution.
Pending interactions are logged to DisplayLog so they can be replayed to reconnecting or newly joining clients.
Existing callers (askYesNoWithContext, confirmTranslation, sessionContext.popupQuestion) required no changes — the ClientIO interface signatures are unchanged.