Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
4 changes: 3 additions & 1 deletion dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,7 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance

var hasHooks = config.Hooks != null && (
config.Hooks.OnPreToolUse != null ||
config.Hooks.OnPreMcpToolCall != null ||
config.Hooks.OnPostToolUse != null ||
config.Hooks.OnUserPromptSubmitted != null ||
config.Hooks.OnSessionStart != null ||
Expand Down Expand Up @@ -688,6 +689,7 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes

var hasHooks = config.Hooks != null && (
config.Hooks.OnPreToolUse != null ||
config.Hooks.OnPreMcpToolCall != null ||
config.Hooks.OnPostToolUse != null ||
config.Hooks.OnUserPromptSubmitted != null ||
config.Hooks.OnSessionStart != null ||
Expand Down Expand Up @@ -1231,7 +1233,7 @@ private async Task ConfigureSessionFsAsync(CancellationToken cancellationToken)
}

await Rpc.SessionFs.SetProviderAsync(
_options.SessionFs.InitialCwd,
_options.SessionFs.InitialWorkingDirectory,
_options.SessionFs.SessionStatePath,
_options.SessionFs.Conventions,
_options.SessionFs.Capabilities,
Expand Down
15 changes: 15 additions & 0 deletions dotnet/src/Session.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1245,6 +1245,11 @@ internal void RegisterHooks(SessionHooks hooks)
JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.PreToolUseHookInput)!,
invocation)
: null,
"preMcpToolCall" => hooks.OnPreMcpToolCall != null
? SerializeHookOutput(await hooks.OnPreMcpToolCall(
JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.PreMcpToolCallHookInput)!,
invocation))
: null,
"postToolUse" => hooks.OnPostToolUse != null
? await hooks.OnPostToolUse(
JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.PostToolUseHookInput)!,
Expand Down Expand Up @@ -1283,6 +1288,14 @@ internal void RegisterHooks(SessionHooks hooks)
}
}

/// <summary>
/// Pre-serializes a hook output to JsonElement so that the <c>object?</c> typed
/// <see cref="CopilotClient.HooksInvokeResponse.Output"/> property writes the
/// correct JSON without relying on polymorphic type resolution.
/// </summary>
private static JsonElement? SerializeHookOutput(PreMcpToolCallHookOutput? output) =>
output is null ? null : JsonSerializer.SerializeToElement(output, SessionJsonContext.Default.PreMcpToolCallHookOutput);

