Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions nodejs/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
53 changes: 53 additions & 0 deletions nodejs/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1475,6 +1475,49 @@ export type ErrorOccurredHandler = (
invocation: { sessionId: string }
) => Promise<ErrorOccurredHookOutput | void> | 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> | AgentStopHookOutput | void;
Comment on lines +1516 to +1519

/**
* Configuration for session hooks
*/
Expand Down Expand Up @@ -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;
Comment on lines +1575 to +1580
}

// ============================================================================
Expand Down
73 changes: 73 additions & 0 deletions nodejs/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading