From d1f6f7beee6d4dc1cdbff3df8b5180c1339acac2 Mon Sep 17 00:00:00 2001 From: Belal Taher Date: Wed, 22 Jul 2026 12:38:35 -0400 Subject: [PATCH] nodejs: Expose onAgentStop session hook The runtime already fires the top-level agent's `agentStop` hook and registers it for SDK callback sessions (REGISTERED_CALLBACK_EVENT_NAMES), with a working block/continue re-prompt loop, but the Node SDK never exposed it: SessionHooks had no `onAgentStop` and `_handleHooksInvoke` had no dispatch entry, so `agentStop` callbacks were silently dropped. Add the AgentStopHookInput/Output/Handler types, the `onAgentStop` field on SessionHooks, and the `agentStop` entry in the hook dispatcher. Returning `{ decision: "block", reason }` keeps the agent running with `reason` enqueued as a follow-up message (e.g. to remediate findings a handler surfaced); returning nothing lets the agent stop. This unblocks the Copilot cloud agent restoring its post-completion security-tool hooks (dependabot / secret scanning) on the proper platform hook rather than ad-hoc per-commit hooks. Note: parallel changes for the Python/Go/.NET SDKs are follow-ups. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2031e02b-7fe5-4075-9d75-eb20eb29f407 --- nodejs/src/session.ts | 1 + nodejs/src/types.ts | 53 +++++++++++++++++++++++++++ nodejs/test/client.test.ts | 73 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+) diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 1f71209de8..12a03cefae 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -1258,6 +1258,7 @@ export class CopilotSession { sessionStart: this.hooks.onSessionStart as GenericHandler | undefined, sessionEnd: this.hooks.onSessionEnd as GenericHandler | undefined, errorOccurred: this.hooks.onErrorOccurred as GenericHandler | undefined, + agentStop: this.hooks.onAgentStop as GenericHandler | undefined, }; const handler = handlerMap[hookType]; diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index e48c9064c1..21c5bc0ea3 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1475,6 +1475,49 @@ export type ErrorOccurredHandler = ( invocation: { sessionId: string } ) => Promise | ErrorOccurredHookOutput | void; +/** + * Input for the agent-stop hook. + * + * Fires for the top-level (main) agent when it reaches a natural terminal stop + * — i.e. the agent has gone idle without a pending non-terminal tool call and + * was not aborted or blocked by a rejected tool. (For sub-agents, the runtime + * fires a separate sub-agent stop lifecycle.) + */ +export interface AgentStopHookInput extends BaseHookInput { + /** Why the agent stopped (for example, `"end_turn"`). */ + stopReason?: string; + /** Path to the on-disk session transcript, when available. */ + transcriptPath?: string; + /** + * True when this stop is a re-entry triggered by a previous agent-stop + * `block` decision (Claude-compatible `stop_hook_active` semantics). Lets a + * handler avoid blocking indefinitely. + */ + stopHookActive?: boolean; +} + +/** + * Output for the agent-stop hook. + * + * Return `{ decision: "block", reason }` to keep the agent running: the + * `reason` is enqueued as a follow-up user message so the agent continues + * working (for example, to remediate findings surfaced by the hook). The + * runtime caps consecutive blocks to prevent runaway loops. Returning nothing + * (or omitting `decision`) lets the agent stop normally. + */ +export interface AgentStopHookOutput { + decision?: "block"; + reason?: string; +} + +/** + * Handler for the agent-stop hook. + */ +export type AgentStopHandler = ( + input: AgentStopHookInput, + invocation: { sessionId: string } +) => Promise | AgentStopHookOutput | void; + /** * Configuration for session hooks */ @@ -1525,6 +1568,16 @@ export interface SessionHooks { * Called when an error occurs */ onErrorOccurred?: ErrorOccurredHandler; + + /** + * Called when the top-level agent reaches a natural terminal stop (it went + * idle without pending work and was not aborted). Return + * `{ decision: "block", reason }` to keep the agent running with `reason` + * enqueued as a follow-up message — for example, to have the agent + * remediate findings the handler surfaced. Returning nothing lets the + * agent stop. + */ + onAgentStop?: AgentStopHandler; } // ============================================================================ diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 85d49fc328..f540d0d120 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -3193,6 +3193,79 @@ describe("CopilotClient", () => { output: { additionalContext: "context from failure hook" }, }); }); + + it("dispatches agentStop to onAgentStop and returns a block decision", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => stopClient(client)); + + const received: { input: any; invocation: any }[] = []; + const session = await client.createSession({ + onPermissionRequest: approveAll, + hooks: { + onAgentStop: async (input, invocation) => { + received.push({ input, invocation }); + return { decision: "block", reason: "2 vulnerabilities found; please fix" }; + }, + }, + }); + + const result = await (session as any)._handleHooksInvoke("agentStop", { + stopReason: "end_turn", + transcriptPath: "/tmp/transcript.jsonl", + timestamp: 1700000000000, + cwd: "/repo", + }); + + expect(received).toHaveLength(1); + expect(received[0].input).toEqual({ + stopReason: "end_turn", + transcriptPath: "/tmp/transcript.jsonl", + timestamp: new Date(1700000000000), + workingDirectory: "/repo", + }); + expect(received[0].invocation.sessionId).toBe(session.sessionId); + expect(result).toEqual({ + decision: "block", + reason: "2 vulnerabilities found; please fix", + }); + }); + + it("routes agentStop hooks.invoke JSON-RPC requests to onAgentStop", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => stopClient(client)); + + const received: { input: any }[] = []; + const session = await client.createSession({ + onPermissionRequest: approveAll, + hooks: { + onAgentStop: async (input) => { + received.push({ input }); + // Returning nothing lets the agent stop normally. + }, + }, + }); + + const response = await (client as any).clientGlobalHandlers.hooks.invoke({ + sessionId: session.sessionId, + hookType: "agentStop", + input: { + stopReason: "end_turn", + timestamp: 1700000000000, + cwd: "/repo", + }, + }); + + expect(received).toHaveLength(1); + expect(received[0].input).toEqual({ + stopReason: "end_turn", + timestamp: new Date(1700000000000), + workingDirectory: "/repo", + }); + // No decision returned — the SDK forwards an empty output envelope. + expect(response).toEqual({ output: undefined }); + }); }); describe("shutdown", () => {