Skip to content

Commit 2fa6a92

Browse files
authored
feat: add hooks and user input handlers to all SDKs with e2e tests (github#269)
* feat(auth): add githubToken and useLoggedInUser options to all SDK clients Enable SDK clients to customize authentication when spawning the CLI server. Node.js: - Add githubToken and useLoggedInUser options to CopilotClientOptions - Set COPILOT_SDK_AUTH_TOKEN env var and pass --auth-token-env flag - Pass --no-auto-login when useLoggedInUser is false - Default useLoggedInUser to false when githubToken is provided Python: - Add github_token and use_logged_in_user options - Same behavior as Node.js SDK Go: - Add GithubToken and UseLoggedInUser fields to ClientOptions - Same behavior as Node.js SDK .NET: - Add GithubToken and UseLoggedInUser properties to CopilotClientOptions - Same behavior as Node.js SDK All SDKs include validation to prevent use with cliUrl (external server) and tests for the new options. * feat: add hooks and user input handlers to all SDKs with e2e tests - Add preToolUse, postToolUse, and other hook callbacks to Node.js, Python, Go, .NET SDKs - Add requestUserInput callback (ask_user) to all SDKs - Fix .NET SDK bug: StreamJsonRpc requires explicit = null defaults for optional parameters - Add e2e tests for hooks (4 tests) and ask-user (3 tests) in all SDKs - Create shared test snapshots for consistent LLM responses across SDKs
1 parent 2fe7352 commit 2fa6a92

File tree

43 files changed

+3957
-61
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+3957
-61
lines changed

.vscode/settings.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,8 @@
1313
},
1414
"python.testing.pytestEnabled": true,
1515
"python.testing.unittestEnabled": false,
16-
"python.testing.pytestArgs": ["python"]
16+
"python.testing.pytestArgs": ["python"],
17+
"[python]": {
18+
"editor.defaultFormatter": "charliermarsh.ruff"
19+
}
1720
}

dotnet/src/Client.cs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,14 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig? config = nul
336336
{
337337
var connection = await EnsureConnectedAsync(cancellationToken);
338338

339+
var hasHooks = config?.Hooks != null && (
340+
config.Hooks.OnPreToolUse != null ||
341+
config.Hooks.OnPostToolUse != null ||
342+
config.Hooks.OnUserPromptSubmitted != null ||
343+
config.Hooks.OnSessionStart != null ||
344+
config.Hooks.OnSessionEnd != null ||
345+
config.Hooks.OnErrorOccurred != null);
346+
339347
var request = new CreateSessionRequest(
340348
config?.Model,
341349
config?.SessionId,
@@ -345,6 +353,9 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig? config = nul
345353
config?.ExcludedTools,
346354
config?.Provider,
347355
config?.OnPermissionRequest != null ? true : null,
356+
config?.OnUserInputRequest != null ? true : null,
357+
hasHooks ? true : null,
358+
config?.WorkingDirectory,
348359
config?.Streaming == true ? true : null,
349360
config?.McpServers,
350361
config?.CustomAgents,
@@ -362,6 +373,14 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig? config = nul
362373
{
363374
session.RegisterPermissionHandler(config.OnPermissionRequest);
364375
}
376+
if (config?.OnUserInputRequest != null)
377+
{
378+
session.RegisterUserInputHandler(config.OnUserInputRequest);
379+
}
380+
if (config?.Hooks != null)
381+
{
382+
session.RegisterHooks(config.Hooks);
383+
}
365384

366385
if (!_sessions.TryAdd(response.SessionId, session))
367386
{
@@ -399,11 +418,23 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
399418
{
400419
var connection = await EnsureConnectedAsync(cancellationToken);
401420

421+
var hasHooks = config?.Hooks != null && (
422+
config.Hooks.OnPreToolUse != null ||
423+
config.Hooks.OnPostToolUse != null ||
424+
config.Hooks.OnUserPromptSubmitted != null ||
425+
config.Hooks.OnSessionStart != null ||
426+
config.Hooks.OnSessionEnd != null ||
427+
config.Hooks.OnErrorOccurred != null);
428+
402429
var request = new ResumeSessionRequest(
403430
sessionId,
404431
config?.Tools?.Select(ToolDefinition.FromAIFunction).ToList(),
405432
config?.Provider,
406433
config?.OnPermissionRequest != null ? true : null,
434+
config?.OnUserInputRequest != null ? true : null,
435+
hasHooks ? true : null,
436+
config?.WorkingDirectory,
437+
config?.DisableResume == true ? true : null,
407438
config?.Streaming == true ? true : null,
408439
config?.McpServers,
409440
config?.CustomAgents,
@@ -419,6 +450,14 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
419450
{
420451
session.RegisterPermissionHandler(config.OnPermissionRequest);
421452
}
453+
if (config?.OnUserInputRequest != null)
454+
{
455+
session.RegisterUserInputHandler(config.OnUserInputRequest);
456+
}
457+
if (config?.Hooks != null)
458+
{
459+
session.RegisterHooks(config.Hooks);
460+
}
422461

423462
// Replace any existing session entry to ensure new config (like permission handler) is used
424463
_sessions[response.SessionId] = session;
@@ -804,6 +843,8 @@ private async Task<Connection> ConnectToServerAsync(Process? cliProcess, string?
804843
rpc.AddLocalRpcMethod("session.event", handler.OnSessionEvent);
805844
rpc.AddLocalRpcMethod("tool.call", handler.OnToolCall);
806845
rpc.AddLocalRpcMethod("permission.request", handler.OnPermissionRequest);
846+
rpc.AddLocalRpcMethod("userInput.request", handler.OnUserInputRequest);
847+
rpc.AddLocalRpcMethod("hooks.invoke", handler.OnHooksInvoke);
807848
rpc.StartListening();
808849
return new Connection(rpc, cliProcess, tcpClient, networkStream);
809850
}
@@ -990,6 +1031,37 @@ public async Task<PermissionRequestResponse> OnPermissionRequest(string sessionI
9901031
});
9911032
}
9921033
}
1034+
1035+
public async Task<UserInputRequestResponse> OnUserInputRequest(string sessionId, string question, List<string>? choices = null, bool? allowFreeform = null)
1036+
{
1037+
var session = client.GetSession(sessionId);
1038+
if (session == null)
1039+
{
1040+
throw new ArgumentException($"Unknown session {sessionId}");
1041+
}
1042+
1043+
var request = new UserInputRequest
1044+
{
1045+
Question = question,
1046+
Choices = choices,
1047+
AllowFreeform = allowFreeform
1048+
};
1049+
1050+
var result = await session.HandleUserInputRequestAsync(request);
1051+
return new UserInputRequestResponse(result.Answer, result.WasFreeform);
1052+
}
1053+
1054+
public async Task<HooksInvokeResponse> OnHooksInvoke(string sessionId, string hookType, JsonElement input)
1055+
{
1056+
var session = client.GetSession(sessionId);
1057+
if (session == null)
1058+
{
1059+
throw new ArgumentException($"Unknown session {sessionId}");
1060+
}
1061+
1062+
var output = await session.HandleHooksInvokeAsync(hookType, input);
1063+
return new HooksInvokeResponse(output);
1064+
}
9931065
}
9941066

9951067
private class Connection(
@@ -1024,6 +1096,9 @@ internal record CreateSessionRequest(
10241096
List<string>? ExcludedTools,
10251097
ProviderConfig? Provider,
10261098
bool? RequestPermission,
1099+
bool? RequestUserInput,
1100+
bool? Hooks,
1101+
string? WorkingDirectory,
10271102
bool? Streaming,
10281103
Dictionary<string, object>? McpServers,
10291104
List<CustomAgentConfig>? CustomAgents,
@@ -1050,6 +1125,10 @@ internal record ResumeSessionRequest(
10501125
List<ToolDefinition>? Tools,
10511126
ProviderConfig? Provider,
10521127
bool? RequestPermission,
1128+
bool? RequestUserInput,
1129+
bool? Hooks,
1130+
string? WorkingDirectory,
1131+
bool? DisableResume,
10531132
bool? Streaming,
10541133
Dictionary<string, object>? McpServers,
10551134
List<CustomAgentConfig>? CustomAgents,
@@ -1079,6 +1158,13 @@ internal record ToolCallResponse(
10791158
internal record PermissionRequestResponse(
10801159
PermissionRequestResult Result);
10811160

1161+
internal record UserInputRequestResponse(
1162+
string Answer,
1163+
bool WasFreeform);
1164+
1165+
internal record HooksInvokeResponse(
1166+
object? Output);
1167+
10821168
/// <summary>Trace source that forwards all logs to the ILogger.</summary>
10831169
internal sealed class LoggerTraceSource : TraceSource
10841170
{
@@ -1131,6 +1217,7 @@ public override void WriteLine(string? message) =>
11311217
[JsonSerializable(typeof(DeleteSessionRequest))]
11321218
[JsonSerializable(typeof(DeleteSessionResponse))]
11331219
[JsonSerializable(typeof(GetLastSessionIdResponse))]
1220+
[JsonSerializable(typeof(HooksInvokeResponse))]
11341221
[JsonSerializable(typeof(ListSessionsResponse))]
11351222
[JsonSerializable(typeof(PermissionRequestResponse))]
11361223
[JsonSerializable(typeof(PermissionRequestResult))]
@@ -1143,6 +1230,9 @@ public override void WriteLine(string? message) =>
11431230
[JsonSerializable(typeof(ToolDefinition))]
11441231
[JsonSerializable(typeof(ToolResultAIContent))]
11451232
[JsonSerializable(typeof(ToolResultObject))]
1233+
[JsonSerializable(typeof(UserInputRequestResponse))]
1234+
[JsonSerializable(typeof(UserInputRequest))]
1235+
[JsonSerializable(typeof(UserInputResponse))]
11461236
internal partial class ClientJsonContext : JsonSerializerContext;
11471237
}
11481238

dotnet/src/Session.cs

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ public partial class CopilotSession : IAsyncDisposable
4848
private readonly JsonRpc _rpc;
4949
private PermissionHandler? _permissionHandler;
5050
private readonly SemaphoreSlim _permissionHandlerLock = new(1, 1);
51+
private UserInputHandler? _userInputHandler;
52+
private readonly SemaphoreSlim _userInputHandlerLock = new(1, 1);
53+
private SessionHooks? _hooks;
54+
private readonly SemaphoreSlim _hooksLock = new(1, 1);
5155

5256
/// <summary>
5357
/// Gets the unique identifier for this session.
@@ -330,6 +334,136 @@ internal async Task<PermissionRequestResult> HandlePermissionRequestAsync(JsonEl
330334
return await handler(request, invocation);
331335
}
332336

337+
/// <summary>
338+
/// Registers a handler for user input requests from the agent.
339+
/// </summary>
340+
/// <param name="handler">The handler to invoke when user input is requested.</param>
341+
internal void RegisterUserInputHandler(UserInputHandler handler)
342+
{
343+
_userInputHandlerLock.Wait();
344+
try
345+
{
346+
_userInputHandler = handler;
347+
}
348+
finally
349+
{
350+
_userInputHandlerLock.Release();
351+
}
352+
}
353+
354+
/// <summary>
355+
/// Handles a user input request from the Copilot CLI.
356+
/// </summary>
357+
/// <param name="request">The user input request from the CLI.</param>
358+
/// <returns>A task that resolves with the user's response.</returns>
359+
internal async Task<UserInputResponse> HandleUserInputRequestAsync(UserInputRequest request)
360+
{
361+
await _userInputHandlerLock.WaitAsync();
362+
UserInputHandler? handler;
363+
try
364+
{
365+
handler = _userInputHandler;
366+
}
367+
finally
368+
{
369+
_userInputHandlerLock.Release();
370+
}
371+
372+
if (handler == null)
373+
{
374+
throw new InvalidOperationException("No user input handler registered");
375+
}
376+
377+
var invocation = new UserInputInvocation
378+
{
379+
SessionId = SessionId
380+
};
381+
382+
return await handler(request, invocation);
383+
}
384+
385+
/// <summary>
386+
/// Registers hook handlers for this session.
387+
/// </summary>
388+
/// <param name="hooks">The hooks configuration.</param>
389+
internal void RegisterHooks(SessionHooks hooks)
390+
{
391+
_hooksLock.Wait();
392+
try
393+
{
394+
_hooks = hooks;
395+
}
396+
finally
397+
{
398+
_hooksLock.Release();
399+
}
400+
}
401+
402+
/// <summary>
403+
/// Handles a hook invocation from the Copilot CLI.
404+
/// </summary>
405+
/// <param name="hookType">The type of hook to invoke.</param>
406+
/// <param name="input">The hook input data.</param>
407+
/// <returns>A task that resolves with the hook output.</returns>
408+
internal async Task<object?> HandleHooksInvokeAsync(string hookType, JsonElement input)
409+
{
410+
await _hooksLock.WaitAsync();
411+
SessionHooks? hooks;
412+
try
413+
{
414+
hooks = _hooks;
415+
}
416+
finally
417+
{
418+
_hooksLock.Release();
419+
}
420+
421+
if (hooks == null)
422+
{
423+
return null;
424+
}
425+
426+
var invocation = new HookInvocation
427+
{
428+
SessionId = SessionId
429+
};
430+
431+
return hookType switch
432+
{
433+
"preToolUse" => hooks.OnPreToolUse != null
434+
? await hooks.OnPreToolUse(
435+
JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.PreToolUseHookInput)!,
436+
invocation)
437+
: null,
438+
"postToolUse" => hooks.OnPostToolUse != null
439+
? await hooks.OnPostToolUse(
440+
JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.PostToolUseHookInput)!,
441+
invocation)
442+
: null,
443+
"userPromptSubmitted" => hooks.OnUserPromptSubmitted != null
444+
? await hooks.OnUserPromptSubmitted(
445+
JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.UserPromptSubmittedHookInput)!,
446+
invocation)
447+
: null,
448+
"sessionStart" => hooks.OnSessionStart != null
449+
? await hooks.OnSessionStart(
450+
JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.SessionStartHookInput)!,
451+
invocation)
452+
: null,
453+
"sessionEnd" => hooks.OnSessionEnd != null
454+
? await hooks.OnSessionEnd(
455+
JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.SessionEndHookInput)!,
456+
invocation)
457+
: null,
458+
"errorOccurred" => hooks.OnErrorOccurred != null
459+
? await hooks.OnErrorOccurred(
460+
JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.ErrorOccurredHookInput)!,
461+
invocation)
462+
: null,
463+
_ => throw new ArgumentException($"Unknown hook type: {hookType}")
464+
};
465+
}
466+
333467
/// <summary>
334468
/// Gets the complete list of messages and events in the session.
335469
/// </summary>
@@ -487,5 +621,17 @@ internal record SessionDestroyRequest
487621
[JsonSerializable(typeof(SessionAbortRequest))]
488622
[JsonSerializable(typeof(SessionDestroyRequest))]
489623
[JsonSerializable(typeof(UserMessageDataAttachmentsItem))]
624+
[JsonSerializable(typeof(PreToolUseHookInput))]
625+
[JsonSerializable(typeof(PreToolUseHookOutput))]
626+
[JsonSerializable(typeof(PostToolUseHookInput))]
627+
[JsonSerializable(typeof(PostToolUseHookOutput))]
628+
[JsonSerializable(typeof(UserPromptSubmittedHookInput))]
629+
[JsonSerializable(typeof(UserPromptSubmittedHookOutput))]
630+
[JsonSerializable(typeof(SessionStartHookInput))]
631+
[JsonSerializable(typeof(SessionStartHookOutput))]
632+
[JsonSerializable(typeof(SessionEndHookInput))]
633+
[JsonSerializable(typeof(SessionEndHookOutput))]
634+
[JsonSerializable(typeof(ErrorOccurredHookInput))]
635+
[JsonSerializable(typeof(ErrorOccurredHookOutput))]
490636
internal partial class SessionJsonContext : JsonSerializerContext;
491637
}

0 commit comments

Comments
 (0)