/// <summary>
/// Registers transform callbacks for system message sections.
/// </summary>
Expand Down Expand Up @@ -1607,6 +1620,8 @@ internal void ThrowIfDisposed()
[JsonSerializable(typeof(GetMessagesResponse))]
[JsonSerializable(typeof(PostToolUseHookInput))]
[JsonSerializable(typeof(PostToolUseHookOutput))]
[JsonSerializable(typeof(PreMcpToolCallHookInput))]
[JsonSerializable(typeof(PreMcpToolCallHookOutput))]
[JsonSerializable(typeof(PreToolUseHookInput))]
[JsonSerializable(typeof(PreToolUseHookOutput))]
[JsonSerializable(typeof(SendMessageRequest))]
Expand Down
102 changes: 93 additions & 9 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/*---------------------------------------------------------------------------------------------
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/

Expand Down Expand Up @@ -396,7 +396,8 @@ public sealed class SessionFsConfig
/// <summary>
/// Initial working directory for sessions (user's project directory).
/// </summary>
public required string InitialCwd { get; init; }
[JsonPropertyName("initialCwd")]
public required string InitialWorkingDirectory { get; init; }

/// <summary>
/// Path within each session's SessionFs where the runtime stores
Expand Down Expand Up @@ -1215,7 +1216,7 @@ public sealed class PreToolUseHookInput
/// Current working directory of the session.
/// </summary>
[JsonPropertyName("cwd")]
public string Cwd { get; set; } = string.Empty;
public string WorkingDirectory { get; set; } = string.Empty;

/// <summary>
/// Name of the tool about to be executed.
Expand Down Expand Up @@ -1271,6 +1272,83 @@ public sealed class PreToolUseHookOutput
public bool? SuppressOutput { get; set; }
}

/// <summary>
/// Input for a pre-MCP-tool-call hook.
/// </summary>
public sealed class PreMcpToolCallHookInput
{
/// <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 hook was triggered.
/// </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>
/// Name of the MCP server being called.
/// </summary>
[JsonPropertyName("serverName")]
public string ServerName { get; set; } = string.Empty;

/// <summary>
/// Name of the MCP tool being called.
/// </summary>
[JsonPropertyName("toolName")]
public string ToolName { get; set; } = string.Empty;

/// <summary>
/// Arguments for the MCP tool call.
/// </summary>
[JsonPropertyName("arguments")]
public JsonElement? Arguments { get; set; }

/// <summary>
/// Tool call ID, if available.
/// </summary>
[JsonPropertyName("toolCallId")]
public string? ToolCallId { get; set; }

/// <summary>
/// MCP request metadata, if present.
/// </summary>
[JsonPropertyName("_meta")]
public IDictionary<string, JsonElement>? Meta { get; set; }
}

/// <summary>
/// Output for a pre-MCP-tool-call hook.
/// </summary>
/// <remarks>
/// <para>The <see cref="MetaToUse"/> property controls outgoing MCP request metadata:</para>
/// <list type="bullet">
/// <item><description>Return <c>null</c> from the hook handler: preserve existing <c>_meta</c> (no-op).</description></item>
/// <item><description>Return a <see cref="PreMcpToolCallHookOutput"/> with <see cref="MetaToUse"/> left as <c>null</c>: omit <c>_meta</c> from the request.</description></item>
/// <item><description>Return a <see cref="PreMcpToolCallHookOutput"/> with <see cref="MetaToUse"/> set to a <see cref="JsonElement"/> object: replace <c>_meta</c> with that object.</description></item>
/// </list>
/// </remarks>
public sealed class PreMcpToolCallHookOutput
{
/// <summary>
/// Hook-controlled metadata to use for the outgoing MCP request.
/// See class remarks for semantics.
/// </summary>
[JsonPropertyName("metaToUse")]
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public JsonElement? MetaToUse { get; set; }
}

/// <summary>
/// Input for a post-tool-use hook.
/// </summary>
Expand All @@ -1293,7 +1371,7 @@ public sealed class PostToolUseHookInput
/// Current working directory of the session.
/// </summary>
[JsonPropertyName("cwd")]
public string Cwd { get; set; } = string.Empty;
public string WorkingDirectory { get; set; } = string.Empty;

/// <summary>
/// Name of the tool that was executed.
Expand Down Expand Up @@ -1360,7 +1438,7 @@ public sealed class UserPromptSubmittedHookInput
/// Current working directory of the session.
/// </summary>
[JsonPropertyName("cwd")]
public string Cwd { get; set; } = string.Empty;
public string WorkingDirectory { get; set; } = string.Empty;

/// <summary>
/// The user's prompt text.
Expand Down Expand Up @@ -1415,7 +1493,7 @@ public sealed class SessionStartHookInput
/// Current working directory of the session.
/// </summary>
[JsonPropertyName("cwd")]
public string Cwd { get; set; } = string.Empty;
public string WorkingDirectory { get; set; } = string.Empty;

/// <summary>
/// Source of the session start.
Expand Down Expand Up @@ -1475,7 +1553,7 @@ public sealed class SessionEndHookInput
/// Current working directory of the session.
/// </summary>
[JsonPropertyName("cwd")]
public string Cwd { get; set; } = string.Empty;
public string WorkingDirectory { get; set; } = string.Empty;

/// <summary>
/// Reason for session end.
Expand Down Expand Up @@ -1549,7 +1627,7 @@ public sealed class ErrorOccurredHookInput
/// Current working directory of the session.
/// </summary>
[JsonPropertyName("cwd")]
public string Cwd { get; set; } = string.Empty;
public string WorkingDirectory { get; set; } = string.Empty;

/// <summary>
/// Error message describing what went wrong.
Expand Down Expand Up @@ -1621,6 +1699,11 @@ public sealed class SessionHooks
/// </summary>
public Func<PreToolUseHookInput, HookInvocation, Task<PreToolUseHookOutput?>>? OnPreToolUse { get; set; }

/// <summary>
/// Handler called before an MCP tool is called.
/// </summary>
public Func<PreMcpToolCallHookInput, HookInvocation, Task<PreMcpToolCallHookOutput?>>? OnPreMcpToolCall { get; set; }

/// <summary>
/// Handler called after a tool has been executed.
/// </summary>
Expand Down Expand Up @@ -2518,7 +2601,8 @@ public MessageOptions Clone()
public sealed class SessionContext
{
/// <summary>Working directory where the session was created.</summary>
public string Cwd { get; set; } = string.Empty;
[JsonPropertyName("cwd")]
public string WorkingDirectory { get; set; } = string.Empty;
/// <summary>Git repository root (if in a git repo).</summary>
public string? GitRoot { get; set; }
/// <summary>GitHub repository in "owner/repo" format.</summary>
Expand Down
8 changes: 4 additions & 4 deletions dotnet/test/E2E/HookLifecycleAndOutputE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public async Task Should_Invoke_OnSessionStart_Hook_On_New_Session()
Assert.NotEmpty(sessionStartInputs);
Assert.Equal("new", sessionStartInputs[0].Source);
Assert.True(sessionStartInputs[0].Timestamp > DateTimeOffset.UnixEpoch);
Assert.False(string.IsNullOrEmpty(sessionStartInputs[0].Cwd));
Assert.False(string.IsNullOrEmpty(sessionStartInputs[0].WorkingDirectory));

await session.DisposeAsync();
}
Expand Down Expand Up @@ -73,7 +73,7 @@ public async Task Should_Invoke_OnUserPromptSubmitted_Hook_When_Sending_A_Messag
Assert.NotEmpty(userPromptInputs);
Assert.Contains("Say hello", userPromptInputs[0].Prompt);
Assert.True(userPromptInputs[0].Timestamp > DateTimeOffset.UnixEpoch);
Assert.False(string.IsNullOrEmpty(userPromptInputs[0].Cwd));
Assert.False(string.IsNullOrEmpty(userPromptInputs[0].WorkingDirectory));

await session.DisposeAsync();
}
Expand Down Expand Up @@ -118,7 +118,7 @@ public async Task Should_Invoke_OnErrorOccurred_Hook_When_Error_Occurs()
{
Assert.Equal(session!.SessionId, invocation.SessionId);
Assert.True(input.Timestamp > DateTimeOffset.UnixEpoch);
Assert.False(string.IsNullOrEmpty(input.Cwd));
Assert.False(string.IsNullOrEmpty(input.WorkingDirectory));
Assert.False(string.IsNullOrEmpty(input.Error));
Assert.Contains(input.ErrorContext, ValidErrorContexts);
return Task.FromResult<ErrorOccurredHookOutput?>(null);
Expand Down Expand Up @@ -188,7 +188,7 @@ public async Task Should_Invoke_SessionStart_Hook()

Assert.NotEmpty(inputs);
Assert.Equal("new", inputs[0].Source);
Assert.False(string.IsNullOrEmpty(inputs[0].Cwd));
Assert.False(string.IsNullOrEmpty(inputs[0].WorkingDirectory));
}

[Fact]
Expand Down
Loading
Loading