From d1f6f7beee6d4dc1cdbff3df8b5180c1339acac2 Mon Sep 17 00:00:00 2001 From: Belal Taher Date: Wed, 22 Jul 2026 12:38:35 -0400 Subject: [PATCH 1/7] 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", () => { From 4eaf75d19130c31b153a7d6c416d9a4992ba3cae Mon Sep 17 00:00:00 2001 From: Belal Taher Date: Mon, 27 Jul 2026 13:43:44 -0400 Subject: [PATCH 2/7] Expose AgentStop hook across SDKs Add AgentStop types and dispatch support for Python, Go, .NET, Rust, and Java, and normalize the Node stop_hook_active wire field. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4f4d61ce-6d2f-4b72-a2da-52f60300df59 --- docs/hooks/hooks-overview.md | 2 + docs/hooks/session-lifecycle.md | 34 ++++ dotnet/src/Client.cs | 6 +- dotnet/src/Session.cs | 7 + dotnet/src/Types.cs | 66 +++++++ dotnet/test/Unit/SerializationTests.cs | 42 +++++ go/client.go | 6 +- go/session.go | 11 ++ go/session_test.go | 57 +++++++ go/types.go | 43 +++++ .../com/github/copilot/CopilotSession.java | 11 ++ .../github/copilot/rpc/AgentStopHandler.java | 28 +++ .../copilot/rpc/AgentStopHookInput.java | 161 ++++++++++++++++++ .../copilot/rpc/AgentStopHookOutput.java | 65 +++++++ .../com/github/copilot/rpc/SessionHooks.java | 27 ++- .../github/copilot/SessionHandlerTest.java | 27 +++ nodejs/src/session.ts | 15 +- nodejs/test/client.test.ts | 2 + python/copilot/__init__.py | 6 + python/copilot/session.py | 28 +++ python/test_client.py | 39 +++++ rust/src/hooks.rs | 110 ++++++++++++ 22 files changed, 785 insertions(+), 8 deletions(-) create mode 100644 java/src/main/java/com/github/copilot/rpc/AgentStopHandler.java create mode 100644 java/src/main/java/com/github/copilot/rpc/AgentStopHookInput.java create mode 100644 java/src/main/java/com/github/copilot/rpc/AgentStopHookOutput.java diff --git a/docs/hooks/hooks-overview.md b/docs/hooks/hooks-overview.md index ad9a2eb52c..6de4c3e72c 100644 --- a/docs/hooks/hooks-overview.md +++ b/docs/hooks/hooks-overview.md @@ -19,6 +19,7 @@ Hooks allow you to intercept and customize the behavior of Copilot sessions at k | [`onSessionStart`](./session-lifecycle.md#session-start) | Session begins | Add context, configure session | | [`onSessionEnd`](./session-lifecycle.md#session-end) | Session ends | Cleanup, analytics | | [`onErrorOccurred`](./error-handling.md) | Error happens | Custom error handling | +| [`onAgentStop`](./session-lifecycle.md#agent-stop) | Top-level agent naturally stops | Validate completion or request another turn | ## Quick start @@ -263,6 +264,7 @@ const session = await client.createSession({ * **[Post-Tool Use Hook](./post-tool-use.md)** - Transform tool results * **[User Prompt Submitted Hook](./user-prompt-submitted.md)** - Modify user prompts * **[Session Lifecycle Hooks](./session-lifecycle.md)** - Session start and end +* **[Agent Stop Hook](./session-lifecycle.md#agent-stop)** - Validate completion before the agent stops * **[Error Handling Hook](./error-handling.md)** - Custom error handling ## See also diff --git a/docs/hooks/session-lifecycle.md b/docs/hooks/session-lifecycle.md index 21c9bdcf6b..843f216086 100644 --- a/docs/hooks/session-lifecycle.md +++ b/docs/hooks/session-lifecycle.md @@ -540,6 +540,40 @@ Session Summary: }); ``` +## Agent stop hook {#agent-stop} + +The agent stop hook runs when the top-level agent naturally reaches the end of a turn. It is separate from `onSessionEnd`: the session remains active, and the hook can request another agent turn. + +| Language | Handler | +|----------|---------| +| Node.js / TypeScript | `onAgentStop` | +| Python | `on_agent_stop` | +| Go | `OnAgentStop` | +| .NET | `OnAgentStop` | +| Rust | `on_agent_stop` | +| Java | `setOnAgentStop` | + +### Input + +| Field | Type | Description | +|-------|------|-------------| +| `stopReason` | string \| undefined | Why the agent stopped, such as `end_turn` | +| `transcriptPath` | string \| undefined | Path to the on-disk session transcript | +| `stopHookActive` | boolean \| undefined | Whether this turn was already forced to continue by an earlier block decision | + +### Output + +Return no output to let the agent stop. Return a block decision to enqueue another user message and continue: + +```json +{ + "decision": "block", + "reason": "Run the final validation and fix any failures." +} +``` + +Use `stopHookActive` to avoid repeatedly blocking an agent that has already continued because of this hook. The runtime also caps consecutive block decisions. + ## Best practices 1. **Keep `onSessionStart` fast** - Users are waiting for the session to be ready. diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index f75b75659e..c8d83dfee2 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -1095,7 +1095,8 @@ public async Task CreateSessionAsync(SessionConfig config, Cance config.Hooks.OnUserPromptSubmitted != null || config.Hooks.OnSessionStart != null || config.Hooks.OnSessionEnd != null || - config.Hooks.OnErrorOccurred != null); + config.Hooks.OnErrorOccurred != null || + config.Hooks.OnAgentStop != null); var (wireSystemMessage, transformCallbacks) = ExtractTransformCallbacks(config.SystemMessage); @@ -1320,7 +1321,8 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes config.Hooks.OnUserPromptSubmitted != null || config.Hooks.OnSessionStart != null || config.Hooks.OnSessionEnd != null || - config.Hooks.OnErrorOccurred != null); + config.Hooks.OnErrorOccurred != null || + config.Hooks.OnAgentStop != null); var (wireSystemMessage, transformCallbacks) = ExtractTransformCallbacks(config.SystemMessage); diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index 04306b7f62..de25b78256 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -1620,6 +1620,11 @@ internal void RegisterHooks(SessionHooks hooks) JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.ErrorOccurredHookInput)!, invocation) : null, + "agentStop" => hooks.OnAgentStop != null + ? await hooks.OnAgentStop( + JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.AgentStopHookInput)!, + invocation) + : null, _ => null }; } @@ -1987,6 +1992,8 @@ internal void ThrowIfDisposed() AllowOutOfOrderMetadataProperties = true, NumberHandling = JsonNumberHandling.AllowReadingFromString, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] + [JsonSerializable(typeof(AgentStopHookInput))] + [JsonSerializable(typeof(AgentStopHookOutput))] [JsonSerializable(typeof(AutoModeSwitchRequest))] [JsonSerializable(typeof(AutoModeSwitchResponse))] [JsonSerializable(typeof(Dictionary))] diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 127cf40305..6c9bebf25e 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -1871,6 +1871,67 @@ public sealed class ErrorOccurredHookOutput public string? UserNotification { get; set; } } +/// +/// Input for an agent-stop hook. +/// +public sealed class AgentStopHookInput +{ + /// + /// The runtime session ID of the session that triggered the hook. + /// + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + + /// + /// Unix timestamp in milliseconds when the agent stopped. + /// + [JsonPropertyName("timestamp")] + [JsonConverter(typeof(UnixMillisecondsDateTimeOffsetConverter))] + public DateTimeOffset Timestamp { get; set; } + + /// + /// Current working directory of the session. + /// + [JsonPropertyName("cwd")] + public string WorkingDirectory { get; set; } = string.Empty; + + /// + /// Reason the agent stopped. + /// + [JsonPropertyName("stopReason")] + public string? StopReason { get; set; } + + /// + /// Path to the on-disk session transcript. + /// + [JsonPropertyName("transcriptPath")] + public string? TranscriptPath { get; set; } + + /// + /// Whether this stop follows a previous block decision from the hook. + /// + [JsonPropertyName("stop_hook_active")] + public bool? StopHookActive { get; set; } +} + +/// +/// Output for an agent-stop hook. +/// +public sealed class AgentStopHookOutput +{ + /// + /// Set to "block" to keep the agent running. + /// + [JsonPropertyName("decision")] + public string? Decision { get; set; } + + /// + /// Follow-up instruction supplied when the stop is blocked. + /// + [JsonPropertyName("reason")] + public string? Reason { get; set; } +} + /// /// Hook handlers configuration for a session. /// @@ -1917,6 +1978,11 @@ public sealed class SessionHooks /// Handler called when an error occurs. /// public Func>? OnErrorOccurred { get; set; } + + /// + /// Handler called when the top-level agent reaches a natural stop. + /// + public Func>? OnAgentStop { get; set; } } /// diff --git a/dotnet/test/Unit/SerializationTests.cs b/dotnet/test/Unit/SerializationTests.cs index 717fefb199..6aaee01e77 100644 --- a/dotnet/test/Unit/SerializationTests.cs +++ b/dotnet/test/Unit/SerializationTests.cs @@ -790,6 +790,48 @@ public void PermissionDecision_SerializesBaseDiscriminator_WithSdkOptions() Assert.Equal("approve-once", document.RootElement.GetProperty("kind").GetString()); } + [Fact] + public void AgentStopHookInput_DeserializesWireFields_WithSdkOptions() + { + var options = GetSerializerOptions(); + var input = JsonSerializer.Deserialize( + """ + { + "sessionId": "session-1", + "timestamp": 1700000000000, + "cwd": "/repo", + "stopReason": "end_turn", + "transcriptPath": "/tmp/transcript.jsonl", + "stop_hook_active": true + } + """, + options); + + Assert.NotNull(input); + Assert.Equal("session-1", input.SessionId); + Assert.Equal("/repo", input.WorkingDirectory); + Assert.Equal("end_turn", input.StopReason); + Assert.Equal("/tmp/transcript.jsonl", input.TranscriptPath); + Assert.True(input.StopHookActive); + Assert.Equal(DateTimeOffset.FromUnixTimeMilliseconds(1700000000000), input.Timestamp); + } + + [Fact] + public void AgentStopHookOutput_SerializesBlockDecision_WithSdkOptions() + { + var options = GetSerializerOptions(); + var output = new AgentStopHookOutput + { + Decision = "block", + Reason = "finish the remaining work" + }; + + var json = JsonSerializer.SerializeToElement(output, options); + + Assert.Equal("block", json.GetProperty("decision").GetString()); + Assert.Equal("finish the remaining work", json.GetProperty("reason").GetString()); + } + [Fact] public void HooksInvokeResponse_SerializesPreMcpToolCallHookOutput_WithMetaToUse() { diff --git a/go/client.go b/go/client.go index f243268aa4..0d07e21f54 100644 --- a/go/client.go +++ b/go/client.go @@ -870,7 +870,8 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses config.Hooks.OnUserPromptSubmitted != nil || config.Hooks.OnSessionStart != nil || config.Hooks.OnSessionEnd != nil || - config.Hooks.OnErrorOccurred != nil) { + config.Hooks.OnErrorOccurred != nil || + config.Hooks.OnAgentStop != nil) { req.Hooks = Bool(true) } if config.OnPermissionRequest != nil { @@ -1151,7 +1152,8 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, config.Hooks.OnUserPromptSubmitted != nil || config.Hooks.OnSessionStart != nil || config.Hooks.OnSessionEnd != nil || - config.Hooks.OnErrorOccurred != nil) { + config.Hooks.OnErrorOccurred != nil || + config.Hooks.OnAgentStop != nil) { req.Hooks = Bool(true) } req.WorkingDirectory = config.WorkingDirectory diff --git a/go/session.go b/go/session.go index c4e742e906..3059e3b3ae 100644 --- a/go/session.go +++ b/go/session.go @@ -808,6 +808,17 @@ func (s *Session) handleHooksInvoke(hookType string, rawInput json.RawMessage) ( return nil, fmt.Errorf("invalid hook input: %w", err) } return hooks.OnErrorOccurred(input, invocation) + + case "agentStop": + if hooks.OnAgentStop == nil { + return nil, nil + } + var input AgentStopHookInput + if err := json.Unmarshal(rawInput, &input); err != nil { + return nil, fmt.Errorf("invalid hook input: %w", err) + } + return hooks.OnAgentStop(input, invocation) + default: return nil, nil } diff --git a/go/session_test.go b/go/session_test.go index d34c34233b..9c5f4df8c9 100644 --- a/go/session_test.go +++ b/go/session_test.go @@ -1098,6 +1098,63 @@ func TestSession_PostToolUseFailureHook(t *testing.T) { }) } +func TestSession_AgentStopHook(t *testing.T) { + session, cleanup := newTestSession() + defer cleanup() + + var captured AgentStopHookInput + session.registerHooks(&SessionHooks{ + OnAgentStop: func(input AgentStopHookInput, invocation HookInvocation) (*AgentStopHookOutput, error) { + captured = input + if invocation.SessionID != session.SessionID { + t.Errorf("expected invocation session ID %q, got %q", session.SessionID, invocation.SessionID) + } + return &AgentStopHookOutput{ + Decision: "block", + Reason: "finish the remaining work", + }, nil + }, + }) + + raw := json.RawMessage(`{ + "sessionId": "sess-1", + "timestamp": 1700000000, + "cwd": "/work", + "stopReason": "end_turn", + "transcriptPath": "/tmp/transcript.jsonl", + "stop_hook_active": true + }`) + output, err := session.handleHooksInvoke("agentStop", raw) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if captured.SessionID != "sess-1" { + t.Errorf("expected sessionId 'sess-1', got %q", captured.SessionID) + } + if captured.StopReason != "end_turn" { + t.Errorf("expected stopReason 'end_turn', got %q", captured.StopReason) + } + if captured.TranscriptPath != "/tmp/transcript.jsonl" { + t.Errorf("expected transcriptPath '/tmp/transcript.jsonl', got %q", captured.TranscriptPath) + } + if !captured.StopHookActive { + t.Error("expected StopHookActive to be true") + } + if !captured.Timestamp.Equal(time.UnixMilli(1700000000)) { + t.Errorf("expected timestamp %v, got %v", time.UnixMilli(1700000000), captured.Timestamp) + } + if captured.WorkingDirectory != "/work" { + t.Errorf("expected WorkingDirectory '/work', got %q", captured.WorkingDirectory) + } + out, ok := output.(*AgentStopHookOutput) + if !ok { + t.Fatalf("expected *AgentStopHookOutput, got %T", output) + } + if out.Decision != "block" || out.Reason != "finish the remaining work" { + t.Errorf("unexpected output: %#v", out) + } +} + func TestSession_HookForwardCompatibility(t *testing.T) { t.Run("unknown hook type returns nil without error when known hooks are registered", func(t *testing.T) { session, cleanup := newTestSession() diff --git a/go/types.go b/go/types.go index d97fab9116..ffdacb322c 100644 --- a/go/types.go +++ b/go/types.go @@ -806,6 +806,48 @@ type ErrorOccurredHookOutput struct { // ErrorOccurredHandler handles error-occurred hook invocations type ErrorOccurredHandler func(input ErrorOccurredHookInput, invocation HookInvocation) (*ErrorOccurredHookOutput, error) +// AgentStopHookInput is the input for an agent-stop hook. +type AgentStopHookInput struct { + SessionID string `json:"sessionId"` + Timestamp time.Time `json:"-"` + WorkingDirectory string `json:"cwd"` + StopReason string `json:"stopReason,omitempty"` + TranscriptPath string `json:"transcriptPath,omitempty"` + StopHookActive bool `json:"stop_hook_active,omitempty"` +} + +// MarshalJSON implements json.Marshaler, emitting Timestamp as Unix milliseconds. +func (h AgentStopHookInput) MarshalJSON() ([]byte, error) { + type alias AgentStopHookInput + return json.Marshal(&struct { + Timestamp int64 `json:"timestamp"` + alias + }{Timestamp: h.Timestamp.UnixMilli(), alias: alias(h)}) +} + +// UnmarshalJSON implements json.Unmarshaler, parsing Timestamp from Unix milliseconds. +func (h *AgentStopHookInput) UnmarshalJSON(data []byte) error { + type alias AgentStopHookInput + aux := &struct { + Timestamp int64 `json:"timestamp"` + *alias + }{alias: (*alias)(h)} + if err := json.Unmarshal(data, aux); err != nil { + return err + } + h.Timestamp = time.UnixMilli(aux.Timestamp) + return nil +} + +// AgentStopHookOutput is the output for an agent-stop hook. +type AgentStopHookOutput struct { + Decision string `json:"decision,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// AgentStopHandler handles agent-stop hook invocations. +type AgentStopHandler func(input AgentStopHookInput, invocation HookInvocation) (*AgentStopHookOutput, error) + // PreMCPToolCallHookInput is the input for a pre-mcp-tool-call hook type PreMCPToolCallHookInput struct { SessionID string `json:"sessionId"` @@ -863,6 +905,7 @@ type SessionHooks struct { OnSessionStart SessionStartHandler OnSessionEnd SessionEndHandler OnErrorOccurred ErrorOccurredHandler + OnAgentStop AgentStopHandler OnPreMCPToolCall PreMCPToolCallHandler } diff --git a/java/src/main/java/com/github/copilot/CopilotSession.java b/java/src/main/java/com/github/copilot/CopilotSession.java index 4826b3309f..b8b253de2e 100644 --- a/java/src/main/java/com/github/copilot/CopilotSession.java +++ b/java/src/main/java/com/github/copilot/CopilotSession.java @@ -78,6 +78,7 @@ import com.github.copilot.rpc.ElicitationSchema; import com.github.copilot.rpc.BearerTokenProvider; import com.github.copilot.rpc.GetMessagesResponse; +import com.github.copilot.rpc.AgentStopHookInput; import com.github.copilot.rpc.HookInvocation; import com.github.copilot.rpc.InputOptions; import com.github.copilot.rpc.MessageOptions; @@ -1880,6 +1881,16 @@ CompletableFuture handleHooksInvoke(String hookType, JsonNode input) { return endResult.thenApply(output -> (Object) output); } break; + case "agentStop" : + if (hooks.getOnAgentStop() != null) { + AgentStopHookInput stopInput = MAPPER.treeToValue(input, AgentStopHookInput.class); + var stopResult = hooks.getOnAgentStop().handle(stopInput, invocation); + if (stopResult == null) { + return CompletableFuture.completedFuture(null); + } + return stopResult.thenApply(output -> (Object) output); + } + break; default : LOG.fine("Unhandled hook type: " + hookType); } diff --git a/java/src/main/java/com/github/copilot/rpc/AgentStopHandler.java b/java/src/main/java/com/github/copilot/rpc/AgentStopHandler.java new file mode 100644 index 0000000000..7f15776054 --- /dev/null +++ b/java/src/main/java/com/github/copilot/rpc/AgentStopHandler.java @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import java.util.concurrent.CompletableFuture; + +/** + * Handler for agent-stop hooks. + * + * @since 1.0.9 + */ +@FunctionalInterface +public interface AgentStopHandler { + + /** + * Handles an agent-stop hook invocation. + * + * @param input + * the hook input + * @param invocation + * context information about the invocation + * @return a future that resolves with the hook output, or {@code null} to let + * the agent stop + */ + CompletableFuture handle(AgentStopHookInput input, HookInvocation invocation); +} diff --git a/java/src/main/java/com/github/copilot/rpc/AgentStopHookInput.java b/java/src/main/java/com/github/copilot/rpc/AgentStopHookInput.java new file mode 100644 index 0000000000..fceea8b72c --- /dev/null +++ b/java/src/main/java/com/github/copilot/rpc/AgentStopHookInput.java @@ -0,0 +1,161 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Input for an agent-stop hook. + * + * @since 1.0.9 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class AgentStopHookInput { + + @JsonProperty("sessionId") + private String sessionId; + + @JsonProperty("timestamp") + private long timestamp; + + @JsonProperty("cwd") + private String cwd; + + @JsonProperty("stopReason") + private String stopReason; + + @JsonProperty("transcriptPath") + private String transcriptPath; + + @JsonProperty("stop_hook_active") + private Boolean stopHookActive; + + /** + * Gets the runtime session ID of the session that triggered the hook. + * + * @return the session ID + */ + public String getSessionId() { + return sessionId; + } + + /** + * Sets the runtime session ID of the session that triggered the hook. + * + * @param sessionId + * the session ID + * @return this instance for method chaining + */ + public AgentStopHookInput setSessionId(String sessionId) { + this.sessionId = sessionId; + return this; + } + + /** + * Gets the timestamp of the hook invocation. + * + * @return the timestamp in milliseconds + */ + public long getTimestamp() { + return timestamp; + } + + /** + * Sets the timestamp of the hook invocation. + * + * @param timestamp + * the timestamp in milliseconds + * @return this instance for method chaining + */ + public AgentStopHookInput setTimestamp(long timestamp) { + this.timestamp = timestamp; + return this; + } + + /** + * Gets the current working directory. + * + * @return the working directory path + */ + public String getCwd() { + return cwd; + } + + /** + * Sets the current working directory. + * + * @param cwd + * the working directory path + * @return this instance for method chaining + */ + public AgentStopHookInput setCwd(String cwd) { + this.cwd = cwd; + return this; + } + + /** + * Gets the reason the agent stopped. + * + * @return the stop reason + */ + public String getStopReason() { + return stopReason; + } + + /** + * Sets the reason the agent stopped. + * + * @param stopReason + * the stop reason + * @return this instance for method chaining + */ + public AgentStopHookInput setStopReason(String stopReason) { + this.stopReason = stopReason; + return this; + } + + /** + * Gets the path to the on-disk session transcript. + * + * @return the transcript path + */ + public String getTranscriptPath() { + return transcriptPath; + } + + /** + * Sets the path to the on-disk session transcript. + * + * @param transcriptPath + * the transcript path + * @return this instance for method chaining + */ + public AgentStopHookInput setTranscriptPath(String transcriptPath) { + this.transcriptPath = transcriptPath; + return this; + } + + /** + * Gets whether this stop follows a previous block decision. + * + * @return {@code true} when the stop hook is already active + */ + public Boolean getStopHookActive() { + return stopHookActive; + } + + /** + * Sets whether this stop follows a previous block decision. + * + * @param stopHookActive + * whether the stop hook is already active + * @return this instance for method chaining + */ + public AgentStopHookInput setStopHookActive(Boolean stopHookActive) { + this.stopHookActive = stopHookActive; + return this; + } +} diff --git a/java/src/main/java/com/github/copilot/rpc/AgentStopHookOutput.java b/java/src/main/java/com/github/copilot/rpc/AgentStopHookOutput.java new file mode 100644 index 0000000000..293bb31387 --- /dev/null +++ b/java/src/main/java/com/github/copilot/rpc/AgentStopHookOutput.java @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Output for an agent-stop hook. + * + * @since 1.0.9 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class AgentStopHookOutput { + + @JsonProperty("decision") + private String decision; + + @JsonProperty("reason") + private String reason; + + /** + * Gets the stop decision. + * + * @return {@code "block"} to keep the agent running, or {@code null} + */ + public String getDecision() { + return decision; + } + + /** + * Sets the stop decision. + * + * @param decision + * {@code "block"} to keep the agent running + * @return this instance for method chaining + */ + public AgentStopHookOutput setDecision(String decision) { + this.decision = decision; + return this; + } + + /** + * Gets the follow-up instruction supplied when the stop is blocked. + * + * @return the follow-up instruction + */ + public String getReason() { + return reason; + } + + /** + * Sets the follow-up instruction supplied when the stop is blocked. + * + * @param reason + * the follow-up instruction + * @return this instance for method chaining + */ + public AgentStopHookOutput setReason(String reason) { + this.reason = reason; + return this; + } +} diff --git a/java/src/main/java/com/github/copilot/rpc/SessionHooks.java b/java/src/main/java/com/github/copilot/rpc/SessionHooks.java index f13e081316..9cf68684dd 100644 --- a/java/src/main/java/com/github/copilot/rpc/SessionHooks.java +++ b/java/src/main/java/com/github/copilot/rpc/SessionHooks.java @@ -44,6 +44,7 @@ public class SessionHooks { private UserPromptSubmittedHandler onUserPromptSubmitted; private SessionStartHandler onSessionStart; private SessionEndHandler onSessionEnd; + private AgentStopHandler onAgentStop; /** * Gets the pre-tool-use handler. @@ -206,6 +207,29 @@ public SessionHooks setOnSessionEnd(SessionEndHandler onSessionEnd) { return this; } + /** + * Gets the agent-stop handler. + * + * @return the handler, or {@code null} if not set + * @since 1.0.9 + */ + public AgentStopHandler getOnAgentStop() { + return onAgentStop; + } + + /** + * Sets the handler called when the top-level agent reaches a natural stop. + * + * @param onAgentStop + * the handler + * @return this instance for method chaining + * @since 1.0.9 + */ + public SessionHooks setOnAgentStop(AgentStopHandler onAgentStop) { + this.onAgentStop = onAgentStop; + return this; + } + /** * Returns whether any hooks are registered. * @@ -213,6 +237,7 @@ public SessionHooks setOnSessionEnd(SessionEndHandler onSessionEnd) { */ public boolean hasHooks() { return onPreToolUse != null || onPreMcpToolCall != null || onPostToolUse != null || onPostToolUseFailure != null - || onUserPromptSubmitted != null || onSessionStart != null || onSessionEnd != null; + || onUserPromptSubmitted != null || onSessionStart != null || onSessionEnd != null + || onAgentStop != null; } } diff --git a/java/src/test/java/com/github/copilot/SessionHandlerTest.java b/java/src/test/java/com/github/copilot/SessionHandlerTest.java index 1b672e6e25..05994df8d8 100644 --- a/java/src/test/java/com/github/copilot/SessionHandlerTest.java +++ b/java/src/test/java/com/github/copilot/SessionHandlerTest.java @@ -16,6 +16,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.copilot.rpc.AgentStopHookOutput; import com.github.copilot.rpc.PermissionRequestResult; import com.github.copilot.rpc.PermissionRequestResultKind; import com.github.copilot.rpc.SessionEndHookOutput; @@ -262,6 +263,32 @@ void testHandleHooksInvokeSessionEnd() throws Exception { assertEquals("summary", output.sessionSummary()); } + // ===== handleHooksInvoke: agentStop ===== + + @Test + void testHandleHooksInvokeAgentStop() throws Exception { + var hooks = new SessionHooks().setOnAgentStop((hookInput, invocation) -> { + assertEquals("handler-test-session", invocation.getSessionId()); + assertEquals("runtime-session-123", hookInput.getSessionId()); + assertEquals("end_turn", hookInput.getStopReason()); + assertEquals("/tmp/transcript.jsonl", hookInput.getTranscriptPath()); + assertTrue(hookInput.getStopHookActive()); + return CompletableFuture.completedFuture( + new AgentStopHookOutput().setDecision("block").setReason("finish the remaining work")); + }); + session.registerHooks(hooks); + + JsonNode input = MAPPER.valueToTree(Map.of("sessionId", "runtime-session-123", "timestamp", 1735689600L, "cwd", + "/tmp", "stopReason", "end_turn", "transcriptPath", "/tmp/transcript.jsonl", "stop_hook_active", true)); + + Object result = session.handleHooksInvoke("agentStop", input).get(); + + assertInstanceOf(AgentStopHookOutput.class, result); + var output = (AgentStopHookOutput) result; + assertEquals("block", output.getDecision()); + assertEquals("finish the remaining work", output.getReason()); + } + // ===== handleHooksInvoke: sessionId deserialization on hook inputs ===== @Test diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 12a03cefae..89403ade7b 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -74,9 +74,18 @@ function deserializeHookInput(raw: unknown): unknown { ) { return raw; } - const obj = raw as Record & { timestamp: number; cwd?: string }; - const { cwd, ...rest } = obj; - return { ...rest, timestamp: new Date(obj.timestamp), workingDirectory: cwd }; + const obj = raw as Record & { + timestamp: number; + cwd?: string; + stop_hook_active?: boolean; + }; + const { cwd, stop_hook_active, ...rest } = obj; + return { + ...rest, + timestamp: new Date(obj.timestamp), + workingDirectory: cwd, + ...(stop_hook_active === undefined ? {} : { stopHookActive: stop_hook_active }), + }; } function isOpenCanvasInstance(value: unknown): value is OpenCanvasInstance { diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index f540d0d120..fb628fe1d6 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -3213,6 +3213,7 @@ describe("CopilotClient", () => { const result = await (session as any)._handleHooksInvoke("agentStop", { stopReason: "end_turn", transcriptPath: "/tmp/transcript.jsonl", + stop_hook_active: true, timestamp: 1700000000000, cwd: "/repo", }); @@ -3221,6 +3222,7 @@ describe("CopilotClient", () => { expect(received[0].input).toEqual({ stopReason: "end_turn", transcriptPath: "/tmp/transcript.jsonl", + stopHookActive: true, timestamp: new Date(1700000000000), workingDirectory: "/repo", }); diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index 6fb59a8957..27c220ecf2 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -92,6 +92,9 @@ SessionEventType, ) from .session import ( + AgentStopHandler, + AgentStopHookInput, + AgentStopHookOutput, AutoModeSwitchHandler, AutoModeSwitchRequest, AutoModeSwitchResponse, @@ -196,6 +199,9 @@ __version__ = "0.0.0.dev0" __all__ = [ + "AgentStopHandler", + "AgentStopHookInput", + "AgentStopHookOutput", "AutoModeSwitchHandler", "AutoModeSwitchRequest", "AutoModeSwitchResponse", diff --git a/python/copilot/session.py b/python/copilot/session.py index b6736939ac..7b1d3155ce 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -1017,6 +1017,30 @@ class ErrorOccurredHookOutput(TypedDict, total=False): ] +class AgentStopHookInput(TypedDict): + """Input for the agent-stop hook.""" + + sessionId: str + timestamp: datetime + workingDirectory: str + stopReason: NotRequired[str] + transcriptPath: NotRequired[str] + stopHookActive: NotRequired[bool] + + +class AgentStopHookOutput(TypedDict, total=False): + """Output for the agent-stop hook.""" + + decision: Literal["block"] + reason: str + + +AgentStopHandler = Callable[ + [AgentStopHookInput, dict[str, str]], + AgentStopHookOutput | None | Awaitable[AgentStopHookOutput | None], +] + + class SessionHooks(TypedDict, total=False): """Configuration for session hooks""" @@ -1028,6 +1052,7 @@ class SessionHooks(TypedDict, total=False): on_session_start: SessionStartHandler on_session_end: SessionEndHandler on_error_occurred: ErrorOccurredHandler + on_agent_stop: AgentStopHandler # ============================================================================ @@ -2721,6 +2746,7 @@ async def _handle_hooks_invoke(self, hook_type: str, input_data: Any) -> Any: "sessionStart": hooks.get("on_session_start"), "sessionEnd": hooks.get("on_session_end"), "errorOccurred": hooks.get("on_error_occurred"), + "agentStop": hooks.get("on_agent_stop"), } handler = handler_map.get(hook_type) @@ -2737,6 +2763,8 @@ async def _handle_hooks_invoke(self, hook_type: str, input_data: Any) -> Any: transformed: dict[str, Any] = dict(input_data) if "cwd" in transformed: transformed["workingDirectory"] = transformed.pop("cwd") + if "stop_hook_active" in transformed: + transformed["stopHookActive"] = transformed.pop("stop_hook_active") timestamp = transformed.get("timestamp") if isinstance(timestamp, (int, float)): transformed["timestamp"] = datetime.fromtimestamp(timestamp / 1000, tz=UTC) diff --git a/python/test_client.py b/python/test_client.py index d48bfaf4bf..a37d8dce2b 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -2411,6 +2411,45 @@ def on_failure(input_data, invocation): assert result == {"additionalContext": "sync-ok"} +class TestAgentStopHookDispatch: + """Unit tests for the agentStop handler dispatch.""" + + @pytest.mark.asyncio + async def test_dispatches_to_on_agent_stop(self): + from copilot.session import CopilotSession, SessionHooks + + captured: dict = {} + + async def on_agent_stop(input_data, invocation): + captured["input"] = input_data + captured["invocation"] = invocation + return {"decision": "block", "reason": "finish the remaining work"} + + session = CopilotSession.__new__(CopilotSession) + CopilotSession.__init__(session, "sess-123", client=None) + session._hooks = SessionHooks(on_agent_stop=on_agent_stop) # type: ignore[typeddict-item] + + result = await session._handle_hooks_invoke( + "agentStop", + { + "sessionId": "sess-x", + "timestamp": 1700000000, + "cwd": "/work", + "stopReason": "end_turn", + "transcriptPath": "/tmp/transcript.jsonl", + "stop_hook_active": True, + }, + ) + + assert result == {"decision": "block", "reason": "finish the remaining work"} + assert captured["input"]["stopReason"] == "end_turn" + assert captured["input"]["transcriptPath"] == "/tmp/transcript.jsonl" + assert captured["input"]["stopHookActive"] is True + assert captured["input"]["workingDirectory"] == "/work" + assert captured["input"]["timestamp"] == datetime.fromtimestamp(1700000000 / 1000, tz=UTC) + assert captured["invocation"] == {"session_id": "sess-123"} + + class TestGitHubTelemetry: """Unit tests for the experimental gitHubTelemetry.event consumer surface.""" diff --git a/rust/src/hooks.rs b/rust/src/hooks.rs index 0c3d64076f..a2b61ed8b9 100644 --- a/rust/src/hooks.rs +++ b/rust/src/hooks.rs @@ -302,6 +302,40 @@ pub struct ErrorOccurredOutput { pub user_notification: Option, } +/// Input for the `agentStop` hook, received when the top-level agent reaches a natural stop. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentStopInput { + /// The runtime session ID of the session that triggered the hook. + pub session_id: String, + /// Unix timestamp in ms (the runtime serializes this as a JSON float). + pub timestamp: f64, + /// Working directory. + #[serde(rename = "cwd")] + pub working_directory: PathBuf, + /// Reason the agent stopped. + #[serde(default)] + pub stop_reason: Option, + /// Path to the on-disk session transcript. + #[serde(default)] + pub transcript_path: Option, + /// Whether this stop follows a previous block decision from the hook. + #[serde(default, rename = "stop_hook_active")] + pub stop_hook_active: Option, +} + +/// Output for the `agentStop` hook. +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentStopOutput { + /// Set to `"block"` to keep the agent running. + #[serde(skip_serializing_if = "Option::is_none")] + pub decision: Option, + /// Follow-up instruction supplied when the stop is blocked. + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + /// Events dispatched to [`SessionHooks::on_hook`] at CLI lifecycle points. /// /// Each variant carries the typed input for that hook plus the shared @@ -368,6 +402,13 @@ pub enum HookEvent { /// Session context. ctx: HookContext, }, + /// Fired when the top-level agent reaches a natural stop. + AgentStop { + /// Typed input data. + input: AgentStopInput, + /// Session context. + ctx: HookContext, + }, } /// Response from [`SessionHooks::on_hook`] back to the SDK. @@ -395,6 +436,8 @@ pub enum HookOutput { SessionEnd(SessionEndOutput), /// Response for an error-occurred hook. ErrorOccurred(ErrorOccurredOutput), + /// Response for an agent-stop hook. + AgentStop(AgentStopOutput), } impl HookOutput { @@ -409,6 +452,7 @@ impl HookOutput { Self::SessionStart(_) => "SessionStart", Self::SessionEnd(_) => "SessionEnd", Self::ErrorOccurred(_) => "ErrorOccurred", + Self::AgentStop(_) => "AgentStop", } } } @@ -477,6 +521,11 @@ pub trait SessionHooks: Send + Sync + 'static { .await .map(HookOutput::ErrorOccurred) .unwrap_or(HookOutput::None), + HookEvent::AgentStop { input, ctx } => self + .on_agent_stop(input, ctx) + .await + .map(HookOutput::AgentStop) + .unwrap_or(HookOutput::None), } } @@ -563,6 +612,16 @@ pub trait SessionHooks: Send + Sync + 'static { ) -> Option { None } + + /// Called when the top-level agent reaches a natural stop. Return a block + /// decision to keep the agent running with a follow-up instruction. + async fn on_agent_stop( + &self, + _input: AgentStopInput, + _ctx: HookContext, + ) -> Option { + None + } } /// Dispatches a `hooks.invoke` request to [`SessionHooks::on_hook`]. @@ -613,6 +672,10 @@ pub(crate) async fn dispatch_hook( let input: ErrorOccurredInput = serde_json::from_value(raw_input)?; HookEvent::ErrorOccurred { input, ctx } } + "agentStop" => { + let input: AgentStopInput = serde_json::from_value(raw_input)?; + HookEvent::AgentStop { input, ctx } + } _ => { tracing::warn!( hook_type = hook_type, @@ -648,6 +711,7 @@ pub(crate) async fn dispatch_hook( ("sessionStart", HookOutput::SessionStart(o)) => Some(serde_json::to_value(o)?), ("sessionEnd", HookOutput::SessionEnd(o)) => Some(serde_json::to_value(o)?), ("errorOccurred", HookOutput::ErrorOccurred(o)) => Some(serde_json::to_value(o)?), + ("agentStop", HookOutput::AgentStop(o)) => Some(serde_json::to_value(o)?), _ => { tracing::warn!( hook_type = hook_type, @@ -981,4 +1045,50 @@ mod tests { assert_eq!(result["output"]["errorHandling"], "retry"); assert_eq!(result["output"]["retryCount"], 3); } + + #[tokio::test] + async fn dispatch_agent_stop_block() { + struct AgentStopHooks; + #[async_trait] + impl SessionHooks for AgentStopHooks { + async fn on_agent_stop( + &self, + input: AgentStopInput, + ctx: HookContext, + ) -> Option { + assert_eq!(ctx.session_id, SessionId::new("sess-1")); + assert_eq!(input.session_id, "sess-1"); + assert_eq!(input.stop_reason.as_deref(), Some("end_turn")); + assert_eq!( + input.transcript_path, + Some(PathBuf::from("/tmp/transcript.jsonl")) + ); + assert_eq!(input.stop_hook_active, Some(true)); + Some(AgentStopOutput { + decision: Some("block".to_string()), + reason: Some("finish the remaining work".to_string()), + }) + } + } + + let input = serde_json::json!({ + "sessionId": "sess-1", + "timestamp": 1234567890, + "cwd": "/tmp", + "stopReason": "end_turn", + "transcriptPath": "/tmp/transcript.jsonl", + "stop_hook_active": true + }); + let result = dispatch_hook( + &AgentStopHooks, + &SessionId::new("sess-1"), + "agentStop", + input, + ) + .await + .unwrap(); + + assert_eq!(result["output"]["decision"], "block"); + assert_eq!(result["output"]["reason"], "finish the remaining work"); + } } From 0fb030aba32640e999f94e17cc3a7dc843a17286 Mon Sep 17 00:00:00 2001 From: Belal Taher Date: Mon, 27 Jul 2026 22:15:37 -0400 Subject: [PATCH 3/7] Address AgentStop review feedback Export Node hook types, document the hook, cover wire-field normalization, add Go and .NET E2E block-continuation coverage, and format the Python README. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4f4d61ce-6d2f-4b72-a2da-52f60300df59 --- .../E2E/HookLifecycleAndOutputE2ETests.cs | 41 +++++++++- go/internal/e2e/hooks_extended_e2e_test.go | 62 ++++++++++++++- nodejs/README.md | 11 +++ nodejs/src/index.ts | 4 + nodejs/test/client.test.ts | 2 + python/README.md | 75 ++++++++++++++----- ...entstop_hook_and_apply_block_response.yaml | 14 ++++ 7 files changed, 187 insertions(+), 22 deletions(-) create mode 100644 test/snapshots/hooks_extended/should_invoke_agentstop_hook_and_apply_block_response.yaml diff --git a/dotnet/test/E2E/HookLifecycleAndOutputE2ETests.cs b/dotnet/test/E2E/HookLifecycleAndOutputE2ETests.cs index b9366c134e..cd94a2ebd6 100644 --- a/dotnet/test/E2E/HookLifecycleAndOutputE2ETests.cs +++ b/dotnet/test/E2E/HookLifecycleAndOutputE2ETests.cs @@ -11,7 +11,7 @@ namespace GitHub.Copilot.Test.E2E; /// /// E2E coverage for every handler exposed on : /// OnPreToolUse, OnPostToolUse, OnPostToolUseFailure, OnUserPromptSubmitted, -/// OnSessionStart, OnSessionEnd, OnErrorOccurred. Output-shape behavior +/// OnSessionStart, OnSessionEnd, OnErrorOccurred, OnAgentStop. Output-shape behavior /// (modifiedPrompt / additionalContext / errorHandling / modifiedArgs / /// modifiedResult / sessionSummary) is asserted alongside hook invocation. If a /// new handler is added to SessionHooks, add a corresponding test here. @@ -255,6 +255,45 @@ await session.SendAndWaitAsync(new MessageOptions Assert.NotNull(session.SessionId); } + [Fact] + public async Task Should_Invoke_AgentStop_Hook_And_Apply_Block_Response() + { + var inputs = new List(); + var session = await CreateSessionAsync(new SessionConfig + { + Hooks = new SessionHooks + { + OnAgentStop = (input, invocation) => + { + inputs.Add(input); + Assert.False(string.IsNullOrWhiteSpace(invocation.SessionId)); + if (inputs.Count == 1) + { + return Task.FromResult(new AgentStopHookOutput + { + Decision = "block", + Reason = "Reply with exactly: AGENT_STOP_CONTINUED", + }); + } + + return Task.FromResult(null); + }, + }, + }); + + var response = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "Reply with exactly: AGENT_STOP_INITIAL", + }); + + Assert.Equal(2, inputs.Count); + Assert.NotEqual(true, inputs[0].StopHookActive); + Assert.True(inputs[1].StopHookActive); + Assert.Equal("end_turn", inputs[0].StopReason); + Assert.False(string.IsNullOrWhiteSpace(inputs[0].TranscriptPath)); + Assert.Contains("AGENT_STOP_CONTINUED", response?.Data.Content ?? string.Empty); + } + [Fact] public async Task Should_Allow_PreToolUse_To_Return_ModifiedArgs_And_SuppressOutput() { diff --git a/go/internal/e2e/hooks_extended_e2e_test.go b/go/internal/e2e/hooks_extended_e2e_test.go index f53dd13f6a..1afb05a133 100644 --- a/go/internal/e2e/hooks_extended_e2e_test.go +++ b/go/internal/e2e/hooks_extended_e2e_test.go @@ -15,7 +15,7 @@ import ( // // Covers each handler exposed on copilot.SessionHooks: OnPreToolUse, // OnPostToolUse, OnPostToolUseFailure, OnUserPromptSubmitted, OnSessionStart, -// OnSessionEnd, OnErrorOccurred. Output-shape behavior (modifiedPrompt / +// OnSessionEnd, OnErrorOccurred, OnAgentStop. Output-shape behavior (modifiedPrompt / // additionalContext / errorHandling / modifiedArgs / modifiedResult / // sessionSummary) is asserted alongside hook invocation. If a new handler is // added to SessionHooks, add a corresponding test here. @@ -215,6 +215,66 @@ func TestHooksExtendedE2E(t *testing.T) { } }) + t.Run("should invoke agentStop hook and apply block response", func(t *testing.T) { + ctx.ConfigureForTest(t) + + var ( + mu sync.Mutex + inputs []copilot.AgentStopHookInput + ) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Hooks: &copilot.SessionHooks{ + OnAgentStop: func(input copilot.AgentStopHookInput, invocation copilot.HookInvocation) (*copilot.AgentStopHookOutput, error) { + mu.Lock() + inputs = append(inputs, input) + callCount := len(inputs) + mu.Unlock() + if invocation.SessionID == "" { + t.Error("Expected non-empty session ID in invocation") + } + if callCount == 1 { + return &copilot.AgentStopHookOutput{ + Decision: "block", + Reason: "Reply with exactly: AGENT_STOP_CONTINUED", + }, nil + } + return nil, nil + }, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + response, err := session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Reply with exactly: AGENT_STOP_INITIAL", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if len(inputs) != 2 { + t.Fatalf("Expected two agentStop hook invocations, got %+v", inputs) + } + if inputs[0].StopHookActive { + t.Error("Expected first agentStop invocation to not be a continuation") + } + if !inputs[1].StopHookActive { + t.Error("Expected second agentStop invocation to be a continuation") + } + if inputs[0].StopReason != "end_turn" || inputs[0].TranscriptPath == "" { + t.Errorf("Unexpected first agentStop input: %+v", inputs[0]) + } + assistantMessage, ok := response.Data.(*copilot.AssistantMessageData) + if !ok || !strings.Contains(assistantMessage.Content, "AGENT_STOP_CONTINUED") { + t.Errorf("Expected final response to contain AGENT_STOP_CONTINUED, got %v", response.Data) + } + }) + t.Run("should allow preToolUse to return modifiedArgs and suppressOutput", func(t *testing.T) { ctx.ConfigureForTest(t) diff --git a/nodejs/README.md b/nodejs/README.md index 52b3d28053..8f2462b70a 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -1058,6 +1058,16 @@ const session = await client.createSession({ errorHandling: "retry", // "retry", "skip", or "abort" }; }, + + // Called when the top-level agent naturally stops + onAgentStop: async (input, invocation) => { + if (!input.stopHookActive && needsMoreWork()) { + return { + decision: "block", + reason: "Run the final validation and fix any failures.", + }; + } + }, }, }); ``` @@ -1071,6 +1081,7 @@ const session = await client.createSession({ - `onSessionStart` - Run logic when a session starts or resumes. - `onSessionEnd` - Cleanup or logging when session ends. - `onErrorOccurred` - Handle errors with retry/skip/abort strategies. +- `onAgentStop` - Observe natural top-level agent completion. Return `{ decision: "block", reason }` to request another turn; use `stopHookActive` to avoid repeated blocks. ## Error Handling diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index c1ab289150..352afc6a94 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -57,6 +57,9 @@ export type { AutoModeSwitchHandler, AutoModeSwitchRequest, AutoModeSwitchResponse, + AgentStopHandler, + AgentStopHookInput, + AgentStopHookOutput, CopilotClientMode, CopilotClientOptions, CopilotExpAssignmentResponse, @@ -126,6 +129,7 @@ export type { SessionLifecycleEventMetadata, SessionLifecycleEventType, SessionLifecycleHandler, + SessionHooks, SessionCreatedEvent, SessionDeletedEvent, SessionUpdatedEvent, diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index fb628fe1d6..77149bc4b9 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -3254,6 +3254,7 @@ describe("CopilotClient", () => { hookType: "agentStop", input: { stopReason: "end_turn", + stop_hook_active: true, timestamp: 1700000000000, cwd: "/repo", }, @@ -3262,6 +3263,7 @@ describe("CopilotClient", () => { expect(received).toHaveLength(1); expect(received[0].input).toEqual({ stopReason: "end_turn", + stopHookActive: true, timestamp: new Date(1700000000000), workingDirectory: "/repo", }); diff --git a/python/README.md b/python/README.md index 3089c36699..2ae6f41bfc 100644 --- a/python/README.md +++ b/python/README.md @@ -76,6 +76,7 @@ from copilot import CopilotClient from copilot.session_events import AssistantMessageData, SessionIdleData from copilot.session import PermissionHandler + async def main(): # Client automatically starts on enter and cleans up on exit async with CopilotClient() as client: @@ -100,6 +101,7 @@ async def main(): await session.send("What is 2+2?") await done.wait() + asyncio.run(main()) ``` @@ -114,6 +116,7 @@ from copilot import CopilotClient from copilot.session_events import AssistantMessageData, SessionIdleData from copilot.session import PermissionHandler + async def main(): client = CopilotClient() await client.start() @@ -141,6 +144,7 @@ async def main(): await session.disconnect() await client.stop() + asyncio.run(main()) ``` @@ -167,6 +171,7 @@ async with CopilotClient() as client: on_permission_request=PermissionHandler.approve_all, model="gpt-5", ) as session: + def on_event(event): print(f"Event: {event.type}") @@ -287,14 +292,18 @@ session_id = await client.get_foreground_session_id() # Request TUI to display a specific session (TUI+server mode only) await client.set_foreground_session_id("session-123") + # Subscribe to all lifecycle events def on_lifecycle(event): print(f"{event.type}: {event.session_id}") + unsubscribe = client.on_lifecycle(on_lifecycle) # Subscribe to specific event type -unsubscribe = client.on_lifecycle("session.foreground", lambda e: print(f"Foreground: {e.session_id}")) +unsubscribe = client.on_lifecycle( + "session.foreground", lambda e: print(f"Foreground: {e.session_id}") +) # Later, to stop receiving events: unsubscribe() @@ -316,14 +325,17 @@ Define tools with automatic JSON schema generation using the `@define_tool` deco from pydantic import BaseModel, Field from copilot import CopilotClient, define_tool + class LookupIssueParams(BaseModel): id: str = Field(description="Issue identifier") + @define_tool(description="Fetch issue details from our tracker") async def lookup_issue(params: LookupIssueParams) -> str: issue = await fetch_issue(params.id) return issue.summary + async with await client.create_session( on_permission_request=PermissionHandler.approve_all, model="gpt-5", @@ -343,6 +355,7 @@ from copilot import CopilotClient from copilot.tools import Tool, ToolInvocation, ToolResult from copilot.session import PermissionHandler + async def lookup_issue(invocation: ToolInvocation) -> ToolResult: issue_id = invocation.arguments["id"] issue = await fetch_issue(issue_id) @@ -352,6 +365,7 @@ async def lookup_issue(invocation: ToolInvocation) -> ToolResult: session_log=f"Fetched issue {issue_id}", ) + async with await client.create_session( on_permission_request=PermissionHandler.approve_all, model="gpt-5", @@ -471,6 +485,7 @@ from copilot.session_events import ( ) from copilot.session import PermissionHandler + async def main(): async with CopilotClient() as client: async with await client.create_session( @@ -507,6 +522,7 @@ async def main(): await session.send("Tell me a short story") await done.wait() # Wait for streaming to complete + asyncio.run(main()) ``` @@ -678,7 +694,10 @@ async with await client.create_session( system_message={ "mode": "customize", "sections": { - "tone": {"action": "replace", "content": "Respond in a warm, professional tone. Be thorough in explanations."}, + "tone": { + "action": "replace", + "content": "Respond in a warm, professional tone. Be thorough in explanations.", + }, "code_change_rules": {"action": "remove"}, "guidelines": {"action": "append", "content": "\n* Always cite data sources"}, }, @@ -698,6 +717,7 @@ You can also pass a transform callback as the `action` instead of a string. The def redact_paths(content: str) -> str: return content.replace("/home/user", "/***") + async with await client.create_session( on_permission_request=PermissionHandler.approve_all, model="gpt-5", @@ -785,9 +805,7 @@ from copilot.rpc import ( from copilot.session_events import PermissionRequestShell -def on_permission_request( - request: PermissionRequest, invocation: dict -) -> PermissionRequestResult: +def on_permission_request(request: PermissionRequest, invocation: dict) -> PermissionRequestResult: # ``PermissionRequest`` is a discriminated union — pattern-match on # the variant class to access the per-kind fields. match request: @@ -871,6 +889,7 @@ async def handle_user_input(request, invocation): "wasFreeform": True, # Whether the answer was freeform (not from choices) } + async with await client.create_session( on_permission_request=PermissionHandler.approve_all, model="gpt-5", @@ -893,12 +912,14 @@ async def on_pre_tool_use(input, invocation): "additionalContext": "Extra context for the model", } + async def on_post_tool_use(input, invocation): print(f"Tool {input['toolName']} completed") return { "additionalContext": "Post-execution notes", } + async def on_post_tool_use_failure(input, invocation): # Fires when a tool's result was a failure. `on_post_tool_use` only fires # on success, so register this handler to observe failed tool calls. The @@ -908,27 +929,32 @@ async def on_post_tool_use_failure(input, invocation): "additionalContext": f"Retry guidance for {input['toolName']}", } + async def on_user_prompt_submitted(input, invocation): print(f"User prompt: {input['prompt']}") return { "modifiedPrompt": input["prompt"], # Optionally modify the prompt } + async def on_session_start(input, invocation): print(f"Session started from: {input['source']}") # "startup", "resume", "new" return { "additionalContext": "Session initialization context", } + async def on_session_end(input, invocation): print(f"Session ended: {input['reason']}") + async def on_error_occurred(input, invocation): print(f"Error in {input['errorContext']}: {input['error']}") return { "errorHandling": "retry", # "retry", "skip", or "abort" } + async with await client.create_session( on_permission_request=PermissionHandler.approve_all, model="gpt-5", @@ -962,6 +988,7 @@ Register slash commands that users can invoke from the CLI TUI. When the user ty ```python from copilot.session import CommandDefinition, CommandContext, PermissionHandler + async def handle_deploy(ctx: CommandContext) -> None: print(f"Deploying with args: {ctx.args}") # ctx.session_id — the session where the command was invoked @@ -969,6 +996,7 @@ async def handle_deploy(ctx: CommandContext) -> None: # ctx.command_name — command name without leading / (e.g. "deploy") # ctx.args — raw argument string (e.g. "production") + async with await client.create_session( on_permission_request=PermissionHandler.approve_all, commands=[ @@ -1030,11 +1058,14 @@ Shows a text input dialog with optional constraints: name = await session.ui.input("Enter your name:") # With options -email = await session.ui.input("Enter email:", { - "title": "Email Address", - "description": "We'll use this for notifications", - "format": "email", -}) +email = await session.ui.input( + "Enter email:", + { + "title": "Email Address", + "description": "We'll use this for notifications", + "format": "email", + }, +) ``` ### Custom Elicitation @@ -1042,17 +1073,19 @@ email = await session.ui.input("Enter email:", { For full control, use the `elicitation()` method with a custom JSON schema: ```python -result = await session.ui.elicitation({ - "message": "Configure deployment", - "requestedSchema": { - "type": "object", - "properties": { - "region": {"type": "string", "enum": ["us-east-1", "eu-west-1"]}, - "replicas": {"type": "number", "minimum": 1, "maximum": 10}, +result = await session.ui.elicitation( + { + "message": "Configure deployment", + "requestedSchema": { + "type": "object", + "properties": { + "region": {"type": "string", "enum": ["us-east-1", "eu-west-1"]}, + "replicas": {"type": "number", "minimum": 1, "maximum": 10}, + }, + "required": ["region"], }, - "required": ["region"], - }, -}) + } +) if result["action"] == "accept": region = result["content"]["region"] @@ -1066,6 +1099,7 @@ When the server (or an MCP tool) needs to ask the end-user a question, it sends ```python from copilot.session import ElicitationContext, ElicitationResult, PermissionHandler + async def handle_elicitation( context: ElicitationContext, ) -> ElicitationResult: @@ -1082,6 +1116,7 @@ async def handle_elicitation( "content": {"answer": "yes"}, } + async with await client.create_session( on_permission_request=PermissionHandler.approve_all, on_elicitation_request=handle_elicitation, diff --git a/test/snapshots/hooks_extended/should_invoke_agentstop_hook_and_apply_block_response.yaml b/test/snapshots/hooks_extended/should_invoke_agentstop_hook_and_apply_block_response.yaml new file mode 100644 index 0000000000..6485670a1c --- /dev/null +++ b/test/snapshots/hooks_extended/should_invoke_agentstop_hook_and_apply_block_response.yaml @@ -0,0 +1,14 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: "Reply with exactly: AGENT_STOP_INITIAL" + - role: assistant + content: AGENT_STOP_INITIAL + - role: user + content: "Reply with exactly: AGENT_STOP_CONTINUED" + - role: assistant + content: AGENT_STOP_CONTINUED From c98253714851c8f4099c70846fa9e689839f4499 Mon Sep 17 00:00:00 2001 From: Belal Taher Date: Mon, 27 Jul 2026 22:39:40 -0400 Subject: [PATCH 4/7] Clarify AgentStop input naming by language Document the public input member names for Node, Python, Go, .NET, Rust, and Java. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4f4d61ce-6d2f-4b72-a2da-52f60300df59 --- docs/hooks/session-lifecycle.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/hooks/session-lifecycle.md b/docs/hooks/session-lifecycle.md index 843f216086..485752601d 100644 --- a/docs/hooks/session-lifecycle.md +++ b/docs/hooks/session-lifecycle.md @@ -555,11 +555,13 @@ The agent stop hook runs when the top-level agent naturally reaches the end of a ### Input -| Field | Type | Description | -|-------|------|-------------| -| `stopReason` | string \| undefined | Why the agent stopped, such as `end_turn` | -| `transcriptPath` | string \| undefined | Path to the on-disk session transcript | -| `stopHookActive` | boolean \| undefined | Whether this turn was already forced to continue by an earlier block decision | +The public member names follow each language's casing conventions: + +| Meaning | Node.js / Python | Go / .NET | Rust | Java | +|---------|------------------|-----------|------|------| +| Why the agent stopped, such as `end_turn` | `stopReason` | `StopReason` | `stop_reason` | `getStopReason()` | +| Path to the on-disk session transcript | `transcriptPath` | `TranscriptPath` | `transcript_path` | `getTranscriptPath()` | +| Whether an earlier block decision already forced this continuation | `stopHookActive` | `StopHookActive` | `stop_hook_active` | `getStopHookActive()` | ### Output @@ -572,7 +574,7 @@ Return no output to let the agent stop. Return a block decision to enqueue anoth } ``` -Use `stopHookActive` to avoid repeatedly blocking an agent that has already continued because of this hook. The runtime also caps consecutive block decisions. +Use the active-stop member listed above to avoid repeatedly blocking an agent that has already continued because of this hook. The runtime also caps consecutive block decisions. ## Best practices From 4e1ece6a6339136a5ccececce98a1d47f2c5d06b Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:59:24 +0000 Subject: [PATCH 5/7] test: cover AgentStop E2E in every SDK Add Node, Python, Rust, and Java replay tests for natural-stop callback delivery and block-driven continuation using the shared hooks_extended snapshot. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../java/com/github/copilot/HooksTest.java | 42 +++++++++++ nodejs/test/e2e/hooks_extended.e2e.test.ts | 33 +++++++++ python/e2e/test_hooks_extended_e2e.py | 32 +++++++- rust/tests/e2e/hooks_extended.rs | 73 ++++++++++++++++++- 4 files changed, 175 insertions(+), 5 deletions(-) diff --git a/java/src/test/java/com/github/copilot/HooksTest.java b/java/src/test/java/com/github/copilot/HooksTest.java index 329883581e..9079fa113a 100644 --- a/java/src/test/java/com/github/copilot/HooksTest.java +++ b/java/src/test/java/com/github/copilot/HooksTest.java @@ -18,6 +18,8 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import com.github.copilot.rpc.AgentStopHookInput; +import com.github.copilot.rpc.AgentStopHookOutput; import com.github.copilot.rpc.MessageOptions; import com.github.copilot.rpc.PermissionHandler; import com.github.copilot.rpc.PostToolUseHookInput; @@ -225,4 +227,44 @@ void testDenyToolExecutionWhenPreToolUseReturnsDeny() throws Exception { assertEquals(originalContent, Files.readString(testFile), "Denied preToolUse hook should block file edits"); } } + + /** + * Verifies that agent-stop can block a natural stop and enqueue another turn. + * + * @see Snapshot: hooks_extended/should_invoke_agentstop_hook_and_apply_block_response + */ + @Test + void testInvokeAgentStopHookAndApplyBlockResponse() throws Exception { + ctx.configureForTest("hooks_extended", "should_invoke_agentstop_hook_and_apply_block_response"); + + var inputs = new ArrayList(); + final String[] sessionIdHolder = new String[1]; + var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setHooks(new SessionHooks().setOnAgentStop((input, invocation) -> { + assertEquals(sessionIdHolder[0], invocation.getSessionId()); + inputs.add(input); + if (inputs.size() == 1) { + return CompletableFuture.completedFuture(new AgentStopHookOutput().setDecision("block") + .setReason("Reply with exactly: AGENT_STOP_CONTINUED")); + } + return CompletableFuture.completedFuture(null); + })); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession(config).get(); + sessionIdHolder[0] = session.getSessionId(); + + var response = session + .sendAndWait(new MessageOptions().setPrompt("Reply with exactly: AGENT_STOP_INITIAL")) + .get(60, TimeUnit.SECONDS); + + assertEquals(2, inputs.size()); + assertNotEquals(Boolean.TRUE, inputs.get(0).getStopHookActive()); + assertEquals(Boolean.TRUE, inputs.get(1).getStopHookActive()); + assertEquals("end_turn", inputs.get(0).getStopReason()); + assertFalse(inputs.get(0).getTranscriptPath().isBlank()); + assertNotNull(response); + assertTrue(response.getData().content().contains("AGENT_STOP_CONTINUED")); + } + } } diff --git a/nodejs/test/e2e/hooks_extended.e2e.test.ts b/nodejs/test/e2e/hooks_extended.e2e.test.ts index 82e1812f11..5b997adb2c 100644 --- a/nodejs/test/e2e/hooks_extended.e2e.test.ts +++ b/nodejs/test/e2e/hooks_extended.e2e.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; import { approveAll, defineTool } from "../../src/index.js"; import type { + AgentStopHookInput, ErrorOccurredHookInput, PostToolUseFailureHookInput, PostToolUseHookInput, @@ -260,6 +261,38 @@ describe("Extended session hooks", async () => { await session.disconnect(); }); + it("should invoke agentStop hook and apply block response", async () => { + const inputs: AgentStopHookInput[] = []; + const session = await client.createSession({ + onPermissionRequest: approveAll, + hooks: { + onAgentStop: async (input, invocation) => { + expect(invocation.sessionId).toBe(session.sessionId); + inputs.push(input); + if (inputs.length === 1) { + return { + decision: "block", + reason: "Reply with exactly: AGENT_STOP_CONTINUED", + }; + } + }, + }, + }); + + const response = await session.sendAndWait({ + prompt: "Reply with exactly: AGENT_STOP_INITIAL", + }); + + expect(inputs).toHaveLength(2); + expect(inputs[0].stopHookActive).not.toBe(true); + expect(inputs[1].stopHookActive).toBe(true); + expect(inputs[0].stopReason).toBe("end_turn"); + expect(inputs[0].transcriptPath).toBeTruthy(); + expect(response?.data.content ?? "").toContain("AGENT_STOP_CONTINUED"); + + await session.disconnect(); + }); + it("should allow preToolUse to return modifiedArgs and suppressOutput", async () => { const inputs: PreToolUseHookInput[] = []; const session = await client.createSession({ diff --git a/python/e2e/test_hooks_extended_e2e.py b/python/e2e/test_hooks_extended_e2e.py index 841c14c77b..1b7543e13f 100644 --- a/python/e2e/test_hooks_extended_e2e.py +++ b/python/e2e/test_hooks_extended_e2e.py @@ -4,7 +4,7 @@ E2E coverage for every handler exposed on ``SessionHooks``: ``on_pre_tool_use``, ``on_post_tool_use``, ``on_post_tool_use_failure``, ``on_user_prompt_submitted``, ``on_session_start``, ``on_session_end``, -``on_error_occurred``. Output-shape behavior (modifiedPrompt / +``on_error_occurred``, ``on_agent_stop``. Output-shape behavior (modifiedPrompt / additionalContext / errorHandling / modifiedArgs / modifiedResult / sessionSummary) is asserted alongside hook invocation. """ @@ -114,6 +114,36 @@ async def on_error_occurred(input_data, invocation): finally: await session.disconnect() + async def test_should_invoke_agentstop_hook_and_apply_block_response( + self, ctx: E2ETestContext + ): + inputs: list[dict] = [] + + async def on_agent_stop(input_data, invocation): + assert invocation["session_id"] == session.session_id + inputs.append(input_data) + if len(inputs) == 1: + return { + "decision": "block", + "reason": "Reply with exactly: AGENT_STOP_CONTINUED", + } + return None + + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + hooks={"on_agent_stop": on_agent_stop}, + ) + try: + response = await session.send_and_wait("Reply with exactly: AGENT_STOP_INITIAL") + assert len(inputs) == 2 + assert inputs[0].get("stopHookActive") is not True + assert inputs[1].get("stopHookActive") is True + assert inputs[0].get("stopReason") == "end_turn" + assert inputs[0].get("transcriptPath") + assert "AGENT_STOP_CONTINUED" in (response.data.content or "") + finally: + await session.disconnect() + async def test_should_allow_pretooluse_to_return_modifiedargs_and_suppressoutput( self, ctx: E2ETestContext ): diff --git a/rust/tests/e2e/hooks_extended.rs b/rust/tests/e2e/hooks_extended.rs index ab93a0c3cd..94d29961aa 100644 --- a/rust/tests/e2e/hooks_extended.rs +++ b/rust/tests/e2e/hooks_extended.rs @@ -1,12 +1,13 @@ use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; use async_trait::async_trait; use github_copilot_sdk::handler::ApproveAllHandler; use github_copilot_sdk::hooks::{ - ErrorOccurredInput, ErrorOccurredOutput, HookContext, PostToolUseFailureInput, - PostToolUseFailureOutput, PostToolUseInput, PostToolUseOutput, PreToolUseInput, - PreToolUseOutput, SessionEndInput, SessionEndOutput, SessionHooks, SessionStartInput, - SessionStartOutput, UserPromptSubmittedInput, UserPromptSubmittedOutput, + AgentStopInput, AgentStopOutput, ErrorOccurredInput, ErrorOccurredOutput, HookContext, + PostToolUseFailureInput, PostToolUseFailureOutput, PostToolUseInput, PostToolUseOutput, + PreToolUseInput, PreToolUseOutput, SessionEndInput, SessionEndOutput, SessionHooks, + SessionStartInput, SessionStartOutput, UserPromptSubmittedInput, UserPromptSubmittedOutput, }; use github_copilot_sdk::tool::ToolHandler; use github_copilot_sdk::{Error, SessionConfig, Tool, ToolInvocation, ToolResult}; @@ -281,6 +282,49 @@ async fn should_register_erroroccurred_hook() { .await; } +#[tokio::test] +async fn should_invoke_agentstop_hook_and_apply_block_response() { + with_e2e_context( + "hooks_extended", + "should_invoke_agentstop_hook_and_apply_block_response", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_hooks(Arc::new(AgentStopHooks { + tx, + call_count: AtomicUsize::new(0), + })), + ) + .await + .expect("create session"); + + let answer = session + .send_and_wait("Reply with exactly: AGENT_STOP_INITIAL") + .await + .expect("send") + .expect("assistant message"); + let first = recv_with_timeout(&mut rx, "first agentStop hook").await; + let second = recv_with_timeout(&mut rx, "second agentStop hook").await; + + assert_ne!(first.stop_hook_active, Some(true)); + assert_eq!(second.stop_hook_active, Some(true)); + assert_eq!(first.stop_reason.as_deref(), Some("end_turn")); + assert!(first.transcript_path.is_some()); + assert!(assistant_message_content(&answer).contains("AGENT_STOP_CONTINUED")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + #[tokio::test] async fn should_allow_pretooluse_to_return_modifiedargs_and_suppressoutput() { with_e2e_context( @@ -441,6 +485,27 @@ struct RecordingHooks { post_tool_failure: Option>, } +struct AgentStopHooks { + tx: mpsc::UnboundedSender, + call_count: AtomicUsize, +} + +#[async_trait] +impl SessionHooks for AgentStopHooks { + async fn on_agent_stop( + &self, + input: AgentStopInput, + ctx: HookContext, + ) -> Option { + assert!(!ctx.session_id.as_str().is_empty()); + let _ = self.tx.send(input); + (self.call_count.fetch_add(1, Ordering::SeqCst) == 0).then(|| AgentStopOutput { + decision: Some("block".to_string()), + reason: Some("Reply with exactly: AGENT_STOP_CONTINUED".to_string()), + }) + } +} + impl RecordingHooks { fn session_start( tx: mpsc::UnboundedSender, From 68cc04ec3307b93ae54cedd588e6018d3d04e1b0 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:00:36 +0000 Subject: [PATCH 6/7] test: format Python AgentStop E2E Apply Ruff formatting to the new replay test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/e2e/test_hooks_extended_e2e.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/python/e2e/test_hooks_extended_e2e.py b/python/e2e/test_hooks_extended_e2e.py index 1b7543e13f..b38534ea22 100644 --- a/python/e2e/test_hooks_extended_e2e.py +++ b/python/e2e/test_hooks_extended_e2e.py @@ -114,9 +114,7 @@ async def on_error_occurred(input_data, invocation): finally: await session.disconnect() - async def test_should_invoke_agentstop_hook_and_apply_block_response( - self, ctx: E2ETestContext - ): + async def test_should_invoke_agentstop_hook_and_apply_block_response(self, ctx: E2ETestContext): inputs: list[dict] = [] async def on_agent_stop(input_data, invocation): From ac3a17bebd89741ad811fc8695308e0a5130e486 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:09:08 +0000 Subject: [PATCH 7/7] test: apply Java and Rust formatting Match Spotless and nightly rustfmt output for the AgentStop replay tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/test/java/com/github/copilot/HooksTest.java | 6 +++--- rust/tests/e2e/hooks_extended.rs | 13 ++++++------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/java/src/test/java/com/github/copilot/HooksTest.java b/java/src/test/java/com/github/copilot/HooksTest.java index 9079fa113a..98cb962fc0 100644 --- a/java/src/test/java/com/github/copilot/HooksTest.java +++ b/java/src/test/java/com/github/copilot/HooksTest.java @@ -231,7 +231,8 @@ void testDenyToolExecutionWhenPreToolUseReturnsDeny() throws Exception { /** * Verifies that agent-stop can block a natural stop and enqueue another turn. * - * @see Snapshot: hooks_extended/should_invoke_agentstop_hook_and_apply_block_response + * @see Snapshot: + * hooks_extended/should_invoke_agentstop_hook_and_apply_block_response */ @Test void testInvokeAgentStopHookAndApplyBlockResponse() throws Exception { @@ -254,8 +255,7 @@ void testInvokeAgentStopHookAndApplyBlockResponse() throws Exception { CopilotSession session = client.createSession(config).get(); sessionIdHolder[0] = session.getSessionId(); - var response = session - .sendAndWait(new MessageOptions().setPrompt("Reply with exactly: AGENT_STOP_INITIAL")) + var response = session.sendAndWait(new MessageOptions().setPrompt("Reply with exactly: AGENT_STOP_INITIAL")) .get(60, TimeUnit.SECONDS); assertEquals(2, inputs.size()); diff --git a/rust/tests/e2e/hooks_extended.rs b/rust/tests/e2e/hooks_extended.rs index 94d29961aa..4c61757e8f 100644 --- a/rust/tests/e2e/hooks_extended.rs +++ b/rust/tests/e2e/hooks_extended.rs @@ -293,13 +293,12 @@ async fn should_invoke_agentstop_hook_and_apply_block_response() { let (tx, mut rx) = mpsc::unbounded_channel(); let client = ctx.start_client().await; let session = client - .create_session( - ctx.approve_all_session_config() - .with_hooks(Arc::new(AgentStopHooks { - tx, - call_count: AtomicUsize::new(0), - })), - ) + .create_session(ctx.approve_all_session_config().with_hooks(Arc::new( + AgentStopHooks { + tx, + call_count: AtomicUsize::new(0), + }, + ))) .await .expect("create session");