-
Notifications
You must be signed in to change notification settings - Fork 981
Add shell notification types and handlers across all SDKs #790
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -67,6 +67,12 @@ public sealed partial class CopilotSession : IAsyncDisposable | |||||||||||||
| private readonly SemaphoreSlim _hooksLock = new(1, 1); | ||||||||||||||
| private SessionRpc? _sessionRpc; | ||||||||||||||
| private int _isDisposed; | ||||||||||||||
| private event Action<ShellOutputNotification>? ShellOutputHandlers; | ||||||||||||||
| private event Action<ShellExitNotification>? ShellExitHandlers; | ||||||||||||||
| private readonly HashSet<string> _trackedProcessIds = []; | ||||||||||||||
| private readonly object _trackedProcessIdsLock = new(); | ||||||||||||||
| private Action<string, CopilotSession>? _registerShellProcess; | ||||||||||||||
| private Action<string>? _unregisterShellProcess; | ||||||||||||||
|
|
||||||||||||||
| /// <summary> | ||||||||||||||
| /// Channel that serializes event dispatch. <see cref="DispatchEvent"/> enqueues; | ||||||||||||||
|
|
@@ -278,6 +284,52 @@ public IDisposable On(SessionEventHandler handler) | |||||||||||||
| return new ActionDisposable(() => ImmutableInterlocked.Update(ref _eventHandlers, array => array.Remove(handler))); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| /// <summary> | ||||||||||||||
| /// Subscribes to shell output notifications for this session. | ||||||||||||||
| /// </summary> | ||||||||||||||
| /// <param name="handler">A callback that receives shell output notifications.</param> | ||||||||||||||
| /// <returns>An <see cref="IDisposable"/> that unsubscribes the handler when disposed.</returns> | ||||||||||||||
| /// <remarks> | ||||||||||||||
| /// Shell output notifications are streamed in chunks when commands started | ||||||||||||||
| /// via <c>session.Rpc.Shell.ExecAsync()</c> produce stdout or stderr output. | ||||||||||||||
| /// </remarks> | ||||||||||||||
| /// <example> | ||||||||||||||
| /// <code> | ||||||||||||||
| /// using var sub = session.OnShellOutput(n => | ||||||||||||||
| /// { | ||||||||||||||
| /// Console.WriteLine($"[{n.ProcessId}:{n.Stream}] {n.Data}"); | ||||||||||||||
| /// }); | ||||||||||||||
| /// </code> | ||||||||||||||
| /// </example> | ||||||||||||||
| public IDisposable OnShellOutput(Action<ShellOutputNotification> handler) | ||||||||||||||
| { | ||||||||||||||
| ShellOutputHandlers += handler; | ||||||||||||||
| return new ActionDisposable(() => ShellOutputHandlers -= handler); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| /// <summary> | ||||||||||||||
| /// Subscribes to shell exit notifications for this session. | ||||||||||||||
| /// </summary> | ||||||||||||||
| /// <param name="handler">A callback that receives shell exit notifications.</param> | ||||||||||||||
| /// <returns>An <see cref="IDisposable"/> that unsubscribes the handler when disposed.</returns> | ||||||||||||||
| /// <remarks> | ||||||||||||||
| /// Shell exit notifications are sent when commands started via | ||||||||||||||
| /// <c>session.Rpc.Shell.ExecAsync()</c> complete (after all output has been streamed). | ||||||||||||||
|
Comment on lines
+316
to
+317
|
||||||||||||||
| /// Shell exit notifications are sent when commands started via | |
| /// <c>session.Rpc.Shell.ExecAsync()</c> complete (after all output has been streamed). | |
| /// Shell exit notifications are sent when shell commands started via the session's RPC | |
| /// surface complete (after all output has been streamed). The notification's | |
| /// <see cref="ShellExitNotification.ProcessId"/> matches the <c>processId</c> returned | |
| /// when the corresponding shell command was started. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -91,6 +91,8 @@ type Client struct { | |
| lifecycleHandlers []SessionLifecycleHandler | ||
| typedLifecycleHandlers map[SessionLifecycleEventType][]SessionLifecycleHandler | ||
| lifecycleHandlersMux sync.Mutex | ||
| shellProcessMap map[string]*Session | ||
| shellProcessMapMux sync.Mutex | ||
| startStopMux sync.RWMutex // protects process and state during start/[force]stop | ||
| processDone chan struct{} | ||
| processErrorPtr *error | ||
|
|
@@ -130,6 +132,7 @@ func NewClient(options *ClientOptions) *Client { | |
| options: opts, | ||
| state: StateDisconnected, | ||
| sessions: make(map[string]*Session), | ||
| shellProcessMap: make(map[string]*Session), | ||
| actualHost: "localhost", | ||
| isExternalServer: false, | ||
| useStdio: true, | ||
|
|
@@ -535,6 +538,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses | |
| // Create and register the session before issuing the RPC so that | ||
| // events emitted by the CLI (e.g. session.start) are not dropped. | ||
| session := newSession(sessionID, c.client, "") | ||
| session.setShellProcessCallbacks(c.registerShellProcess, c.unregisterShellProcess) | ||
|
|
||
| session.registerTools(config.Tools) | ||
| session.registerPermissionHandler(config.OnPermissionRequest) | ||
|
|
@@ -648,6 +652,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, | |
| // Create and register the session before issuing the RPC so that | ||
| // events emitted by the CLI (e.g. session.start) are not dropped. | ||
| session := newSession(sessionID, c.client, "") | ||
| session.setShellProcessCallbacks(c.registerShellProcess, c.unregisterShellProcess) | ||
|
|
||
| session.registerTools(config.Tools) | ||
| session.registerPermissionHandler(config.OnPermissionRequest) | ||
|
|
@@ -1379,6 +1384,8 @@ func (c *Client) setupNotificationHandler() { | |
| c.client.SetRequestHandler("permission.request", jsonrpc2.RequestHandlerFor(c.handlePermissionRequestV2)) | ||
| c.client.SetRequestHandler("userInput.request", jsonrpc2.RequestHandlerFor(c.handleUserInputRequest)) | ||
| c.client.SetRequestHandler("hooks.invoke", jsonrpc2.RequestHandlerFor(c.handleHooksInvoke)) | ||
| c.client.SetRequestHandler("shell.output", jsonrpc2.NotificationHandlerFor(c.handleShellOutput)) | ||
| c.client.SetRequestHandler("shell.exit", jsonrpc2.NotificationHandlerFor(c.handleShellExit)) | ||
| } | ||
|
|
||
| func (c *Client) handleSessionEvent(req sessionEventRequest) { | ||
|
|
@@ -1395,6 +1402,43 @@ func (c *Client) handleSessionEvent(req sessionEventRequest) { | |
| } | ||
| } | ||
|
|
||
| func (c *Client) handleShellOutput(notification ShellOutputNotification) { | ||
| c.shellProcessMapMux.Lock() | ||
| session, ok := c.shellProcessMap[notification.ProcessID] | ||
| c.shellProcessMapMux.Unlock() | ||
|
|
||
| if ok { | ||
| session.dispatchShellOutput(notification) | ||
| } | ||
| } | ||
|
|
||
| func (c *Client) handleShellExit(notification ShellExitNotification) { | ||
| c.shellProcessMapMux.Lock() | ||
| session, ok := c.shellProcessMap[notification.ProcessID] | ||
| c.shellProcessMapMux.Unlock() | ||
|
|
||
| if ok { | ||
| session.dispatchShellExit(notification) | ||
| // Clean up the mapping after exit | ||
| c.shellProcessMapMux.Lock() | ||
| delete(c.shellProcessMap, notification.ProcessID) | ||
| c.shellProcessMapMux.Unlock() | ||
| session.untrackShellProcess(notification.ProcessID) | ||
| } | ||
|
Comment on lines
+1405
to
+1427
|
||
| } | ||
|
|
||
| func (c *Client) registerShellProcess(processID string, session *Session) { | ||
| c.shellProcessMapMux.Lock() | ||
| c.shellProcessMap[processID] = session | ||
| c.shellProcessMapMux.Unlock() | ||
| } | ||
|
|
||
| func (c *Client) unregisterShellProcess(processID string) { | ||
| c.shellProcessMapMux.Lock() | ||
| delete(c.shellProcessMap, processID) | ||
| c.shellProcessMapMux.Unlock() | ||
| } | ||
|
|
||
| // handleUserInputRequest handles a user input request from the CLI server. | ||
| func (c *Client) handleUserInputRequest(req userInputRequest) (*userInputResponse, *jsonrpc2.Error) { | ||
| if req.SessionID == "" || req.Question == "" { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_shellProcessMapis the only routing mechanism forshell.output/shell.exit, but nothing in the .NET SDK callsCopilotSession.TrackShellProcess(...)(search shows only the method definition). This means these notifications will be dropped andOnShellOutput/OnShellExitwon’t fire. The process registration needs to happen when starting a shell command (track immediately after receivingprocessId), or the server needs to includesessionIdin these notifications so routing doesn’t depend on prior registration.