Skip to content
Merged
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
2 changes: 2 additions & 0 deletions docs/hooks/hooks-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions docs/hooks/session-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,42 @@ 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` |
Comment thread
belaltaher8 marked this conversation as resolved.

### Input

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

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 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

1. **Keep `onSessionStart` fast** - Users are waiting for the session to be ready.
Expand Down
6 changes: 4 additions & 2 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1095,7 +1095,8 @@ public async Task<CopilotSession> 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);

Expand Down Expand Up @@ -1320,7 +1321,8 @@ public async Task<CopilotSession> 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);

Expand Down
7 changes: 7 additions & 0 deletions dotnet/src/Session.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
}
Expand Down Expand Up @@ -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<string, SystemMessageTransformSection>))]
Expand Down
66 changes: 66 additions & 0 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1871,6 +1871,67 @@ public sealed class ErrorOccurredHookOutput
public string? UserNotification { get; set; }
}

/// <summary>
/// Input for an agent-stop hook.
/// </summary>
public sealed class AgentStopHookInput
{
/// <summary>
/// The runtime session ID of the session that triggered the hook.
/// </summary>
[JsonPropertyName("sessionId")]
public string SessionId { get; set; } = string.Empty;

/// <summary>
/// Unix timestamp in milliseconds when the agent stopped.
/// </summary>
[JsonPropertyName("timestamp")]
[JsonConverter(typeof(UnixMillisecondsDateTimeOffsetConverter))]
public DateTimeOffset Timestamp { get; set; }

/// <summary>
/// Current working directory of the session.
/// </summary>
[JsonPropertyName("cwd")]
public string WorkingDirectory { get; set; } = string.Empty;

/// <summary>
/// Reason the agent stopped.
/// </summary>
[JsonPropertyName("stopReason")]
public string? StopReason { get; set; }

/// <summary>
/// Path to the on-disk session transcript.
/// </summary>
[JsonPropertyName("transcriptPath")]
public string? TranscriptPath { get; set; }

/// <summary>
/// Whether this stop follows a previous block decision from the hook.
/// </summary>
[JsonPropertyName("stop_hook_active")]
public bool? StopHookActive { get; set; }
}

/// <summary>
/// Output for an agent-stop hook.
/// </summary>
public sealed class AgentStopHookOutput
{
/// <summary>
/// Set to <c>"block"</c> to keep the agent running.
/// </summary>
[JsonPropertyName("decision")]
public string? Decision { get; set; }

/// <summary>
/// Follow-up instruction supplied when the stop is blocked.
/// </summary>
[JsonPropertyName("reason")]
public string? Reason { get; set; }
}

/// <summary>
/// Hook handlers configuration for a session.
/// </summary>
Expand Down Expand Up @@ -1917,6 +1978,11 @@ public sealed class SessionHooks
/// Handler called when an error occurs.
/// </summary>
public Func<ErrorOccurredHookInput, HookInvocation, Task<ErrorOccurredHookOutput?>>? OnErrorOccurred { get; set; }

/// <summary>
/// Handler called when the top-level agent reaches a natural stop.
/// </summary>
public Func<AgentStopHookInput, HookInvocation, Task<AgentStopHookOutput?>>? OnAgentStop { get; set; }
Comment thread
belaltaher8 marked this conversation as resolved.
}

/// <summary>
Expand Down
41 changes: 40 additions & 1 deletion dotnet/test/E2E/HookLifecycleAndOutputE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ namespace GitHub.Copilot.Test.E2E;
/// <summary>
/// E2E coverage for every handler exposed on <see cref="SessionHooks"/>:
/// 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 <c>SessionHooks</c>, add a corresponding test here.
Expand Down Expand Up @@ -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<AgentStopHookInput>();
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<AgentStopHookOutput?>(new AgentStopHookOutput
{
Decision = "block",
Reason = "Reply with exactly: AGENT_STOP_CONTINUED",
});
}

return Task.FromResult<AgentStopHookOutput?>(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()
{
Expand Down
42 changes: 42 additions & 0 deletions dotnet/test/Unit/SerializationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AgentStopHookInput>(
"""
{
"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()
{
Expand Down
6 changes: 4 additions & 2 deletions go/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
62 changes: 61 additions & 1 deletion go/internal/e2e/hooks_extended_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)

Expand Down
11 changes: 11 additions & 0 deletions go/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading
Loading