diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index 9cee4209..01911d58 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -192,6 +192,28 @@ public class AccountGetQuotaResult public Dictionary QuotaSnapshots { get; set; } = []; } +public class SessionLogResult +{ + /// The unique identifier of the emitted session event + [JsonPropertyName("eventId")] + public Guid EventId { get; set; } +} + +internal class SessionLogRequest +{ + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; + + [JsonPropertyName("level")] + public SessionLogRequestLevel? Level { get; set; } + + [JsonPropertyName("ephemeral")] + public bool? Ephemeral { get; set; } +} + public class SessionModelGetCurrentResult { [JsonPropertyName("modelId")] @@ -217,6 +239,9 @@ internal class SessionModelSwitchToRequest [JsonPropertyName("modelId")] public string ModelId { get; set; } = string.Empty; + + [JsonPropertyName("reasoningEffort")] + public SessionModelSwitchToRequestReasoningEffort? ReasoningEffort { get; set; } } public class SessionModeGetResult @@ -511,6 +536,32 @@ internal class SessionPermissionsHandlePendingPermissionRequestRequest public object Result { get; set; } = null!; } +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SessionLogRequestLevel +{ + [JsonStringEnumMemberName("info")] + Info, + [JsonStringEnumMemberName("warning")] + Warning, + [JsonStringEnumMemberName("error")] + Error, +} + + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SessionModelSwitchToRequestReasoningEffort +{ + [JsonStringEnumMemberName("low")] + Low, + [JsonStringEnumMemberName("medium")] + Medium, + [JsonStringEnumMemberName("high")] + High, + [JsonStringEnumMemberName("xhigh")] + Xhigh, +} + + [JsonConverter(typeof(JsonStringEnumConverter))] public enum SessionModeGetResultMode { @@ -643,6 +694,13 @@ internal SessionRpc(JsonRpc rpc, string sessionId) public ToolsApi Tools { get; } public PermissionsApi Permissions { get; } + + /// Calls "session.log". + public async Task LogAsync(string message, SessionLogRequestLevel? level = null, bool? ephemeral = null, CancellationToken cancellationToken = default) + { + var request = new SessionLogRequest { SessionId = _sessionId, Message = message, Level = level, Ephemeral = ephemeral }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.log", [request], cancellationToken); + } } public class ModelApi @@ -664,9 +722,9 @@ public async Task GetCurrentAsync(CancellationToke } /// Calls "session.model.switchTo". - public async Task SwitchToAsync(string modelId, CancellationToken cancellationToken = default) + public async Task SwitchToAsync(string modelId, SessionModelSwitchToRequestReasoningEffort? reasoningEffort = null, CancellationToken cancellationToken = default) { - var request = new SessionModelSwitchToRequest { SessionId = _sessionId, ModelId = modelId }; + var request = new SessionModelSwitchToRequest { SessionId = _sessionId, ModelId = modelId, ReasoningEffort = reasoningEffort }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.model.switchTo", [request], cancellationToken); } } @@ -909,6 +967,8 @@ public async Task Handle [JsonSerializable(typeof(SessionCompactionCompactResult))] [JsonSerializable(typeof(SessionFleetStartRequest))] [JsonSerializable(typeof(SessionFleetStartResult))] +[JsonSerializable(typeof(SessionLogRequest))] +[JsonSerializable(typeof(SessionLogResult))] [JsonSerializable(typeof(SessionModeGetRequest))] [JsonSerializable(typeof(SessionModeGetResult))] [JsonSerializable(typeof(SessionModeSetRequest))] diff --git a/dotnet/src/Generated/SessionEvents.cs b/dotnet/src/Generated/SessionEvents.cs index f87ab32d..5bdf50df 100644 --- a/dotnet/src/Generated/SessionEvents.cs +++ b/dotnet/src/Generated/SessionEvents.cs @@ -69,6 +69,7 @@ namespace GitHub.Copilot.SDK; [JsonDerivedType(typeof(SubagentSelectedEvent), "subagent.selected")] [JsonDerivedType(typeof(SubagentStartedEvent), "subagent.started")] [JsonDerivedType(typeof(SystemMessageEvent), "system.message")] +[JsonDerivedType(typeof(SystemNotificationEvent), "system.notification")] [JsonDerivedType(typeof(ToolExecutionCompleteEvent), "tool.execution_complete")] [JsonDerivedType(typeof(ToolExecutionPartialResultEvent), "tool.execution_partial_result")] [JsonDerivedType(typeof(ToolExecutionProgressEvent), "tool.execution_progress")] @@ -657,6 +658,18 @@ public partial class SystemMessageEvent : SessionEvent public required SystemMessageData Data { get; set; } } +/// +/// Event: system.notification +/// +public partial class SystemNotificationEvent : SessionEvent +{ + [JsonIgnore] + public override string Type => "system.notification"; + + [JsonPropertyName("data")] + public required SystemNotificationData Data { get; set; } +} + /// /// Event: permission.requested /// @@ -825,6 +838,10 @@ public partial class SessionStartData [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("context")] public SessionStartDataContext? Context { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("alreadyInUse")] + public bool? AlreadyInUse { get; set; } } public partial class SessionResumeData @@ -838,6 +855,10 @@ public partial class SessionResumeData [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("context")] public SessionResumeDataContext? Context { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("alreadyInUse")] + public bool? AlreadyInUse { get; set; } } public partial class SessionErrorData @@ -1522,6 +1543,15 @@ public partial class SystemMessageData public SystemMessageDataMetadata? Metadata { get; set; } } +public partial class SystemNotificationData +{ + [JsonPropertyName("content")] + public required string Content { get; set; } + + [JsonPropertyName("kind")] + public required SystemNotificationDataKind Kind { get; set; } +} + public partial class PermissionRequestedData { [JsonPropertyName("requestId")] @@ -2095,6 +2125,72 @@ public partial class SystemMessageDataMetadata public Dictionary? Variables { get; set; } } +public partial class SystemNotificationDataKindAgentCompleted : SystemNotificationDataKind +{ + [JsonIgnore] + public override string Type => "agent_completed"; + + [JsonPropertyName("agentId")] + public required string AgentId { get; set; } + + [JsonPropertyName("agentType")] + public required string AgentType { get; set; } + + [JsonPropertyName("status")] + public required SystemNotificationDataKindAgentCompletedStatus Status { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("prompt")] + public string? Prompt { get; set; } +} + +public partial class SystemNotificationDataKindShellCompleted : SystemNotificationDataKind +{ + [JsonIgnore] + public override string Type => "shell_completed"; + + [JsonPropertyName("shellId")] + public required string ShellId { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("exitCode")] + public double? ExitCode { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("description")] + public string? Description { get; set; } +} + +public partial class SystemNotificationDataKindShellDetachedCompleted : SystemNotificationDataKind +{ + [JsonIgnore] + public override string Type => "shell_detached_completed"; + + [JsonPropertyName("shellId")] + public required string ShellId { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("description")] + public string? Description { get; set; } +} + +[JsonPolymorphic( + TypeDiscriminatorPropertyName = "type", + UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToBaseType)] +[JsonDerivedType(typeof(SystemNotificationDataKindAgentCompleted), "agent_completed")] +[JsonDerivedType(typeof(SystemNotificationDataKindShellCompleted), "shell_completed")] +[JsonDerivedType(typeof(SystemNotificationDataKindShellDetachedCompleted), "shell_detached_completed")] +public partial class SystemNotificationDataKind +{ + [JsonPropertyName("type")] + public virtual string Type { get; set; } = string.Empty; +} + + public partial class PermissionRequestShellCommandsItem { [JsonPropertyName("identifier")] @@ -2390,6 +2486,15 @@ public enum SystemMessageDataRole Developer, } +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SystemNotificationDataKindAgentCompletedStatus +{ + [JsonStringEnumMemberName("completed")] + Completed, + [JsonStringEnumMemberName("failed")] + Failed, +} + [JsonConverter(typeof(JsonStringEnumConverter))] public enum PermissionCompletedDataResultKind { @@ -2536,6 +2641,12 @@ public enum PermissionCompletedDataResultKind [JsonSerializable(typeof(SystemMessageData))] [JsonSerializable(typeof(SystemMessageDataMetadata))] [JsonSerializable(typeof(SystemMessageEvent))] +[JsonSerializable(typeof(SystemNotificationData))] +[JsonSerializable(typeof(SystemNotificationDataKind))] +[JsonSerializable(typeof(SystemNotificationDataKindAgentCompleted))] +[JsonSerializable(typeof(SystemNotificationDataKindShellCompleted))] +[JsonSerializable(typeof(SystemNotificationDataKindShellDetachedCompleted))] +[JsonSerializable(typeof(SystemNotificationEvent))] [JsonSerializable(typeof(ToolExecutionCompleteData))] [JsonSerializable(typeof(ToolExecutionCompleteDataError))] [JsonSerializable(typeof(ToolExecutionCompleteDataResult))] diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index 282fc50d..b9d70a2a 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -671,7 +671,29 @@ await InvokeRpcAsync( /// public async Task SetModelAsync(string model, CancellationToken cancellationToken = default) { - await Rpc.Model.SwitchToAsync(model, cancellationToken); + await Rpc.Model.SwitchToAsync(model, cancellationToken: cancellationToken); + } + + /// + /// Log a message to the session timeline. + /// The message appears in the session event stream and is visible to SDK consumers + /// and (for non-ephemeral messages) persisted to the session event log on disk. + /// + /// The message to log. + /// Log level (default: info). + /// When true, the message is not persisted to disk. + /// Optional cancellation token. + /// + /// + /// await session.LogAsync("Build completed successfully"); + /// await session.LogAsync("Disk space low", level: SessionLogRequestLevel.Warning); + /// await session.LogAsync("Connection failed", level: SessionLogRequestLevel.Error); + /// await session.LogAsync("Temporary status", ephemeral: true); + /// + /// + public async Task LogAsync(string message, SessionLogRequestLevel? level = null, bool? ephemeral = null, CancellationToken cancellationToken = default) + { + await Rpc.LogAsync(message, level, ephemeral, cancellationToken); } /// diff --git a/dotnet/test/SessionTests.cs b/dotnet/test/SessionTests.cs index e710835d..20d6f3ac 100644 --- a/dotnet/test/SessionTests.cs +++ b/dotnet/test/SessionTests.cs @@ -3,6 +3,7 @@ *--------------------------------------------------------------------------------------------*/ using GitHub.Copilot.SDK.Test.Harness; +using GitHub.Copilot.SDK.Rpc; using Microsoft.Extensions.AI; using System.ComponentModel; using Xunit; @@ -404,4 +405,51 @@ public async Task Should_Set_Model_On_Existing_Session() var modelChanged = await modelChangedTask; Assert.Equal("gpt-4.1", modelChanged.Data.NewModel); } + + [Fact] + public async Task Should_Log_Messages_At_Various_Levels() + { + var session = await CreateSessionAsync(); + var events = new List(); + session.On(evt => events.Add(evt)); + + await session.LogAsync("Info message"); + await session.LogAsync("Warning message", level: SessionLogRequestLevel.Warning); + await session.LogAsync("Error message", level: SessionLogRequestLevel.Error); + await session.LogAsync("Ephemeral message", ephemeral: true); + + // Poll until all 4 notification events arrive + await WaitForAsync(() => + { + var notifications = events.Where(e => + e is SessionInfoEvent info && info.Data.InfoType == "notification" || + e is SessionWarningEvent warn && warn.Data.WarningType == "notification" || + e is SessionErrorEvent err && err.Data.ErrorType == "notification" + ).ToList(); + return notifications.Count >= 4; + }, timeout: TimeSpan.FromSeconds(10)); + + var infoEvent = events.OfType().First(e => e.Data.Message == "Info message"); + Assert.Equal("notification", infoEvent.Data.InfoType); + + var warningEvent = events.OfType().First(e => e.Data.Message == "Warning message"); + Assert.Equal("notification", warningEvent.Data.WarningType); + + var errorEvent = events.OfType().First(e => e.Data.Message == "Error message"); + Assert.Equal("notification", errorEvent.Data.ErrorType); + + var ephemeralEvent = events.OfType().First(e => e.Data.Message == "Ephemeral message"); + Assert.Equal("notification", ephemeralEvent.Data.InfoType); + } + + private static async Task WaitForAsync(Func condition, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (!condition()) + { + if (DateTime.UtcNow > deadline) + throw new TimeoutException($"Condition not met within {timeout}"); + await Task.Delay(100); + } + } } diff --git a/go/generated_session_events.go b/go/generated_session_events.go index 86f5066f..72e428d1 100644 --- a/go/generated_session_events.go +++ b/go/generated_session_events.go @@ -56,6 +56,7 @@ type SessionEvent struct { // Empty payload; the event signals that the custom agent was deselected, returning to the // default agent type Data struct { + AlreadyInUse *bool `json:"alreadyInUse,omitempty"` // Working directory and git context at session start // // Updated working directory and git context at resume time @@ -267,6 +268,8 @@ type Data struct { // Full content of the skill file, injected into the conversation for the model // // The system or developer prompt text + // + // The notification text, typically wrapped in XML tags Content *string `json:"content,omitempty"` // CAPI interaction ID for correlating this user message with its turn // @@ -426,6 +429,8 @@ type Data struct { Metadata *Metadata `json:"metadata,omitempty"` // Message role: "system" for system prompts, "developer" for developer-injected instructions Role *Role `json:"role,omitempty"` + // Structured metadata identifying what triggered this notification + Kind *KindClass `json:"kind,omitempty"` // Details of the permission being requested PermissionRequest *PermissionRequest `json:"permissionRequest,omitempty"` // Whether the user can provide a free-form text response in addition to predefined choices @@ -594,6 +599,29 @@ type ErrorClass struct { Stack *string `json:"stack,omitempty"` } +// Structured metadata identifying what triggered this notification +type KindClass struct { + // Unique identifier of the background agent + AgentID *string `json:"agentId,omitempty"` + // Type of the agent (e.g., explore, task, general-purpose) + AgentType *string `json:"agentType,omitempty"` + // Human-readable description of the agent task + // + // Human-readable description of the command + Description *string `json:"description,omitempty"` + // The full prompt given to the background agent + Prompt *string `json:"prompt,omitempty"` + // Whether the agent completed successfully or failed + Status *Status `json:"status,omitempty"` + Type KindType `json:"type"` + // Exit code of the shell command, if available + ExitCode *float64 `json:"exitCode,omitempty"` + // Unique identifier of the shell session + // + // Unique identifier of the detached shell session + ShellID *string `json:"shellId,omitempty"` +} + // Metadata about the prompt template and its construction type Metadata struct { // Version identifier of the prompt template used @@ -860,6 +888,22 @@ const ( Selection AttachmentType = "selection" ) +// Whether the agent completed successfully or failed +type Status string + +const ( + Completed Status = "completed" + Failed Status = "failed" +) + +type KindType string + +const ( + AgentCompleted KindType = "agent_completed" + ShellCompleted KindType = "shell_completed" + ShellDetachedCompleted KindType = "shell_detached_completed" +) + type Mode string const ( @@ -1011,6 +1055,7 @@ const ( SubagentSelected SessionEventType = "subagent.selected" SubagentStarted SessionEventType = "subagent.started" SystemMessage SessionEventType = "system.message" + SystemNotification SessionEventType = "system.notification" ToolExecutionComplete SessionEventType = "tool.execution_complete" ToolExecutionPartialResult SessionEventType = "tool.execution_partial_result" ToolExecutionProgress SessionEventType = "tool.execution_progress" diff --git a/go/internal/e2e/session_test.go b/go/internal/e2e/session_test.go index d1902311..8da66cdd 100644 --- a/go/internal/e2e/session_test.go +++ b/go/internal/e2e/session_test.go @@ -3,11 +3,13 @@ package e2e import ( "regexp" "strings" + "sync" "testing" "time" copilot "github.com/github/copilot-sdk/go" "github.com/github/copilot-sdk/go/internal/e2e/testharness" + "github.com/github/copilot-sdk/go/rpc" ) func TestSession(t *testing.T) { @@ -889,3 +891,105 @@ func contains(slice []string, item string) bool { } return false } + +func TestSessionLog(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Failed to start client: %v", err) + } + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // Collect events + var events []copilot.SessionEvent + var mu sync.Mutex + unsubscribe := session.On(func(event copilot.SessionEvent) { + mu.Lock() + defer mu.Unlock() + events = append(events, event) + }) + defer unsubscribe() + + t.Run("should log info message (default level)", func(t *testing.T) { + if err := session.Log(t.Context(), "Info message", nil); err != nil { + t.Fatalf("Log failed: %v", err) + } + + evt := waitForEvent(t, &mu, &events, copilot.SessionInfo, "Info message", 5*time.Second) + if evt.Data.InfoType == nil || *evt.Data.InfoType != "notification" { + t.Errorf("Expected infoType 'notification', got %v", evt.Data.InfoType) + } + if evt.Data.Message == nil || *evt.Data.Message != "Info message" { + t.Errorf("Expected message 'Info message', got %v", evt.Data.Message) + } + }) + + t.Run("should log warning message", func(t *testing.T) { + if err := session.Log(t.Context(), "Warning message", &copilot.LogOptions{Level: rpc.Warning}); err != nil { + t.Fatalf("Log failed: %v", err) + } + + evt := waitForEvent(t, &mu, &events, copilot.SessionWarning, "Warning message", 5*time.Second) + if evt.Data.WarningType == nil || *evt.Data.WarningType != "notification" { + t.Errorf("Expected warningType 'notification', got %v", evt.Data.WarningType) + } + if evt.Data.Message == nil || *evt.Data.Message != "Warning message" { + t.Errorf("Expected message 'Warning message', got %v", evt.Data.Message) + } + }) + + t.Run("should log error message", func(t *testing.T) { + if err := session.Log(t.Context(), "Error message", &copilot.LogOptions{Level: rpc.Error}); err != nil { + t.Fatalf("Log failed: %v", err) + } + + evt := waitForEvent(t, &mu, &events, copilot.SessionError, "Error message", 5*time.Second) + if evt.Data.ErrorType == nil || *evt.Data.ErrorType != "notification" { + t.Errorf("Expected errorType 'notification', got %v", evt.Data.ErrorType) + } + if evt.Data.Message == nil || *evt.Data.Message != "Error message" { + t.Errorf("Expected message 'Error message', got %v", evt.Data.Message) + } + }) + + t.Run("should log ephemeral message", func(t *testing.T) { + if err := session.Log(t.Context(), "Ephemeral message", &copilot.LogOptions{Ephemeral: true}); err != nil { + t.Fatalf("Log failed: %v", err) + } + + evt := waitForEvent(t, &mu, &events, copilot.SessionInfo, "Ephemeral message", 5*time.Second) + if evt.Data.InfoType == nil || *evt.Data.InfoType != "notification" { + t.Errorf("Expected infoType 'notification', got %v", evt.Data.InfoType) + } + if evt.Data.Message == nil || *evt.Data.Message != "Ephemeral message" { + t.Errorf("Expected message 'Ephemeral message', got %v", evt.Data.Message) + } + }) +} + +// waitForEvent polls the collected events for a matching event type and message. +func waitForEvent(t *testing.T, mu *sync.Mutex, events *[]copilot.SessionEvent, eventType copilot.SessionEventType, message string, timeout time.Duration) copilot.SessionEvent { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + mu.Lock() + for _, evt := range *events { + if evt.Type == eventType && evt.Data.Message != nil && *evt.Data.Message == message { + mu.Unlock() + return evt + } + } + mu.Unlock() + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("Timed out waiting for %s event with message %q", eventType, message) + return copilot.SessionEvent{} // unreachable +} diff --git a/go/rpc/generated_rpc.go b/go/rpc/generated_rpc.go index 67a35420..0e4b96e4 100644 --- a/go/rpc/generated_rpc.go +++ b/go/rpc/generated_rpc.go @@ -129,7 +129,8 @@ type SessionModelSwitchToResult struct { } type SessionModelSwitchToParams struct { - ModelID string `json:"modelId"` + ModelID string `json:"modelId"` + ReasoningEffort *ReasoningEffort `json:"reasoningEffort,omitempty"` } type SessionModeGetResult struct { @@ -296,6 +297,30 @@ type SessionPermissionsHandlePendingPermissionRequestParamsResult struct { Path *string `json:"path,omitempty"` } +type SessionLogResult struct { + // The unique identifier of the emitted session event + EventID string `json:"eventId"` +} + +type SessionLogParams struct { + // When true, the message is transient and not persisted to the session event log on disk + Ephemeral *bool `json:"ephemeral,omitempty"` + // Log severity level. Determines how the message is displayed in the timeline. Defaults to + // "info". + Level *Level `json:"level,omitempty"` + // Human-readable message + Message string `json:"message"` +} + +type ReasoningEffort string + +const ( + High ReasoningEffort = "high" + Low ReasoningEffort = "low" + Medium ReasoningEffort = "medium" + Xhigh ReasoningEffort = "xhigh" +) + // The current agent mode. // // The agent mode after switching. @@ -319,6 +344,16 @@ const ( DeniedNoApprovalRuleAndCouldNotRequestFromUser Kind = "denied-no-approval-rule-and-could-not-request-from-user" ) +// Log severity level. Determines how the message is displayed in the timeline. Defaults to +// "info". +type Level string + +const ( + Error Level = "error" + Info Level = "info" + Warning Level = "warning" +) + type ResultUnion struct { ResultResult *ResultResult String *string @@ -416,6 +451,9 @@ func (a *ModelRpcApi) SwitchTo(ctx context.Context, params *SessionModelSwitchTo req := map[string]interface{}{"sessionId": a.sessionID} if params != nil { req["modelId"] = params.ModelID + if params.ReasoningEffort != nil { + req["reasoningEffort"] = *params.ReasoningEffort + } } raw, err := a.client.Request("session.model.switchTo", req) if err != nil { @@ -725,6 +763,28 @@ type SessionRpc struct { Permissions *PermissionsRpcApi } +func (a *SessionRpc) Log(ctx context.Context, params *SessionLogParams) (*SessionLogResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + if params != nil { + req["message"] = params.Message + if params.Level != nil { + req["level"] = *params.Level + } + if params.Ephemeral != nil { + req["ephemeral"] = *params.Ephemeral + } + } + raw, err := a.client.Request("session.log", req) + if err != nil { + return nil, err + } + var result SessionLogResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + func NewSessionRpc(client *jsonrpc2.Client, sessionID string) *SessionRpc { return &SessionRpc{client: client, sessionID: sessionID, Model: &ModelRpcApi{client: client, sessionID: sessionID}, diff --git a/go/session.go b/go/session.go index c06a8e1e..74529c52 100644 --- a/go/session.go +++ b/go/session.go @@ -701,3 +701,49 @@ func (s *Session) SetModel(ctx context.Context, model string) error { return nil } + +// LogOptions configures optional parameters for [Session.Log]. +type LogOptions struct { + // Level sets the log severity. Valid values are [rpc.Info] (default), + // [rpc.Warning], and [rpc.Error]. + Level rpc.Level + // Ephemeral marks the message as transient so it is not persisted + // to the session event log on disk. + Ephemeral bool +} + +// Log sends a log message to the session timeline. +// The message appears in the session event stream and is visible to SDK consumers +// and (for non-ephemeral messages) persisted to the session event log on disk. +// +// Pass nil for opts to use defaults (info level, non-ephemeral). +// +// Example: +// +// // Simple info message +// session.Log(ctx, "Processing started") +// +// // Warning with options +// session.Log(ctx, "Rate limit approaching", &copilot.LogOptions{Level: rpc.Warning}) +// +// // Ephemeral message (not persisted) +// session.Log(ctx, "Working...", &copilot.LogOptions{Ephemeral: true}) +func (s *Session) Log(ctx context.Context, message string, opts *LogOptions) error { + params := &rpc.SessionLogParams{Message: message} + + if opts != nil { + if opts.Level != "" { + params.Level = &opts.Level + } + if opts.Ephemeral { + params.Ephemeral = &opts.Ephemeral + } + } + + _, err := s.RPC.Log(ctx, params) + if err != nil { + return fmt.Errorf("failed to log message: %w", err) + } + + return nil +} diff --git a/nodejs/docs/agent-author.md b/nodejs/docs/agent-author.md new file mode 100644 index 00000000..4c1e32f6 --- /dev/null +++ b/nodejs/docs/agent-author.md @@ -0,0 +1,265 @@ +# Agent Extension Authoring Guide + +A precise, step-by-step reference for agents writing Copilot CLI extensions programmatically. + +## Workflow + +### Step 1: Scaffold the extension + +Use the `extensions_manage` tool with `operation: "scaffold"`: + +``` +extensions_manage({ operation: "scaffold", name: "my-extension" }) +``` + +This creates `.github/extensions/my-extension/extension.mjs` with a working skeleton. +For user-scoped extensions (persist across all repos), add `location: "user"`. + +### Step 2: Edit the extension file + +Modify the generated `extension.mjs` using `edit` or `create` tools. The file must: +- Be named `extension.mjs` (only `.mjs` is supported) +- Use ES module syntax (`import`/`export`) +- Call `joinSession({ ... })` + +### Step 3: Reload extensions + +``` +extensions_reload({}) +``` + +This stops all running extensions and re-discovers/re-launches them. New tools are available immediately in the same turn (mid-turn refresh). + +### Step 4: Verify + +``` +extensions_manage({ operation: "list" }) +extensions_manage({ operation: "inspect", name: "my-extension" }) +``` + +Check that the extension loaded successfully and isn't marked as "failed". + +--- + +## File Structure + +``` +.github/extensions//extension.mjs +``` + +Discovery rules: +- The CLI scans `.github/extensions/` relative to the git root +- It also scans the user's copilot config extensions directory +- Only immediate subdirectories are checked (not recursive) +- Each subdirectory must contain a file named `extension.mjs` +- Project extensions shadow user extensions on name collision + +--- + +## Minimal Skeleton + +```js +import { approveAll } from "@github/copilot-sdk"; +import { joinSession } from "@github/copilot-sdk/extension"; + +await joinSession({ + onPermissionRequest: approveAll, // Required — handle permission requests + tools: [], // Optional — custom tools + hooks: {}, // Optional — lifecycle hooks +}); +``` + +--- + +## Registering Tools + +```js +tools: [ + { + name: "tool_name", // Required. Must be globally unique across all extensions. + description: "What it does", // Required. Shown to the agent in tool descriptions. + parameters: { // Optional. JSON Schema for the arguments. + type: "object", + properties: { + arg1: { type: "string", description: "..." }, + }, + required: ["arg1"], + }, + handler: async (args, invocation) => { + // args: parsed arguments matching the schema + // invocation.sessionId: current session ID + // invocation.toolCallId: unique call ID + // invocation.toolName: this tool's name + // + // Return value: string or ToolResultObject + // string → treated as success + // { textResultForLlm, resultType } → structured result + // resultType: "success" | "failure" | "rejected" | "denied" + return `Result: ${args.arg1}`; + }, + }, +] +``` + +**Constraints:** +- Tool names must be unique across ALL loaded extensions. Collisions cause the second extension to fail to load. +- Handler must return a string or `{ textResultForLlm: string, resultType?: string }`. +- Handler receives `(args, invocation)` — the second argument has `sessionId`, `toolCallId`, `toolName`. +- Use `session.log()` to surface messages to the user. Don't use `console.log()` (stdout is reserved for JSON-RPC). + +--- + +## Registering Hooks + +```js +hooks: { + onUserPromptSubmitted: async (input, invocation) => { ... }, + onPreToolUse: async (input, invocation) => { ... }, + onPostToolUse: async (input, invocation) => { ... }, + onSessionStart: async (input, invocation) => { ... }, + onSessionEnd: async (input, invocation) => { ... }, + onErrorOccurred: async (input, invocation) => { ... }, +} +``` + +All hook inputs include `timestamp` (unix ms) and `cwd` (working directory). +All handlers receive `invocation: { sessionId: string }` as the second argument. +All handlers may return `void`/`undefined` (no-op) or an output object. + +### onUserPromptSubmitted + +**Input:** `{ prompt: string, timestamp, cwd }` + +**Output (all fields optional):** +| Field | Type | Effect | +|-------|------|--------| +| `modifiedPrompt` | `string` | Replaces the user's prompt | +| `additionalContext` | `string` | Appended as hidden context the agent sees | + +### onPreToolUse + +**Input:** `{ toolName: string, toolArgs: unknown, timestamp, cwd }` + +**Output (all fields optional):** +| Field | Type | Effect | +|-------|------|--------| +| `permissionDecision` | `"allow" \| "deny" \| "ask"` | Override the permission check | +| `permissionDecisionReason` | `string` | Shown to user if denied | +| `modifiedArgs` | `unknown` | Replaces the tool arguments | +| `additionalContext` | `string` | Injected into the conversation | + +### onPostToolUse + +**Input:** `{ toolName: string, toolArgs: unknown, toolResult: ToolResultObject, timestamp, cwd }` + +**Output (all fields optional):** +| Field | Type | Effect | +|-------|------|--------| +| `modifiedResult` | `ToolResultObject` | Replaces the tool result | +| `additionalContext` | `string` | Injected into the conversation | + +### onSessionStart + +**Input:** `{ source: "startup" \| "resume" \| "new", initialPrompt?: string, timestamp, cwd }` + +**Output (all fields optional):** +| Field | Type | Effect | +|-------|------|--------| +| `additionalContext` | `string` | Injected as initial context | + +### onSessionEnd + +**Input:** `{ reason: "complete" \| "error" \| "abort" \| "timeout" \| "user_exit", finalMessage?: string, error?: string, timestamp, cwd }` + +**Output (all fields optional):** +| Field | Type | Effect | +|-------|------|--------| +| `sessionSummary` | `string` | Summary for session persistence | +| `cleanupActions` | `string[]` | Cleanup descriptions | + +### onErrorOccurred + +**Input:** `{ error: string, errorContext: "model_call" \| "tool_execution" \| "system" \| "user_input", recoverable: boolean, timestamp, cwd }` + +**Output (all fields optional):** +| Field | Type | Effect | +|-------|------|--------| +| `errorHandling` | `"retry" \| "skip" \| "abort"` | How to handle the error | +| `retryCount` | `number` | Max retries (when errorHandling is "retry") | +| `userNotification` | `string` | Message shown to the user | + +--- + +## Session Object + +After `joinSession()`, the returned `session` provides: + +### session.send(options) + +Send a message programmatically: +```js +await session.send({ prompt: "Analyze the test results." }); +await session.send({ + prompt: "Review this file", + attachments: [{ type: "file", path: "./src/index.ts" }], +}); +``` + +### session.sendAndWait(options, timeout?) + +Send and block until the agent finishes (resolves on `session.idle`): +```js +const response = await session.sendAndWait({ prompt: "What is 2+2?" }); +// response?.data.content contains the agent's reply +``` + +### session.log(message, options?) + +Log to the CLI timeline: +```js +await session.log("Extension ready"); +await session.log("Rate limit approaching", { level: "warning" }); +await session.log("Connection failed", { level: "error" }); +await session.log("Processing...", { ephemeral: true }); // transient, not persisted +``` + +### session.on(eventType, handler) + +Subscribe to session events. Returns an unsubscribe function. +```js +const unsub = session.on("tool.execution_complete", (event) => { + // event.data.toolName, event.data.success, event.data.result +}); +``` + +### Key Event Types + +| Event | Key Data Fields | +|-------|----------------| +| `assistant.message` | `content`, `messageId` | +| `tool.execution_start` | `toolCallId`, `toolName`, `arguments` | +| `tool.execution_complete` | `toolCallId`, `toolName`, `success`, `result`, `error` | +| `user.message` | `content`, `attachments`, `source` | +| `session.idle` | `backgroundTasks` | +| `session.error` | `errorType`, `message`, `stack` | +| `permission.requested` | `requestId`, `permissionRequest.kind` | +| `session.shutdown` | `shutdownType`, `totalPremiumRequests` | + +### session.workspacePath + +Path to the session workspace directory (checkpoints, plan.md, files/). `undefined` if infinite sessions disabled. + +### session.rpc + +Low-level typed RPC access to all session APIs (model, mode, plan, workspace, etc.). + +--- + +## Gotchas + +- **stdout is reserved for JSON-RPC.** Don't use `console.log()` — it will corrupt the protocol. Use `session.log()` to surface messages to the user. +- **Tool name collisions are fatal.** If two extensions register the same tool name, the second extension fails to initialize. +- **Don't call `session.send()` synchronously from `onUserPromptSubmitted`.** Use `setTimeout(() => session.send(...), 0)` to avoid infinite loops. +- **Extensions are reloaded on `/clear`.** Any in-memory state is lost between sessions. +- **Only `.mjs` is supported.** TypeScript (`.ts`) is not yet supported. +- **The handler's return value is the tool result.** Returning `undefined` sends an empty success. Throwing sends a failure with the error message. diff --git a/nodejs/docs/examples.md b/nodejs/docs/examples.md new file mode 100644 index 00000000..a5b03f87 --- /dev/null +++ b/nodejs/docs/examples.md @@ -0,0 +1,681 @@ +# Copilot CLI Extension Examples + +A practical guide to writing extensions using the `@github/copilot-sdk` extension API. + +## Extension Skeleton + +Every extension starts with the same boilerplate: + +```js +import { approveAll } from "@github/copilot-sdk"; +import { joinSession } from "@github/copilot-sdk/extension"; + +const session = await joinSession({ + onPermissionRequest: approveAll, + hooks: { /* ... */ }, + tools: [ /* ... */ ], +}); +``` + +`joinSession` returns a `CopilotSession` object you can use to send messages and subscribe to events. + +> **Platform notes (Windows vs macOS/Linux):** +> - Use `process.platform === "win32"` to detect Windows at runtime. +> - Clipboard: `pbcopy` on macOS, `clip` on Windows. +> - Use `exec()` instead of `execFile()` for `.cmd` scripts like `code`, `npx`, `npm` on Windows. +> - PowerShell stderr redirection uses `*>&1` instead of `2>&1`. + +--- + +## Logging to the Timeline + +Use `session.log()` to surface messages to the user in the CLI timeline: + +```js +const session = await joinSession({ + onPermissionRequest: approveAll, + hooks: { + onSessionStart: async () => { + await session.log("My extension loaded"); + }, + onPreToolUse: async (input) => { + if (input.toolName === "bash") { + await session.log(`Running: ${input.toolArgs?.command}`, { ephemeral: true }); + } + }, + }, + tools: [], +}); +``` + +Levels: `"info"` (default), `"warning"`, `"error"`. Set `ephemeral: true` for transient messages that aren't persisted. + +--- + +## Registering Custom Tools + +Tools are functions the agent can call. Define them with a name, description, JSON Schema parameters, and a handler. + +### Basic tool + +```js +tools: [ + { + name: "my_tool", + description: "Does something useful", + parameters: { + type: "object", + properties: { + input: { type: "string", description: "The input value" }, + }, + required: ["input"], + }, + handler: async (args) => { + return `Processed: ${args.input}`; + }, + }, +] +``` + +### Tool that invokes an external shell command + +```js +import { execFile } from "node:child_process"; + +{ + name: "run_command", + description: "Runs a shell command and returns its output", + parameters: { + type: "object", + properties: { + command: { type: "string", description: "The command to run" }, + }, + required: ["command"], + }, + handler: async (args) => { + const isWindows = process.platform === "win32"; + const shell = isWindows ? "powershell" : "bash"; + const shellArgs = isWindows + ? ["-NoProfile", "-Command", args.command] + : ["-c", args.command]; + return new Promise((resolve) => { + execFile(shell, shellArgs, (err, stdout, stderr) => { + if (err) resolve(`Error: ${stderr || err.message}`); + else resolve(stdout); + }); + }); + }, +} +``` + +### Tool that calls an external API + +```js +{ + name: "fetch_data", + description: "Fetches data from an API endpoint", + parameters: { + type: "object", + properties: { + url: { type: "string", description: "The URL to fetch" }, + }, + required: ["url"], + }, + handler: async (args) => { + const res = await fetch(args.url); + if (!res.ok) return `Error: HTTP ${res.status}`; + return await res.text(); + }, +} +``` + +### Tool handler invocation context + +The handler receives a second argument with invocation metadata: + +```js +handler: async (args, invocation) => { + // invocation.sessionId — current session ID + // invocation.toolCallId — unique ID for this tool call + // invocation.toolName — name of the tool being called + return "done"; +} +``` + +--- + +## Hooks + +Hooks intercept and modify behavior at key lifecycle points. Register them in the `hooks` option. + +### Available Hooks + +| Hook | Fires When | Can Modify | +|------|-----------|------------| +| `onUserPromptSubmitted` | User sends a message | The prompt text, add context | +| `onPreToolUse` | Before a tool executes | Tool args, permission decision, add context | +| `onPostToolUse` | After a tool executes | Tool result, add context | +| `onSessionStart` | Session starts or resumes | Add context, modify config | +| `onSessionEnd` | Session ends | Cleanup actions, summary | +| `onErrorOccurred` | An error occurs | Error handling strategy (retry/skip/abort) | + +All hook inputs include `timestamp` (unix ms) and `cwd` (working directory). + +### Modifying the user's message + +Use `onUserPromptSubmitted` to rewrite or augment what the user typed before the agent sees it. + +```js +hooks: { + onUserPromptSubmitted: async (input) => { + // Rewrite the prompt + return { modifiedPrompt: input.prompt.toUpperCase() }; + }, +} +``` + +### Injecting additional context into every message + +Return `additionalContext` to silently append instructions the agent will follow. + +```js +hooks: { + onUserPromptSubmitted: async (input) => { + return { + additionalContext: "Always respond in bullet points. Follow our team coding standards.", + }; + }, +} +``` + +### Sending a follow-up message based on a keyword + +Use `session.send()` to programmatically inject a new user message. + +```js +hooks: { + onUserPromptSubmitted: async (input) => { + if (/\\burgent\\b/i.test(input.prompt)) { + // Fire-and-forget a follow-up message + setTimeout(() => session.send({ prompt: "Please prioritize this." }), 0); + } + }, +} +``` + +> **Tip:** Guard against infinite loops if your follow-up message could re-trigger the same hook. + +### Blocking dangerous tool calls + +Use `onPreToolUse` to inspect and optionally deny tool execution. + +```js +hooks: { + onPreToolUse: async (input) => { + if (input.toolName === "bash") { + const cmd = String(input.toolArgs?.command || ""); + if (/rm\\s+-rf/i.test(cmd) || /Remove-Item\\s+.*-Recurse/i.test(cmd)) { + return { + permissionDecision: "deny", + permissionDecisionReason: "Destructive commands are not allowed.", + }; + } + } + // Allow everything else + return { permissionDecision: "allow" }; + }, +} +``` + +### Modifying tool arguments before execution + +```js +hooks: { + onPreToolUse: async (input) => { + if (input.toolName === "bash") { + const redirect = process.platform === "win32" ? "*>&1" : "2>&1"; + return { + modifiedArgs: { + ...input.toolArgs, + command: `${input.toolArgs.command} ${redirect}`, + }, + }; + } + }, +} +``` + +### Reacting when the agent creates or edits a file + +Use `onPostToolUse` to run side effects after a tool completes. + +```js +import { exec } from "node:child_process"; + +hooks: { + onPostToolUse: async (input) => { + if (input.toolName === "create" || input.toolName === "edit") { + const filePath = input.toolArgs?.path; + if (filePath) { + // Open the file in VS Code + exec(`code "${filePath}"`, () => {}); + } + } + }, +} +``` + +### Augmenting tool results with extra context + +```js +hooks: { + onPostToolUse: async (input) => { + if (input.toolName === "bash" && input.toolResult?.resultType === "failure") { + return { + additionalContext: "The command failed. Try a different approach.", + }; + } + }, +} +``` + +### Running a linter after every file edit + +```js +import { exec } from "node:child_process"; + +hooks: { + onPostToolUse: async (input) => { + if (input.toolName === "edit") { + const filePath = input.toolArgs?.path; + if (filePath?.endsWith(".ts")) { + const result = await new Promise((resolve) => { + exec(`npx eslint "${filePath}"`, (err, stdout) => { + resolve(err ? stdout : "No lint errors."); + }); + }); + return { additionalContext: `Lint result: ${result}` }; + } + } + }, +} +``` + +### Handling errors with retry logic + +```js +hooks: { + onErrorOccurred: async (input) => { + if (input.recoverable && input.errorContext === "model_call") { + return { errorHandling: "retry", retryCount: 2 }; + } + return { + errorHandling: "abort", + userNotification: `An error occurred: ${input.error}`, + }; + }, +} +``` + +### Session lifecycle hooks + +```js +hooks: { + onSessionStart: async (input) => { + // input.source is "startup", "resume", or "new" + return { additionalContext: "Remember to write tests for all changes." }; + }, + onSessionEnd: async (input) => { + // input.reason is "complete", "error", "abort", "timeout", or "user_exit" + }, +} +``` + +--- + +## Session Events + +After calling `joinSession`, use `session.on()` to react to events in real time. + +### Listening to a specific event type + +```js +session.on("assistant.message", (event) => { + // event.data.content has the agent's response text +}); +``` + +### Listening to all events + +```js +session.on((event) => { + // event.type and event.data are available for all events +}); +``` + +### Unsubscribing from events + +`session.on()` returns an unsubscribe function: + +```js +const unsubscribe = session.on("tool.execution_complete", (event) => { + // event.data.toolName, event.data.success, event.data.result, event.data.error +}); + +// Later, stop listening +unsubscribe(); +``` + +### Example: Auto-copy agent responses to clipboard + +Combine a hook (to detect a keyword) with a session event (to capture the response): + +```js +import { execFile } from "node:child_process"; + +let copyNextResponse = false; + +function copyToClipboard(text) { + const cmd = process.platform === "win32" ? "clip" : "pbcopy"; + const proc = execFile(cmd, [], () => {}); + proc.stdin.write(text); + proc.stdin.end(); +} + +const session = await joinSession({ + onPermissionRequest: approveAll, + hooks: { + onUserPromptSubmitted: async (input) => { + if (/\\bcopy\\b/i.test(input.prompt)) { + copyNextResponse = true; + } + }, + }, + tools: [], +}); + +session.on("assistant.message", (event) => { + if (copyNextResponse) { + copyNextResponse = false; + copyToClipboard(event.data.content); + } +}); +``` + +### Top 10 Most Useful Event Types + +| Event Type | Description | Key Data Fields | +|-----------|-------------|-----------------| +| `assistant.message` | Agent's final response | `content`, `messageId`, `toolRequests` | +| `assistant.streaming_delta` | Token-by-token streaming (ephemeral) | `totalResponseSizeBytes` | +| `tool.execution_start` | A tool is about to run | `toolCallId`, `toolName`, `arguments` | +| `tool.execution_complete` | A tool finished running | `toolCallId`, `toolName`, `success`, `result`, `error` | +| `user.message` | User sent a message | `content`, `attachments`, `source` | +| `session.idle` | Session finished processing a turn | `backgroundTasks` | +| `session.error` | An error occurred | `errorType`, `message`, `stack` | +| `permission.requested` | Agent needs permission (shell, file write, etc.) | `requestId`, `permissionRequest.kind` | +| `session.shutdown` | Session is ending | `shutdownType`, `totalPremiumRequests`, `codeChanges` | +| `assistant.turn_start` | Agent begins a new thinking/response cycle | `turnId` | + +### Example: Detecting when the plan file is created or edited + +Use `session.workspacePath` to locate the session's `plan.md`, then `fs.watchFile` to detect changes. +Correlate `tool.execution_start` / `tool.execution_complete` events by `toolCallId` to distinguish agent edits from user edits. + +```js +import { existsSync, watchFile, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { approveAll } from "@github/copilot-sdk"; +import { joinSession } from "@github/copilot-sdk/extension"; + +const agentEdits = new Set(); // toolCallIds for in-flight agent edits +const recentAgentPaths = new Set(); // paths recently written by the agent + +const session = await joinSession({ + onPermissionRequest: approveAll, +}); + +const workspace = session.workspacePath; // e.g. ~/.copilot/session-state/ +if (workspace) { + const planPath = join(workspace, "plan.md"); + let lastContent = existsSync(planPath) ? readFileSync(planPath, "utf-8") : null; + + // Track agent edits to suppress false triggers + session.on("tool.execution_start", (event) => { + if ((event.data.toolName === "edit" || event.data.toolName === "create") + && String(event.data.arguments?.path || "").endsWith("plan.md")) { + agentEdits.add(event.data.toolCallId); + recentAgentPaths.add(planPath); + } + }); + session.on("tool.execution_complete", (event) => { + if (agentEdits.delete(event.data.toolCallId)) { + setTimeout(() => { + recentAgentPaths.delete(planPath); + lastContent = existsSync(planPath) ? readFileSync(planPath, "utf-8") : null; + }, 2000); + } + }); + + watchFile(planPath, { interval: 1000 }, () => { + if (recentAgentPaths.has(planPath) || agentEdits.size > 0) return; + const content = existsSync(planPath) ? readFileSync(planPath, "utf-8") : null; + if (content === lastContent) return; + const wasCreated = lastContent === null && content !== null; + lastContent = content; + if (content !== null) { + session.send({ + prompt: `The plan was ${wasCreated ? "created" : "edited"} by the user.`, + }); + } + }); +} +``` + +### Example: Reacting when the user manually edits any file in the repo + +Use `fs.watch` with `recursive: true` on `process.cwd()` to detect file changes. +Filter out agent edits by tracking `tool.execution_start` / `tool.execution_complete` events. + +```js +import { watch, readFileSync, statSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; +import { approveAll } from "@github/copilot-sdk"; +import { joinSession } from "@github/copilot-sdk/extension"; + +const agentEditPaths = new Set(); + +const session = await joinSession({ + onPermissionRequest: approveAll, +}); + +const cwd = process.cwd(); +const IGNORE = new Set(["node_modules", ".git", "dist"]); + +// Track agent file edits +session.on("tool.execution_start", (event) => { + if (event.data.toolName === "edit" || event.data.toolName === "create") { + const p = String(event.data.arguments?.path || ""); + if (p) agentEditPaths.add(resolve(p)); + } +}); +session.on("tool.execution_complete", (event) => { + // Clear after a delay to avoid race with fs.watch + const p = [...agentEditPaths].find((x) => x); // any tracked path + setTimeout(() => agentEditPaths.clear(), 3000); +}); + +const debounce = new Map(); + +watch(cwd, { recursive: true }, (eventType, filename) => { + if (!filename || eventType !== "change") return; + if (filename.split(/[\\\\\\/]/).some((p) => IGNORE.has(p))) return; + + if (debounce.has(filename)) clearTimeout(debounce.get(filename)); + debounce.set(filename, setTimeout(() => { + debounce.delete(filename); + const fullPath = join(cwd, filename); + if (agentEditPaths.has(resolve(fullPath))) return; + + try { if (!statSync(fullPath).isFile()) return; } catch { return; } + const relPath = relative(cwd, fullPath); + session.send({ + prompt: `The user edited \\`${relPath}\\`.`, + attachments: [{ type: "file", path: fullPath }], + }); + }, 500)); +}); +``` + +--- + +## Sending Messages Programmatically + +### Fire-and-forget + +```js +await session.send({ prompt: "Analyze the test results." }); +``` + +### Send and wait for the response + +```js +const response = await session.sendAndWait({ prompt: "What is 2 + 2?" }); +// response?.data.content contains the agent's reply +``` + +### Send with file attachments + +```js +await session.send({ + prompt: "Review this file", + attachments: [ + { type: "file", path: "./src/index.ts" }, + ], +}); +``` + +--- + +## Permission and User Input Handlers + +### Custom permission logic + +```js +const session = await joinSession({ + onPermissionRequest: async (request) => { + if (request.kind === "shell") { + // request.fullCommandText has the shell command + return { kind: "approved" }; + } + if (request.kind === "write") { + return { kind: "approved" }; + } + return { kind: "denied-by-rules" }; + }, +}); +``` + +### Handling agent questions (ask_user) + +Register `onUserInputRequest` to enable the agent's `ask_user` tool: + +```js +const session = await joinSession({ + onPermissionRequest: approveAll, + onUserInputRequest: async (request) => { + // request.question has the agent's question + // request.choices has the options (if multiple choice) + return { answer: "yes", wasFreeform: false }; + }, +}); +``` + +--- + +## Complete Example: Multi-Feature Extension + +An extension that combines tools, hooks, and events. + +```js +import { execFile, exec } from "node:child_process"; +import { approveAll } from "@github/copilot-sdk"; +import { joinSession } from "@github/copilot-sdk/extension"; + +const isWindows = process.platform === "win32"; +let copyNextResponse = false; + +function copyToClipboard(text) { + const proc = execFile(isWindows ? "clip" : "pbcopy", [], () => {}); + proc.stdin.write(text); + proc.stdin.end(); +} + +function openInEditor(filePath) { + if (isWindows) exec(`code "${filePath}"`, () => {}); + else execFile("code", [filePath], () => {}); +} + +const session = await joinSession({ + onPermissionRequest: approveAll, + hooks: { + onUserPromptSubmitted: async (input) => { + if (/\\bcopy this\\b/i.test(input.prompt)) { + copyNextResponse = true; + } + return { + additionalContext: "Follow our team style guide. Use 4-space indentation.", + }; + }, + onPreToolUse: async (input) => { + if (input.toolName === "bash") { + const cmd = String(input.toolArgs?.command || ""); + if (/rm\\s+-rf\\s+\\//i.test(cmd) || /Remove-Item\\s+.*-Recurse/i.test(cmd)) { + return { permissionDecision: "deny" }; + } + } + }, + onPostToolUse: async (input) => { + if (input.toolName === "create" || input.toolName === "edit") { + const filePath = input.toolArgs?.path; + if (filePath) openInEditor(filePath); + } + }, + }, + tools: [ + { + name: "copy_to_clipboard", + description: "Copies text to the system clipboard.", + parameters: { + type: "object", + properties: { + text: { type: "string", description: "Text to copy" }, + }, + required: ["text"], + }, + handler: async (args) => { + return new Promise((resolve) => { + const proc = execFile(isWindows ? "clip" : "pbcopy", [], (err) => { + if (err) resolve(`Error: ${err.message}`); + else resolve("Copied to clipboard."); + }); + proc.stdin.write(args.text); + proc.stdin.end(); + }); + }, + }, + ], +}); + +session.on("assistant.message", (event) => { + if (copyNextResponse) { + copyNextResponse = false; + copyToClipboard(event.data.content); + } +}); + +session.on("tool.execution_complete", (event) => { + // event.data.success, event.data.toolName, event.data.result +}); +``` + diff --git a/nodejs/docs/extensions.md b/nodejs/docs/extensions.md new file mode 100644 index 00000000..5eff9135 --- /dev/null +++ b/nodejs/docs/extensions.md @@ -0,0 +1,61 @@ +# Copilot CLI Extensions + +Extensions add custom tools, hooks, and behaviors to the Copilot CLI. They run as separate Node.js processes that communicate with the CLI over JSON-RPC via stdio. + +## How Extensions Work + +``` +┌─────────────────────┐ JSON-RPC / stdio ┌──────────────────────┐ +│ Copilot CLI │ ◄──────────────────────────────────► │ Extension Process │ +│ (parent process) │ tool calls, events, hooks │ (forked child) │ +│ │ │ │ +│ • Discovers exts │ │ • Registers tools │ +│ • Forks processes │ │ • Registers hooks │ +│ • Routes tool calls │ │ • Listens to events │ +│ • Manages lifecycle │ │ • Uses SDK APIs │ +└─────────────────────┘ └──────────────────────┘ +``` + +1. **Discovery**: The CLI scans `.github/extensions/` (project) and the user's copilot config extensions directory for subdirectories containing `extension.mjs`. +2. **Launch**: Each extension is forked as a child process with `@github/copilot-sdk` available via an automatic module resolver. +3. **Connection**: The extension calls `joinSession()` which establishes a JSON-RPC connection over stdio to the CLI and attaches to the user's current foreground session. +4. **Registration**: Tools and hooks declared in the session options are registered with the CLI and become available to the agent. +5. **Lifecycle**: Extensions are reloaded on `/clear` (or if the foreground session is replaced) and stopped on CLI exit (SIGTERM, then SIGKILL after 5s). + +## File Structure + +``` +.github/extensions/ + my-extension/ + extension.mjs ← Entry point (required, must be .mjs) +``` + +- Only `.mjs` files are supported (ES modules). The file must be named `extension.mjs`. +- Each extension lives in its own subdirectory. +- The `@github/copilot-sdk` import is resolved automatically — you don't install it. + +## The SDK + +Extensions use `@github/copilot-sdk` for all interactions with the CLI: + +```js +import { approveAll } from "@github/copilot-sdk"; +import { joinSession } from "@github/copilot-sdk/extension"; + +const session = await joinSession({ + onPermissionRequest: approveAll, + tools: [ + /* ... */ + ], + hooks: { + /* ... */ + }, +}); +``` + +The `session` object provides methods for sending messages, logging to the timeline, listening to events, and accessing the RPC API. See the `.d.ts` files in the SDK package for full type information. + +## Further Reading + +- `examples.md` — Practical code examples for tools, hooks, events, and complete extensions +- `agent-author.md` — Step-by-step workflow for agents authoring extensions programmatically diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 78aacd1c..a07746bf 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.8", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.2", + "@github/copilot": "^1.0.3-0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -662,26 +662,26 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.2.tgz", - "integrity": "sha512-716SIZMYftldVcJay2uZOzsa9ROGGb2Mh2HnxbDxoisFsWNNgZlQXlV7A+PYoGsnAo2Zk/8e1i5SPTscGf2oww==", + "version": "1.0.3-0", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.3-0.tgz", + "integrity": "sha512-wvd3FwQUgf4Bm3dwRBNXdjE60eGi+4cK0Shn9Ky8GSuusHtClIanTL65ft5HdOlZ1H+ieyWrrGgu7rO1Sip/yQ==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.2", - "@github/copilot-darwin-x64": "1.0.2", - "@github/copilot-linux-arm64": "1.0.2", - "@github/copilot-linux-x64": "1.0.2", - "@github/copilot-win32-arm64": "1.0.2", - "@github/copilot-win32-x64": "1.0.2" + "@github/copilot-darwin-arm64": "1.0.3-0", + "@github/copilot-darwin-x64": "1.0.3-0", + "@github/copilot-linux-arm64": "1.0.3-0", + "@github/copilot-linux-x64": "1.0.3-0", + "@github/copilot-win32-arm64": "1.0.3-0", + "@github/copilot-win32-x64": "1.0.3-0" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.2.tgz", - "integrity": "sha512-dYoeaTidsphRXyMjvAgpjEbBV41ipICnXURrLFEiATcjC4IY6x2BqPOocrExBYW/Tz2VZvDw51iIZaf6GXrTmw==", + "version": "1.0.3-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.3-0.tgz", + "integrity": "sha512-9bpouod3i4S5TbO9zMb6e47O2l8tussndaQu8D2nD7dBVUO/p+k7r9N1agAZ9/h3zrIqWo+JpJ57iUYb8tbCSw==", "cpu": [ "arm64" ], @@ -695,9 +695,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.2.tgz", - "integrity": "sha512-8+Z9dYigEfXf0wHl9c2tgFn8Cr6v4RAY8xTgHMI9mZInjQyxVeBXCxbE2VgzUtDUD3a705Ka2d8ZOz05aYtGsg==", + "version": "1.0.3-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.3-0.tgz", + "integrity": "sha512-L4/OJLcnSnPIUIPaTZR6K7+mjXDPkHFNixioefJZQvJerOZdo9LTML6zkc2j21dWleSHiOVaLAfUdoLMyWzaVg==", "cpu": [ "x64" ], @@ -711,9 +711,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.2.tgz", - "integrity": "sha512-ik0Y5aTXOFRPLFrNjZJdtfzkozYqYeJjVXGBAH3Pp1nFZRu/pxJnrnQ1HrqO/LEgQVbJzAjQmWEfMbXdQIxE4Q==", + "version": "1.0.3-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.3-0.tgz", + "integrity": "sha512-3zGP9UuQAh7goXo7Ae2jm1SPpHWmNJw3iW6oEIhTocYm+xUecYdny7AbDAQs491fZcVGYea22Jqyynlcj1lH/g==", "cpu": [ "arm64" ], @@ -727,9 +727,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.2.tgz", - "integrity": "sha512-mHSPZjH4nU9rwbfwLxYJ7CQ90jK/Qu1v2CmvBCUPfmuGdVwrpGPHB5FrB+f+b0NEXjmemDWstk2zG53F7ppHfw==", + "version": "1.0.3-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.3-0.tgz", + "integrity": "sha512-cdxGofsF7LHjw5mO0uvmsK4wl1QnW3cd2rhwc14XgWMXbenlgyBTmwamGbVdlYtZRIAYgKNQAo3PpZSsyPXw8A==", "cpu": [ "x64" ], @@ -743,9 +743,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.2.tgz", - "integrity": "sha512-tLW2CY/vg0fYLp8EuiFhWIHBVzbFCDDpohxT/F/XyMAdTVSZLnopCcxQHv2BOu0CVGrYjlf7YOIwPfAKYml1FA==", + "version": "1.0.3-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.3-0.tgz", + "integrity": "sha512-ZjUDdE7IOi6EeUEb8hJvRu5RqPrY5kuPzdqMAiIqwDervBdNJwy9AkCNtg0jJ2fPamoQgKSFcAX7QaUX4kMx3A==", "cpu": [ "arm64" ], @@ -759,9 +759,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.2.tgz", - "integrity": "sha512-cFlc3xMkKKFRIYR00EEJ2XlYAemeh5EZHsGA8Ir2G0AH+DOevJbomdP1yyCC5gaK/7IyPkHX3sGie5sER2yPvQ==", + "version": "1.0.3-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.3-0.tgz", + "integrity": "sha512-mNoeF4hwbxXxDtGZPWe78jEfAwdQbG1Zeyztme7Z19NjZF4bUI/iDaifKUfn+fMzGHZyykoaPl9mLrTSYr77Cw==", "cpu": [ "x64" ], diff --git a/nodejs/package.json b/nodejs/package.json index ccd63582..4b407127 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -44,7 +44,7 @@ "author": "GitHub", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.2", + "@github/copilot": "^1.0.3-0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -70,6 +70,7 @@ }, "files": [ "dist/**/*", + "docs/**/*", "README.md" ] } diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 8cc79bf5..b94c0a5a 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -77,6 +77,26 @@ function toJsonSchema(parameters: Tool["parameters"]): Record | return parameters; } +function getNodeExecPath(): string { + if (process.versions.bun) { + return "node"; + } + return process.execPath; +} + +/** + * Gets the path to the bundled CLI from the @github/copilot package. + * Uses index.js directly rather than npm-loader.js (which spawns the native binary). + */ +function getBundledCliPath(): string { + // Find the actual location of the @github/copilot package by resolving its sdk export + const sdkUrl = import.meta.resolve("@github/copilot/sdk"); + const sdkPath = fileURLToPath(sdkUrl); + // sdkPath is like .../node_modules/@github/copilot/sdk/index.js + // Go up two levels to get the package root, then append index.js + return join(dirname(dirname(sdkPath)), "index.js"); +} + /** * Main client for interacting with the Copilot CLI. * @@ -110,27 +130,6 @@ function toJsonSchema(parameters: Tool["parameters"]): Record | * await client.stop(); * ``` */ - -function getNodeExecPath(): string { - if (process.versions.bun) { - return "node"; - } - return process.execPath; -} - -/** - * Gets the path to the bundled CLI from the @github/copilot package. - * Uses index.js directly rather than npm-loader.js (which spawns the native binary). - */ -function getBundledCliPath(): string { - // Find the actual location of the @github/copilot package by resolving its sdk export - const sdkUrl = import.meta.resolve("@github/copilot/sdk"); - const sdkPath = fileURLToPath(sdkUrl); - // sdkPath is like .../node_modules/@github/copilot/sdk/index.js - // Go up two levels to get the package root, then append index.js - return join(dirname(dirname(sdkPath)), "index.js"); -} - export class CopilotClient { private cliProcess: ChildProcess | null = null; private connection: MessageConnection | null = null; diff --git a/nodejs/src/extension.ts b/nodejs/src/extension.ts index b84fb2b6..0a9b7b05 100644 --- a/nodejs/src/extension.ts +++ b/nodejs/src/extension.ts @@ -3,5 +3,37 @@ *--------------------------------------------------------------------------------------------*/ import { CopilotClient } from "./client.js"; +import type { CopilotSession } from "./session.js"; +import type { ResumeSessionConfig } from "./types.js"; -export const extension = new CopilotClient({ isChildProcess: true }); +/** + * Joins the current foreground session. + * + * @param config - Configuration to add to the session + * @returns A promise that resolves with the joined session + * + * @example + * ```typescript + * import { approveAll } from "@github/copilot-sdk"; + * import { joinSession } from "@github/copilot-sdk/extension"; + * + * const session = await joinSession({ + * onPermissionRequest: approveAll, + * tools: [myTool], + * }); + * ``` + */ +export async function joinSession(config: ResumeSessionConfig): Promise { + const sessionId = process.env.SESSION_ID; + if (!sessionId) { + throw new Error( + "joinSession() is intended for extensions running as child processes of the Copilot CLI." + ); + } + + const client = new CopilotClient({ isChildProcess: true }); + return client.resumeSession(sessionId, { + ...config, + disableResume: config.disableResume ?? true, + }); +} diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index c230348e..ec40bfa6 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -173,6 +173,7 @@ export interface SessionModelSwitchToParams { */ sessionId: string; modelId: string; + reasoningEffort?: "low" | "medium" | "high" | "xhigh"; } export interface SessionModeGetResult { @@ -489,6 +490,32 @@ export interface SessionPermissionsHandlePendingPermissionRequestParams { }; } +export interface SessionLogResult { + /** + * The unique identifier of the emitted session event + */ + eventId: string; +} + +export interface SessionLogParams { + /** + * Target session identifier + */ + sessionId: string; + /** + * Human-readable message + */ + message: string; + /** + * Log severity level. Determines how the message is displayed in the timeline. Defaults to "info". + */ + level?: "info" | "warning" | "error"; + /** + * When true, the message is transient and not persisted to the session event log on disk + */ + ephemeral?: boolean; +} + /** Create typed server-scoped RPC methods (no session required). */ export function createServerRpc(connection: MessageConnection) { return { @@ -566,5 +593,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin handlePendingPermissionRequest: async (params: Omit): Promise => connection.sendRequest("session.permissions.handlePendingPermissionRequest", { sessionId, ...params }), }, + log: async (params: Omit): Promise => + connection.sendRequest("session.log", { sessionId, ...params }), }; } diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index cf87e102..f5329cc8 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -68,6 +68,7 @@ export type SessionEvent = */ branch?: string; }; + alreadyInUse?: boolean; }; } | { @@ -118,6 +119,7 @@ export type SessionEvent = */ branch?: string; }; + alreadyInUse?: boolean; }; } | { @@ -2152,6 +2154,84 @@ export type SessionEvent = }; }; } + | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ + ephemeral?: boolean; + type: "system.notification"; + data: { + /** + * The notification text, typically wrapped in XML tags + */ + content: string; + /** + * Structured metadata identifying what triggered this notification + */ + kind: + | { + type: "agent_completed"; + /** + * Unique identifier of the background agent + */ + agentId: string; + /** + * Type of the agent (e.g., explore, task, general-purpose) + */ + agentType: string; + /** + * Whether the agent completed successfully or failed + */ + status: "completed" | "failed"; + /** + * Human-readable description of the agent task + */ + description?: string; + /** + * The full prompt given to the background agent + */ + prompt?: string; + } + | { + type: "shell_completed"; + /** + * Unique identifier of the shell session + */ + shellId: string; + /** + * Exit code of the shell command, if available + */ + exitCode?: number; + /** + * Human-readable description of the command + */ + description?: string; + } + | { + type: "shell_detached_completed"; + /** + * Unique identifier of the detached shell session + */ + shellId: string; + /** + * Human-readable description of the command + */ + description?: string; + }; + }; + } | { /** * Unique event identifier (UUID v4), generated when the event is emitted diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 181d1a96..c8c88d2c 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -7,8 +7,8 @@ * @module session */ -import type { MessageConnection } from "vscode-jsonrpc/node"; -import { ConnectionError, ResponseError } from "vscode-jsonrpc/node"; +import type { MessageConnection } from "vscode-jsonrpc/node.js"; +import { ConnectionError, ResponseError } from "vscode-jsonrpc/node.js"; import { createSessionRpc } from "./generated/rpc.js"; import type { MessageOptions, @@ -693,4 +693,27 @@ export class CopilotSession { async setModel(model: string): Promise { await this.rpc.model.switchTo({ modelId: model }); } + + /** + * Log a message to the session timeline. + * The message appears in the session event stream and is visible to SDK consumers + * and (for non-ephemeral messages) persisted to the session event log on disk. + * + * @param message - Human-readable message text + * @param options - Optional log level and ephemeral flag + * + * @example + * ```typescript + * await session.log("Processing started"); + * await session.log("Disk usage high", { level: "warning" }); + * await session.log("Connection failed", { level: "error" }); + * await session.log("Debug info", { ephemeral: true }); + * ``` + */ + async log( + message: string, + options?: { level?: "info" | "warning" | "error"; ephemeral?: boolean } + ): Promise { + await this.rpc.log({ message, ...options }); + } } diff --git a/nodejs/test/e2e/session.test.ts b/nodejs/test/e2e/session.test.ts index e988e62c..7cd781bc 100644 --- a/nodejs/test/e2e/session.test.ts +++ b/nodejs/test/e2e/session.test.ts @@ -1,5 +1,5 @@ import { rm } from "fs/promises"; -import { describe, expect, it, onTestFinished } from "vitest"; +import { describe, expect, it, onTestFinished, vi } from "vitest"; import { ParsedHttpExchange } from "../../../test/harness/replayingCapiProxy.js"; import { CopilotClient, approveAll } from "../../src/index.js"; import { createSdkTestContext, isCI } from "./harness/sdkTestContext.js"; @@ -334,6 +334,57 @@ describe("Sessions", async () => { const assistantMessage = await getFinalAssistantMessage(session); expect(assistantMessage.data.content).toContain("2"); }); + + it("should log messages at all levels and emit matching session events", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + + const events: Array<{ type: string; id?: string; data?: Record }> = []; + session.on((event) => { + events.push(event as (typeof events)[number]); + }); + + await session.log("Info message"); + await session.log("Warning message", { level: "warning" }); + await session.log("Error message", { level: "error" }); + await session.log("Ephemeral message", { ephemeral: true }); + + await vi.waitFor( + () => { + const notifications = events.filter( + (e) => + e.data && + ("infoType" in e.data || "warningType" in e.data || "errorType" in e.data) + ); + expect(notifications).toHaveLength(4); + }, + { timeout: 10_000 } + ); + + const byMessage = (msg: string) => events.find((e) => e.data?.message === msg)!; + expect(byMessage("Info message").type).toBe("session.info"); + expect(byMessage("Info message").data).toEqual({ + infoType: "notification", + message: "Info message", + }); + + expect(byMessage("Warning message").type).toBe("session.warning"); + expect(byMessage("Warning message").data).toEqual({ + warningType: "notification", + message: "Warning message", + }); + + expect(byMessage("Error message").type).toBe("session.error"); + expect(byMessage("Error message").data).toEqual({ + errorType: "notification", + message: "Error message", + }); + + expect(byMessage("Ephemeral message").type).toBe("session.info"); + expect(byMessage("Ephemeral message").data).toEqual({ + infoType: "notification", + message: "Ephemeral message", + }); + }); }); function getSystemMessage(exchange: ParsedHttpExchange): string | undefined { diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index ef188b09..d5fa7b73 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -13,6 +13,7 @@ from typing import Any, TypeVar, cast from collections.abc import Callable from enum import Enum +from uuid import UUID T = TypeVar("T") @@ -465,19 +466,30 @@ def to_dict(self) -> dict: return result +class ReasoningEffort(Enum): + HIGH = "high" + LOW = "low" + MEDIUM = "medium" + XHIGH = "xhigh" + + @dataclass class SessionModelSwitchToParams: model_id: str + reasoning_effort: ReasoningEffort | None = None @staticmethod def from_dict(obj: Any) -> 'SessionModelSwitchToParams': assert isinstance(obj, dict) model_id = from_str(obj.get("modelId")) - return SessionModelSwitchToParams(model_id) + reasoning_effort = from_union([ReasoningEffort, from_none], obj.get("reasoningEffort")) + return SessionModelSwitchToParams(model_id, reasoning_effort) def to_dict(self) -> dict: result: dict = {} result["modelId"] = from_str(self.model_id) + if self.reasoning_effort is not None: + result["reasoningEffort"] = from_union([lambda x: to_enum(ReasoningEffort, x), from_none], self.reasoning_effort) return result @@ -1065,6 +1077,63 @@ def to_dict(self) -> dict: return result +@dataclass +class SessionLogResult: + event_id: UUID + """The unique identifier of the emitted session event""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionLogResult': + assert isinstance(obj, dict) + event_id = UUID(obj.get("eventId")) + return SessionLogResult(event_id) + + def to_dict(self) -> dict: + result: dict = {} + result["eventId"] = str(self.event_id) + return result + + +class Level(Enum): + """Log severity level. Determines how the message is displayed in the timeline. Defaults to + "info". + """ + ERROR = "error" + INFO = "info" + WARNING = "warning" + + +@dataclass +class SessionLogParams: + message: str + """Human-readable message""" + + ephemeral: bool | None = None + """When true, the message is transient and not persisted to the session event log on disk""" + + level: Level | None = None + """Log severity level. Determines how the message is displayed in the timeline. Defaults to + "info". + """ + + @staticmethod + def from_dict(obj: Any) -> 'SessionLogParams': + assert isinstance(obj, dict) + message = from_str(obj.get("message")) + ephemeral = from_union([from_bool, from_none], obj.get("ephemeral")) + level = from_union([Level, from_none], obj.get("level")) + return SessionLogParams(message, ephemeral, level) + + def to_dict(self) -> dict: + result: dict = {} + result["message"] = from_str(self.message) + if self.ephemeral is not None: + result["ephemeral"] = from_union([from_bool, from_none], self.ephemeral) + if self.level is not None: + result["level"] = from_union([lambda x: to_enum(Level, x), from_none], self.level) + return result + + def ping_result_from_dict(s: Any) -> PingResult: return PingResult.from_dict(s) @@ -1329,6 +1398,22 @@ def session_permissions_handle_pending_permission_request_params_to_dict(x: Sess return to_class(SessionPermissionsHandlePendingPermissionRequestParams, x) +def session_log_result_from_dict(s: Any) -> SessionLogResult: + return SessionLogResult.from_dict(s) + + +def session_log_result_to_dict(x: SessionLogResult) -> Any: + return to_class(SessionLogResult, x) + + +def session_log_params_from_dict(s: Any) -> SessionLogParams: + return SessionLogParams.from_dict(s) + + +def session_log_params_to_dict(x: SessionLogParams) -> Any: + return to_class(SessionLogParams, x) + + def _timeout_kwargs(timeout: float | None) -> dict: """Build keyword arguments for optional timeout forwarding.""" if timeout is not None: @@ -1515,3 +1600,8 @@ def __init__(self, client: "JsonRpcClient", session_id: str): self.tools = ToolsApi(client, session_id) self.permissions = PermissionsApi(client, session_id) + async def log(self, params: SessionLogParams, *, timeout: float | None = None) -> SessionLogResult: + params_dict = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + return SessionLogResult.from_dict(await self._client.request("session.log", params_dict, **_timeout_kwargs(timeout))) + diff --git a/python/copilot/generated/session_events.py b/python/copilot/generated/session_events.py index 1b442530..69d07f77 100644 --- a/python/copilot/generated/session_events.py +++ b/python/copilot/generated/session_events.py @@ -538,6 +538,83 @@ def to_dict(self) -> dict: return result +class Status(Enum): + """Whether the agent completed successfully or failed""" + + COMPLETED = "completed" + FAILED = "failed" + + +class KindType(Enum): + AGENT_COMPLETED = "agent_completed" + SHELL_COMPLETED = "shell_completed" + SHELL_DETACHED_COMPLETED = "shell_detached_completed" + + +@dataclass +class KindClass: + """Structured metadata identifying what triggered this notification""" + + type: KindType + agent_id: str | None = None + """Unique identifier of the background agent""" + + agent_type: str | None = None + """Type of the agent (e.g., explore, task, general-purpose)""" + + description: str | None = None + """Human-readable description of the agent task + + Human-readable description of the command + """ + prompt: str | None = None + """The full prompt given to the background agent""" + + status: Status | None = None + """Whether the agent completed successfully or failed""" + + exit_code: float | None = None + """Exit code of the shell command, if available""" + + shell_id: str | None = None + """Unique identifier of the shell session + + Unique identifier of the detached shell session + """ + + @staticmethod + def from_dict(obj: Any) -> 'KindClass': + assert isinstance(obj, dict) + type = KindType(obj.get("type")) + agent_id = from_union([from_str, from_none], obj.get("agentId")) + agent_type = from_union([from_str, from_none], obj.get("agentType")) + description = from_union([from_str, from_none], obj.get("description")) + prompt = from_union([from_str, from_none], obj.get("prompt")) + status = from_union([Status, from_none], obj.get("status")) + exit_code = from_union([from_float, from_none], obj.get("exitCode")) + shell_id = from_union([from_str, from_none], obj.get("shellId")) + return KindClass(type, agent_id, agent_type, description, prompt, status, exit_code, shell_id) + + def to_dict(self) -> dict: + result: dict = {} + result["type"] = to_enum(KindType, self.type) + if self.agent_id is not None: + result["agentId"] = from_union([from_str, from_none], self.agent_id) + if self.agent_type is not None: + result["agentType"] = from_union([from_str, from_none], self.agent_type) + if self.description is not None: + result["description"] = from_union([from_str, from_none], self.description) + if self.prompt is not None: + result["prompt"] = from_union([from_str, from_none], self.prompt) + if self.status is not None: + result["status"] = from_union([lambda x: to_enum(Status, x), from_none], self.status) + if self.exit_code is not None: + result["exitCode"] = from_union([to_float, from_none], self.exit_code) + if self.shell_id is not None: + result["shellId"] = from_union([from_str, from_none], self.shell_id) + return result + + @dataclass class Metadata: """Metadata about the prompt template and its construction""" @@ -1305,6 +1382,7 @@ class Data: Empty payload; the event signals that the custom agent was deselected, returning to the default agent """ + already_in_use: bool | None = None context: ContextClass | str | None = None """Working directory and git context at session start @@ -1583,6 +1661,8 @@ class Data: Full content of the skill file, injected into the conversation for the model The system or developer prompt text + + The notification text, typically wrapped in XML tags """ interaction_id: str | None = None """CAPI interaction ID for correlating this user message with its turn @@ -1793,6 +1873,9 @@ class Data: role: Role | None = None """Message role: "system" for system prompts, "developer" for developer-injected instructions""" + kind: KindClass | None = None + """Structured metadata identifying what triggered this notification""" + permission_request: PermissionRequest | None = None """Details of the permission being requested""" @@ -1826,6 +1909,7 @@ class Data: @staticmethod def from_dict(obj: Any) -> 'Data': assert isinstance(obj, dict) + already_in_use = from_union([from_bool, from_none], obj.get("alreadyInUse")) context = from_union([ContextClass.from_dict, from_str, from_none], obj.get("context")) copilot_version = from_union([from_str, from_none], obj.get("copilotVersion")) producer = from_union([from_str, from_none], obj.get("producer")) @@ -1944,6 +2028,7 @@ def from_dict(obj: Any) -> 'Data': output = obj.get("output") metadata = from_union([Metadata.from_dict, from_none], obj.get("metadata")) role = from_union([Role, from_none], obj.get("role")) + kind = from_union([KindClass.from_dict, from_none], obj.get("kind")) permission_request = from_union([PermissionRequest.from_dict, from_none], obj.get("permissionRequest")) allow_freeform = from_union([from_bool, from_none], obj.get("allowFreeform")) choices = from_union([lambda x: from_list(from_str, x), from_none], obj.get("choices")) @@ -1954,10 +2039,12 @@ def from_dict(obj: Any) -> 'Data': actions = from_union([lambda x: from_list(from_str, x), from_none], obj.get("actions")) plan_content = from_union([from_str, from_none], obj.get("planContent")) recommended_action = from_union([from_str, from_none], obj.get("recommendedAction")) - return Data(context, copilot_version, producer, selected_model, session_id, start_time, version, event_count, resume_time, error_type, message, provider_call_id, stack, status_code, background_tasks, title, info_type, warning_type, new_model, previous_model, new_mode, previous_mode, operation, path, handoff_time, remote_session_id, repository, source_type, summary, messages_removed_during_truncation, performed_by, post_truncation_messages_length, post_truncation_tokens_in_messages, pre_truncation_messages_length, pre_truncation_tokens_in_messages, token_limit, tokens_removed_during_truncation, events_removed, up_to_event_id, code_changes, current_model, error_reason, model_metrics, session_start_time, shutdown_type, total_api_duration_ms, total_premium_requests, branch, cwd, git_root, current_tokens, messages_length, checkpoint_number, checkpoint_path, compaction_tokens_used, error, messages_removed, post_compaction_tokens, pre_compaction_messages_length, pre_compaction_tokens, request_id, success, summary_content, tokens_removed, agent_mode, attachments, content, interaction_id, source, transformed_content, turn_id, intent, reasoning_id, delta_content, total_response_size_bytes, encrypted_content, message_id, output_tokens, parent_tool_call_id, phase, reasoning_opaque, reasoning_text, tool_requests, api_call_id, cache_read_tokens, cache_write_tokens, copilot_usage, cost, duration, initiator, input_tokens, model, quota_snapshots, reason, arguments, tool_call_id, tool_name, mcp_server_name, mcp_tool_name, partial_output, progress_message, is_user_requested, result, tool_telemetry, allowed_tools, name, plugin_name, plugin_version, agent_description, agent_display_name, agent_name, tools, hook_invocation_id, hook_type, input, output, metadata, role, permission_request, allow_freeform, choices, question, mode, requested_schema, command, actions, plan_content, recommended_action) + return Data(already_in_use, context, copilot_version, producer, selected_model, session_id, start_time, version, event_count, resume_time, error_type, message, provider_call_id, stack, status_code, background_tasks, title, info_type, warning_type, new_model, previous_model, new_mode, previous_mode, operation, path, handoff_time, remote_session_id, repository, source_type, summary, messages_removed_during_truncation, performed_by, post_truncation_messages_length, post_truncation_tokens_in_messages, pre_truncation_messages_length, pre_truncation_tokens_in_messages, token_limit, tokens_removed_during_truncation, events_removed, up_to_event_id, code_changes, current_model, error_reason, model_metrics, session_start_time, shutdown_type, total_api_duration_ms, total_premium_requests, branch, cwd, git_root, current_tokens, messages_length, checkpoint_number, checkpoint_path, compaction_tokens_used, error, messages_removed, post_compaction_tokens, pre_compaction_messages_length, pre_compaction_tokens, request_id, success, summary_content, tokens_removed, agent_mode, attachments, content, interaction_id, source, transformed_content, turn_id, intent, reasoning_id, delta_content, total_response_size_bytes, encrypted_content, message_id, output_tokens, parent_tool_call_id, phase, reasoning_opaque, reasoning_text, tool_requests, api_call_id, cache_read_tokens, cache_write_tokens, copilot_usage, cost, duration, initiator, input_tokens, model, quota_snapshots, reason, arguments, tool_call_id, tool_name, mcp_server_name, mcp_tool_name, partial_output, progress_message, is_user_requested, result, tool_telemetry, allowed_tools, name, plugin_name, plugin_version, agent_description, agent_display_name, agent_name, tools, hook_invocation_id, hook_type, input, output, metadata, role, kind, permission_request, allow_freeform, choices, question, mode, requested_schema, command, actions, plan_content, recommended_action) def to_dict(self) -> dict: result: dict = {} + if self.already_in_use is not None: + result["alreadyInUse"] = from_union([from_bool, from_none], self.already_in_use) if self.context is not None: result["context"] = from_union([lambda x: to_class(ContextClass, x), from_str, from_none], self.context) if self.copilot_version is not None: @@ -2194,6 +2281,8 @@ def to_dict(self) -> dict: result["metadata"] = from_union([lambda x: to_class(Metadata, x), from_none], self.metadata) if self.role is not None: result["role"] = from_union([lambda x: to_enum(Role, x), from_none], self.role) + if self.kind is not None: + result["kind"] = from_union([lambda x: to_class(KindClass, x), from_none], self.kind) if self.permission_request is not None: result["permissionRequest"] = from_union([lambda x: to_class(PermissionRequest, x), from_none], self.permission_request) if self.allow_freeform is not None: @@ -2268,6 +2357,7 @@ class SessionEventType(Enum): SUBAGENT_SELECTED = "subagent.selected" SUBAGENT_STARTED = "subagent.started" SYSTEM_MESSAGE = "system.message" + SYSTEM_NOTIFICATION = "system.notification" TOOL_EXECUTION_COMPLETE = "tool.execution_complete" TOOL_EXECUTION_PARTIAL_RESULT = "tool.execution_partial_result" TOOL_EXECUTION_PROGRESS = "tool.execution_progress" diff --git a/python/copilot/session.py b/python/copilot/session.py index e0e72fc6..ee46cbd7 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -13,7 +13,9 @@ from .generated.rpc import ( Kind, + Level, ResultResult, + SessionLogParams, SessionModelSwitchToParams, SessionPermissionsHandlePendingPermissionRequestParams, SessionPermissionsHandlePendingPermissionRequestParamsResult, @@ -733,3 +735,37 @@ async def set_model(self, model: str) -> None: >>> await session.set_model("gpt-4.1") """ await self.rpc.model.switch_to(SessionModelSwitchToParams(model_id=model)) + + async def log( + self, + message: str, + *, + level: str | None = None, + ephemeral: bool | None = None, + ) -> None: + """ + Log a message to the session timeline. + + The message appears in the session event stream and is visible to SDK consumers + and (for non-ephemeral messages) persisted to the session event log on disk. + + Args: + message: The human-readable message to log. + level: Log severity level ("info", "warning", "error"). Defaults to "info". + ephemeral: When True, the message is transient and not persisted to disk. + + Raises: + Exception: If the session has been destroyed or the connection fails. + + Example: + >>> await session.log("Processing started") + >>> await session.log("Something looks off", level="warning") + >>> await session.log("Operation failed", level="error") + >>> await session.log("Temporary status update", ephemeral=True) + """ + params = SessionLogParams( + message=message, + level=Level(level) if level is not None else None, + ephemeral=ephemeral, + ) + await self.rpc.log(params) diff --git a/python/e2e/test_session.py b/python/e2e/test_session.py index 60cb7c87..aa93ed42 100644 --- a/python/e2e/test_session.py +++ b/python/e2e/test_session.py @@ -501,6 +501,49 @@ async def test_should_create_session_with_custom_config_dir(self, ctx: E2ETestCo assistant_message = await get_final_assistant_message(session) assert "2" in assistant_message.data.content + async def test_session_log_emits_events_at_all_levels(self, ctx: E2ETestContext): + import asyncio + + session = await ctx.client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) + + received_events = [] + + def on_event(event): + if event.type.value in ("session.info", "session.warning", "session.error"): + received_events.append(event) + + session.on(on_event) + + await session.log("Info message") + await session.log("Warning message", level="warning") + await session.log("Error message", level="error") + await session.log("Ephemeral message", ephemeral=True) + + # Poll until all 4 notification events arrive + deadline = asyncio.get_event_loop().time() + 10 + while len(received_events) < 4: + if asyncio.get_event_loop().time() > deadline: + pytest.fail( + f"Timed out waiting for 4 notification events, got {len(received_events)}" + ) + await asyncio.sleep(0.1) + + by_message = {e.data.message: e for e in received_events} + + assert by_message["Info message"].type.value == "session.info" + assert by_message["Info message"].data.info_type == "notification" + + assert by_message["Warning message"].type.value == "session.warning" + assert by_message["Warning message"].data.warning_type == "notification" + + assert by_message["Error message"].type.value == "session.error" + assert by_message["Error message"].data.error_type == "notification" + + assert by_message["Ephemeral message"].type.value == "session.info" + assert by_message["Ephemeral message"].data.info_type == "notification" + def _get_system_message(exchange: dict) -> str: messages = exchange.get("request", {}).get("messages", []) diff --git a/scripts/codegen/csharp.ts b/scripts/codegen/csharp.ts index af5fb78a..c72eb06d 100644 --- a/scripts/codegen/csharp.ts +++ b/scripts/codegen/csharp.ts @@ -701,12 +701,21 @@ function emitServerInstanceMethod( function emitSessionRpcClasses(node: Record, classes: string[]): string[] { const result: string[] = []; const groups = Object.entries(node).filter(([, v]) => typeof v === "object" && v !== null && !isRpcMethod(v)); + const topLevelMethods = Object.entries(node).filter(([, v]) => isRpcMethod(v)); const srLines = [`/// Typed session-scoped RPC methods.`, `public class SessionRpc`, `{`, ` private readonly JsonRpc _rpc;`, ` private readonly string _sessionId;`, ""]; srLines.push(` internal SessionRpc(JsonRpc rpc, string sessionId)`, ` {`, ` _rpc = rpc;`, ` _sessionId = sessionId;`); for (const [groupName] of groups) srLines.push(` ${toPascalCase(groupName)} = new ${toPascalCase(groupName)}Api(rpc, sessionId);`); srLines.push(` }`); for (const [groupName] of groups) srLines.push("", ` public ${toPascalCase(groupName)}Api ${toPascalCase(groupName)} { get; }`); + + // Emit top-level session RPC methods directly on the SessionRpc class + const topLevelLines: string[] = []; + for (const [key, value] of topLevelMethods) { + emitSessionMethod(key, value as RpcMethod, topLevelLines, classes, " "); + } + srLines.push(...topLevelLines); + srLines.push(`}`); result.push(srLines.join("\n")); @@ -716,50 +725,53 @@ function emitSessionRpcClasses(node: Record, classes: string[]) return result; } +function emitSessionMethod(key: string, method: RpcMethod, lines: string[], classes: string[], indent: string): void { + const methodName = toPascalCase(key); + const resultClassName = `${typeToClassName(method.rpcMethod)}Result`; + const resultClass = emitRpcClass(resultClassName, method.result, "public", classes); + if (resultClass) classes.push(resultClass); + + const paramEntries = (method.params?.properties ? Object.entries(method.params.properties) : []).filter(([k]) => k !== "sessionId"); + const requiredSet = new Set(method.params?.required || []); + + // Sort so required params come before optional (C# requires defaults at end) + paramEntries.sort((a, b) => { + const aReq = requiredSet.has(a[0]) ? 0 : 1; + const bReq = requiredSet.has(b[0]) ? 0 : 1; + return aReq - bReq; + }); + + const requestClassName = `${typeToClassName(method.rpcMethod)}Request`; + if (method.params) { + const reqClass = emitRpcClass(requestClassName, method.params, "internal", classes); + if (reqClass) classes.push(reqClass); + } + + lines.push("", `${indent}/// Calls "${method.rpcMethod}".`); + const sigParams: string[] = []; + const bodyAssignments = [`SessionId = _sessionId`]; + + for (const [pName, pSchema] of paramEntries) { + if (typeof pSchema !== "object") continue; + const isReq = requiredSet.has(pName); + const csType = resolveRpcType(pSchema as JSONSchema7, isReq, requestClassName, toPascalCase(pName), classes); + sigParams.push(`${csType} ${pName}${isReq ? "" : " = null"}`); + bodyAssignments.push(`${toPascalCase(pName)} = ${pName}`); + } + sigParams.push("CancellationToken cancellationToken = default"); + + lines.push(`${indent}public async Task<${resultClassName}> ${methodName}Async(${sigParams.join(", ")})`); + lines.push(`${indent}{`, `${indent} var request = new ${requestClassName} { ${bodyAssignments.join(", ")} };`); + lines.push(`${indent} return await CopilotClient.InvokeRpcAsync<${resultClassName}>(_rpc, "${method.rpcMethod}", [request], cancellationToken);`, `${indent}}`); +} + function emitSessionApiClass(className: string, node: Record, classes: string[]): string { const lines = [`public class ${className}`, `{`, ` private readonly JsonRpc _rpc;`, ` private readonly string _sessionId;`, ""]; lines.push(` internal ${className}(JsonRpc rpc, string sessionId)`, ` {`, ` _rpc = rpc;`, ` _sessionId = sessionId;`, ` }`); for (const [key, value] of Object.entries(node)) { if (!isRpcMethod(value)) continue; - const method = value; - const methodName = toPascalCase(key); - const resultClassName = `${typeToClassName(method.rpcMethod)}Result`; - const resultClass = emitRpcClass(resultClassName, method.result, "public", classes); - if (resultClass) classes.push(resultClass); - - const paramEntries = (method.params?.properties ? Object.entries(method.params.properties) : []).filter(([k]) => k !== "sessionId"); - const requiredSet = new Set(method.params?.required || []); - - // Sort so required params come before optional (C# requires defaults at end) - paramEntries.sort((a, b) => { - const aReq = requiredSet.has(a[0]) ? 0 : 1; - const bReq = requiredSet.has(b[0]) ? 0 : 1; - return aReq - bReq; - }); - - const requestClassName = `${typeToClassName(method.rpcMethod)}Request`; - if (method.params) { - const reqClass = emitRpcClass(requestClassName, method.params, "internal", classes); - if (reqClass) classes.push(reqClass); - } - - lines.push("", ` /// Calls "${method.rpcMethod}".`); - const sigParams: string[] = []; - const bodyAssignments = [`SessionId = _sessionId`]; - - for (const [pName, pSchema] of paramEntries) { - if (typeof pSchema !== "object") continue; - const isReq = requiredSet.has(pName); - const csType = resolveRpcType(pSchema as JSONSchema7, isReq, requestClassName, toPascalCase(pName), classes); - sigParams.push(`${csType} ${pName}${isReq ? "" : " = null"}`); - bodyAssignments.push(`${toPascalCase(pName)} = ${pName}`); - } - sigParams.push("CancellationToken cancellationToken = default"); - - lines.push(` public async Task<${resultClassName}> ${methodName}Async(${sigParams.join(", ")})`); - lines.push(` {`, ` var request = new ${requestClassName} { ${bodyAssignments.join(", ")} };`); - lines.push(` return await CopilotClient.InvokeRpcAsync<${resultClassName}>(_rpc, "${method.rpcMethod}", [request], cancellationToken);`, ` }`); + emitSessionMethod(key, value, lines, classes, " "); } lines.push(`}`); return lines.join("\n");