Skip to content
Open
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
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