diff --git a/docs/features/mcp.md b/docs/features/mcp.md index 6f715bd2e..e974532b0 100644 --- a/docs/features/mcp.md +++ b/docs/features/mcp.md @@ -120,7 +120,7 @@ func main() { "my-local-server": copilot.MCPStdioServerConfig{ Command: "node", Args: []string{"./mcp-server.js"}, - Tools: []string{"*"}, + Tools: &[]string{"*"}, }, }, }) diff --git a/docs/features/streaming-events.md b/docs/features/streaming-events.md index 6bc560a48..ebf7d5e30 100644 --- a/docs/features/streaming-events.md +++ b/docs/features/streaming-events.md @@ -130,7 +130,7 @@ func main() { session, _ := client.CreateSession(ctx, &copilot.SessionConfig{ Model: "gpt-4.1", - Streaming: true, + Streaming: copilot.Bool(true), OnPermissionRequest: func(req copilot.PermissionRequest, inv copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { return copilot.PermissionRequestResult{Kind: copilot.PermissionRequestResultKindApproved}, nil }, diff --git a/docs/getting-started.md b/docs/getting-started.md index 3a43fad7c..0d5e5887e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -467,7 +467,7 @@ func main() { session, err := client.CreateSession(ctx, &copilot.SessionConfig{ Model: "gpt-4.1", - Streaming: true, + Streaming: copilot.Bool(true), }) if err != nil { log.Fatal(err) @@ -1046,7 +1046,7 @@ func main() { session, err := client.CreateSession(ctx, &copilot.SessionConfig{ Model: "gpt-4.1", - Streaming: true, + Streaming: copilot.Bool(true), Tools: []copilot.Tool{getWeather}, }) if err != nil { @@ -1482,7 +1482,7 @@ func main() { session, err := client.CreateSession(ctx, &copilot.SessionConfig{ Model: "gpt-4.1", - Streaming: true, + Streaming: copilot.Bool(true), Tools: []copilot.Tool{getWeather}, }) if err != nil { @@ -2001,7 +2001,7 @@ func main() { ctx := context.Background() client := copilot.NewClient(&copilot.ClientOptions{ - CLIUrl: "localhost:4321", + Connection: copilot.UriConnection{URL: "localhost:4321"}, }) if err := client.Start(ctx); err != nil { @@ -2021,7 +2021,7 @@ func main() { import copilot "github.com/github/copilot-sdk/go" client := copilot.NewClient(&copilot.ClientOptions{ - CLIUrl: "localhost:4321", + Connection: copilot.UriConnection{URL: "localhost:4321"}, }) if err := client.Start(ctx); err != nil { @@ -2105,7 +2105,7 @@ var session = client.createSession( -**Note:** When `cli_url` / `cliUrl` / `CLIUrl` is provided, or Rust uses `Transport::External`, the SDK will not spawn or manage a CLI process - it will only connect to the existing server at the specified URL. +**Note:** When `cli_url` / `cliUrl` / Go's `UriConnection` is provided, or Rust uses `Transport::External`, the SDK will not spawn or manage a CLI process - it will only connect to the existing server at the specified URL. ## Telemetry and observability diff --git a/docs/setup/backend-services.md b/docs/setup/backend-services.md index d9dd508e5..0552ee36e 100644 --- a/docs/setup/backend-services.md +++ b/docs/setup/backend-services.md @@ -6,7 +6,7 @@ Run the Copilot SDK in server-side applications—APIs, web backends, microservi ## How it works -Instead of the SDK spawning a CLI child process, you run the CLI independently in **headless server mode**. Your backend connects to it over TCP using the `cliUrl` option. +Instead of the SDK spawning a CLI child process, you run the CLI independently in **headless server mode**. Your backend connects to it over TCP using the `Connection` option (`UriConnection`). ```mermaid flowchart TB @@ -177,7 +177,7 @@ func main() { message := "Hello" client := copilot.NewClient(&copilot.ClientOptions{ - CLIUrl: "localhost:4321", + Connection: copilot.UriConnection{URL: "localhost:4321"}, }) client.Start(ctx) defer client.Stop() @@ -195,7 +195,7 @@ func main() { ```go client := copilot.NewClient(&copilot.ClientOptions{ - CLIUrl:"localhost:4321", + Connection: copilot.UriConnection{URL: "localhost:4321"}, }) client.Start(ctx) defer client.Stop() diff --git a/docs/setup/bundled-cli.md b/docs/setup/bundled-cli.md index c19bc85b6..8f036ee8b 100644 --- a/docs/setup/bundled-cli.md +++ b/docs/setup/bundled-cli.md @@ -71,7 +71,7 @@ await client.stop() Go > [!NOTE] -> The Go SDK does not bundle the CLI. You must install the CLI separately or set `CLIPath` to point to an existing binary. See [Local CLI Setup](./local-cli.md) for details. +> The Go SDK does not bundle the CLI. You must install the CLI separately or set `Connection` to point to an existing binary. See [Local CLI Setup](./local-cli.md) for details. ```go @@ -137,7 +137,7 @@ Console.WriteLine(response?.Data.Content); Java > [!NOTE] -> The Java SDK does not bundle or embed the Copilot CLI. You must install the CLI separately and configure its path via `cliPath` or the `COPILOT_CLI_PATH` environment variable. +> The Java SDK does not bundle or embed the Copilot CLI. You must install the CLI separately and configure its path via `Connection` or the `COPILOT_CLI_PATH` environment variable. ```java import com.github.copilot.sdk.CopilotClient; diff --git a/docs/setup/local-cli.md b/docs/setup/local-cli.md index e7da4d937..4c7b5dced 100644 --- a/docs/setup/local-cli.md +++ b/docs/setup/local-cli.md @@ -6,7 +6,7 @@ Use a specific CLI binary instead of the SDK's bundled CLI. This is an advanced ## How it works -By default, the Node.js, Python, and .NET SDKs include their own CLI dependency (see [Default Setup](./bundled-cli.md)). If you need to override this—for example, to use a system-installed CLI—you can use the `cliPath` option. +By default, the Node.js, Python, and .NET SDKs include their own CLI dependency (see [Default Setup](./bundled-cli.md)). If you need to override this—for example, to use a system-installed CLI—you can use the `Connection` option. ```mermaid flowchart LR @@ -78,7 +78,7 @@ await client.stop() Go > [!NOTE] -> The Go SDK does not bundle a CLI, so you must always provide `CLIPath`. +> The Go SDK does not bundle a CLI, so you must always provide `Connection`. ```go @@ -95,7 +95,7 @@ func main() { ctx := context.Background() client := copilot.NewClient(&copilot.ClientOptions{ - CLIPath: "/usr/local/bin/copilot", + Connection: copilot.StdioConnection{Path: "/usr/local/bin/copilot"}, }) if err := client.Start(ctx); err != nil { log.Fatal(err) @@ -115,7 +115,7 @@ func main() { ```go client := copilot.NewClient(&copilot.ClientOptions{ - CLIPath: "/usr/local/bin/copilot", + Connection: copilot.StdioConnection{Path: "/usr/local/bin/copilot"}, }) if err := client.Start(ctx); err != nil { log.Fatal(err) diff --git a/docs/troubleshooting/debugging.md b/docs/troubleshooting/debugging.md index f01beafc7..3beadb99f 100644 --- a/docs/troubleshooting/debugging.md +++ b/docs/troubleshooting/debugging.md @@ -142,18 +142,25 @@ const client = new CopilotClient({ ```go package main +import copilot "github.com/github/copilot-sdk/go" + func main() { - // The Go SDK does not currently support passing extra CLI arguments. - // For custom log directories, run the CLI manually with --log-dir - // and connect via CLIUrl option. + client := copilot.NewClient(&copilot.ClientOptions{ + Connection: copilot.StdioConnection{ + Args: []string{"--log-dir", "/path/to/logs"}, + }, + }) + _ = client } ``` ```go -// The Go SDK does not currently support passing extra CLI arguments. -// For custom log directories, run the CLI manually with --log-dir -// and connect via CLIUrl option. +client := copilot.NewClient(&copilot.ClientOptions{ + Connection: copilot.StdioConnection{ + Args: []string{"--log-dir", "/path/to/logs"}, + }, +}) ``` @@ -221,7 +228,7 @@ var client = new CopilotClient(new CopilotClientOptions ```go client := copilot.NewClient(&copilot.ClientOptions{ - CLIPath: "/usr/local/bin/copilot", + Connection: copilot.StdioConnection{Path: "/usr/local/bin/copilot"}, }) ``` diff --git a/go/README.md b/go/README.md index b84bbab91..8dcffb1a8 100644 --- a/go/README.md +++ b/go/README.md @@ -94,7 +94,7 @@ Follow these steps to embed the CLI: 1. Run `go get -tool github.com/github/copilot-sdk/go/cmd/bundler`. This is a one-time setup step per project. 2. Run `go tool bundler` in your build environment just before building your application. -That's it! When your application calls `copilot.NewClient` without a `CLIPath` nor the `COPILOT_CLI_PATH` environment variable, the SDK will automatically install the embedded CLI to a cache directory and use it for all operations. +That's it! When your application calls `copilot.NewClient` without a `Connection` field (or with an empty `StdioConnection{}`) and no `COPILOT_CLI_PATH` environment variable, the SDK will automatically install the embedded CLI to a cache directory and use it for all operations. ## API Reference @@ -110,8 +110,8 @@ That's it! When your application calls `copilot.NewClient` without a `CLIPath` n - `ListSessions(filter *SessionListFilter) ([]SessionMetadata, error)` - List sessions (with optional filter) - `DeleteSession(sessionID string) error` - Delete a session permanently - `GetLastSessionID(ctx context.Context) (*string, error)` - Get the ID of the most recently updated session -- `GetState() ConnectionState` - Get connection state - `Ping(message string) (*PingResponse, error)` - Ping the server +- `RuntimePort() int` - TCP port the runtime is listening on (0 if stdio) - `GetForegroundSessionID(ctx context.Context) (*string, error)` - Get the session ID currently displayed in TUI (TUI+server mode only) - `SetForegroundSessionID(ctx context.Context, sessionID string) error` - Request TUI to display a specific session (TUI+server mode only) - `On(handler SessionLifecycleHandler) func()` - Subscribe to all lifecycle events; returns unsubscribe function @@ -136,18 +136,20 @@ Event types: `SessionLifecycleCreated`, `SessionLifecycleDeleted`, `SessionLifec **ClientOptions:** -- `CLIPath` (string): Path to CLI executable (default: "copilot" or `COPILOT_CLI_PATH` env var) -- `CLIUrl` (string): URL of existing CLI server (e.g., `"localhost:8080"`, `"http://127.0.0.1:9000"`, or just `"8080"`). When provided, the client will not spawn a CLI process. -- `Cwd` (string): Working directory for CLI process -- `CopilotHome` (string): Base directory for Copilot data (session state, config, etc.). Sets `COPILOT_HOME` on the spawned CLI process. When empty, the CLI defaults to `~/.copilot`. Useful in restricted environments where only specific directories are writable. Ignored when using `CLIUrl`. This does **not** affect where the Go SDK extracts the embedded CLI binary; use `embeddedcli.Config.Dir` for the extraction/cache location. You can vary `CopilotHome` per client independently of the shared extracted binary location. -- `Port` (int): Server port for TCP mode (default: 0 for random) -- `UseStdio` (bool): Use stdio transport instead of TCP (default: true) -- `LogLevel` (string): Log level (default: "info") -- `AutoStart` (\*bool): Auto-start server on first use (default: true). Use `Bool(false)` to disable. -- `Env` ([]string): Environment variables for CLI process (default: inherits from current process) +- `Connection` (RuntimeConnection): How the SDK connects to the runtime. Construct via one of: + - `StdioConnection{Path, Args}` — spawn a runtime over stdio (the default if `Connection` is nil) + - `TcpConnection{Port, ConnectionToken, Path, Args}` — spawn a runtime that listens on TCP + - `UriConnection{URL, ConnectionToken}` — connect to an already-running runtime (no process spawned) + + When `Path` is empty for stdio/tcp, the SDK uses the bundled CLI (or `COPILOT_CLI_PATH` env var). +- `Cwd` (string): Working directory for the runtime process +- `BaseDirectory` (string): Base directory for Copilot data (session state, config, etc.). Sets `COPILOT_HOME` on the spawned runtime. When empty, the runtime defaults to `~/.copilot`. Ignored with `UriConnection`. This does **not** affect where the Go SDK extracts the embedded CLI binary; use `embeddedcli.Config.Dir` for the extraction/cache location. +- `LogLevel` (string): Log level. When empty (default), the runtime uses its own default level (the SDK does not pass `--log-level`). +- `Env` ([]string): Environment variables for the runtime process (default: inherits from current process) - `GitHubToken` (string): GitHub token for authentication. When provided, takes priority over other auth methods. -- `UseLoggedInUser` (\*bool): Whether to use logged-in user for authentication (default: true, but false when `GitHubToken` is provided). Cannot be used with `CLIUrl`. -- `Telemetry` (\*TelemetryConfig): OpenTelemetry configuration for the CLI process. Providing this enables telemetry — no separate flag needed. See [Telemetry](#telemetry) below. +- `UseLoggedInUser` (\*bool): Whether to use logged-in user for authentication (default: true, but false when `GitHubToken` is provided). Cannot be used with `UriConnection`. +- `EnableRemoteSessions` (bool): Enable remote session support (Mission Control integration). Ignored with `UriConnection`. +- `Telemetry` (\*TelemetryConfig): OpenTelemetry configuration for the runtime. Providing this enables telemetry — no separate flag needed. See [Telemetry](#telemetry) below. **SessionConfig:** @@ -160,7 +162,7 @@ Event types: `SessionLifecycleCreated`, `SessionLifecycleDeleted`, `SessionLifec - **replace**: Replaces the entire prompt with `Content` - **customize**: Selectively override individual sections via `Sections` map (keys: `SectionIdentity`, `SectionTone`, `SectionToolEfficiency`, `SectionEnvironmentContext`, `SectionCodeChangeRules`, `SectionGuidelines`, `SectionSafety`, `SectionToolInstructions`, `SectionCustomInstructions`, `SectionLastInstructions`; values: `SectionOverride` with `Action` and optional `Content`) - `Provider` (\*ProviderConfig): Custom API provider configuration (BYOK). See [Custom Providers](#custom-providers) section. -- `Streaming` (bool): Enable streaming delta events +- `Streaming` (*bool): Enable streaming delta events (nil = runtime default) - `InfiniteSessions` (\*InfiniteSessionConfig): Automatic context compaction configuration - `OnPermissionRequest` (PermissionHandlerFunc): Optional handler called before each tool execution to approve or deny it. When nil, permission requests are emitted as events and left pending for manual resolution. Use `copilot.PermissionHandler.ApproveAll` to allow everything, or provide a custom function for fine-grained control. See [Permission Handling](#permission-handling) section. - `OnUserInputRequest` (UserInputHandler): Handler for user input requests from the agent (enables ask_user tool). See [User Input Requests](#user-input-requests) section. @@ -174,7 +176,7 @@ Event types: `SessionLifecycleCreated`, `SessionLifecycleDeleted`, `SessionLifec - `Tools` ([]Tool): Tools to expose when resuming - `ReasoningEffort` (string): Reasoning effort level for models that support it - `Provider` (\*ProviderConfig): Custom API provider configuration (BYOK). See [Custom Providers](#custom-providers) section. -- `Streaming` (bool): Enable streaming delta events +- `Streaming` (*bool): Enable streaming delta events (nil = runtime default) - `Commands` ([]CommandDefinition): Slash-commands. See [Commands](#commands) section. - `OnElicitationRequest` (ElicitationHandler): Elicitation handler. See [Elicitation Requests](#elicitation-requests-serverclient) section. @@ -183,15 +185,14 @@ Event types: `SessionLifecycleCreated`, `SessionLifecycleDeleted`, `SessionLifec - `Send(ctx context.Context, options MessageOptions) (string, error)` - Send a message - `On(handler SessionEventHandler) func()` - Subscribe to events (returns unsubscribe function) - `Abort(ctx context.Context) error` - Abort the currently processing message -- `GetMessages(ctx context.Context) ([]SessionEvent, error)` - Get message history +- `GetEvents(ctx context.Context) ([]SessionEvent, error)` - Get event history - `Disconnect() error` - Disconnect the session (releases in-memory resources, preserves disk state) -- `Destroy() error` - _(Deprecated)_ Use `Disconnect()` instead - `UI() *SessionUI` - Interactive UI API for elicitation dialogs - `Capabilities() SessionCapabilities` - Host capabilities (e.g. elicitation support) ### Helper Functions -- `Bool(v bool) *bool` - Helper to create bool pointers for `AutoStart` option +- `Bool(v bool) *bool` - Helper to create bool pointers (e.g. for `Streaming`) - `Int(v int) *int` - Helper to create int pointers for `MinLength`, `MaxLength` - `String(v string) *string` - Helper to create string pointers - `Float64(v float64) *float64` - Helper to create float64 pointers @@ -398,7 +399,7 @@ func main() { session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{ Model: "gpt-5", - Streaming: true, + Streaming: copilot.Bool(true), }) if err != nil { log.Fatal(err) @@ -439,7 +440,7 @@ func main() { } ``` -When `Streaming: true`: +When `Streaming: copilot.Bool(true)`: - `assistant.message_delta` events are sent with `DeltaContent` containing incremental text - `assistant.reasoning_delta` events are sent with `DeltaContent` for reasoning/chain-of-thought (model-dependent) @@ -797,7 +798,7 @@ confirmed, err := ui.Confirm(ctx, "Deploy to production?") choice, ok, err := ui.Select(ctx, "Pick an environment", []string{"staging", "production"}) // Text input — returns (text, ok bool, error) -name, ok, err := ui.Input(ctx, "Enter the release name", &copilot.InputOptions{ +name, ok, err := ui.Input(ctx, "Enter the release name", &copilot.UiInputOptions{ Title: "Release Name", Description: "A short name for the release", MinLength: copilot.Int(1), diff --git a/go/client.go b/go/client.go index 9fa772129..dab09a4dd 100644 --- a/go/client.go +++ b/go/client.go @@ -95,13 +95,17 @@ type Client struct { client *jsonrpc2.Client actualPort int actualHost string - state ConnectionState + state connectionState sessions map[string]*Session sessionsMux sync.Mutex isExternalServer bool conn net.Conn // stores net.Conn for external TCP connections useStdio bool // resolved value from options - autoStart bool // resolved value from options + // resolved process options for the spawned runtime (zero values for UriConnection) + cliPath string + cliArgs []string + port int + tcpConnectionToken string modelsCache []ModelInfo modelsCacheMux sync.Mutex @@ -128,115 +132,81 @@ type Client struct { internalRPC *rpc.InternalServerRpc } -// NewClient creates a new Copilot CLI client with the given options. +// NewClient creates a new Copilot runtime client with the given options. // -// If options is nil, default options are used (spawns CLI server using stdio). -// The client is not connected after creation; call [Client.Start] to connect. +// If options is nil, default options are used (spawns the bundled runtime over +// stdio). The client is not connected after creation; call [Client.Start] to +// connect, or simply call [Client.CreateSession]/[Client.ResumeSession], which +// auto-start the runtime on first use. // // Example: // -// // Default options +// // Default options: bundled runtime over stdio // client := copilot.NewClient(nil) // -// // Custom options +// // Custom CLI path over stdio // client := copilot.NewClient(&copilot.ClientOptions{ -// CLIPath: "/usr/local/bin/copilot", -// LogLevel: "debug", +// Connection: copilot.StdioConnection{Path: "/usr/local/bin/copilot"}, +// LogLevel: "debug", +// }) +// +// // Connect to an already-running runtime +// client := copilot.NewClient(&copilot.ClientOptions{ +// Connection: copilot.UriConnection{URL: "localhost:8080"}, // }) func NewClient(options *ClientOptions) *Client { - opts := ClientOptions{ - CLIPath: "", - Cwd: "", - Port: 0, - LogLevel: "info", - } + opts := ClientOptions{} client := &Client{ options: opts, - state: StateDisconnected, + state: stateDisconnected, sessions: make(map[string]*Session), actualHost: "localhost", isExternalServer: false, useStdio: true, - autoStart: true, // default } if options != nil { - // Validate mutually exclusive options - if options.CLIUrl != "" && ((options.UseStdio != nil) || options.CLIPath != "") { - panic("CLIUrl is mutually exclusive with UseStdio and CLIPath") - } + opts = *options + } - // Validate auth options with external server - if options.CLIUrl != "" && (options.GitHubToken != "" || options.UseLoggedInUser != nil) { - panic("GitHubToken and UseLoggedInUser cannot be used with CLIUrl (external server manages its own auth)") + // Resolve the connection. nil defaults to an empty StdioConnection. + connection := opts.Connection + if connection == nil { + connection = StdioConnection{} + } + switch conn := connection.(type) { + case StdioConnection: + client.useStdio = true + client.cliPath = conn.Path + if len(conn.Args) > 0 { + client.cliArgs = append([]string{}, conn.Args...) } - - // Validate token vs stdio - if options.TCPConnectionToken != "" && options.UseStdio != nil && *options.UseStdio { - panic("TCPConnectionToken cannot be used with UseStdio: true") + case TcpConnection: + client.useStdio = false + client.cliPath = conn.Path + if len(conn.Args) > 0 { + client.cliArgs = append([]string{}, conn.Args...) } - - // Parse CLIUrl if provided - if options.CLIUrl != "" { - host, port := parseCliUrl(options.CLIUrl) - client.actualHost = host - client.actualPort = port - client.isExternalServer = true - client.useStdio = false - opts.CLIUrl = options.CLIUrl + client.port = conn.Port + client.tcpConnectionToken = conn.ConnectionToken + case UriConnection: + if conn.URL == "" { + panic("UriConnection requires a non-empty URL") } + host, port := parseCliUrl(conn.URL) + client.actualHost = host + client.actualPort = port + client.isExternalServer = true + client.useStdio = false + client.tcpConnectionToken = conn.ConnectionToken + default: + panic(fmt.Sprintf("unknown RuntimeConnection type: %T", connection)) + } - if options.CLIPath != "" { - opts.CLIPath = options.CLIPath - } - if len(options.CLIArgs) > 0 { - opts.CLIArgs = append([]string{}, options.CLIArgs...) - } - if options.Cwd != "" { - opts.Cwd = options.Cwd - } - if options.Port > 0 { - opts.Port = options.Port - // If port is specified, switch to TCP mode - client.useStdio = false - } - if options.LogLevel != "" { - opts.LogLevel = options.LogLevel - } - if options.Env != nil { - opts.Env = options.Env - } - if options.UseStdio != nil { - client.useStdio = *options.UseStdio - } - if options.AutoStart != nil { - client.autoStart = *options.AutoStart - } - if options.GitHubToken != "" { - opts.GitHubToken = options.GitHubToken - } - if options.UseLoggedInUser != nil { - opts.UseLoggedInUser = options.UseLoggedInUser - } - if options.OnListModels != nil { - client.onListModels = options.OnListModels - } - if options.SessionFs != nil { - if err := validateSessionFsConfig(options.SessionFs); err != nil { - panic(err.Error()) - } - sessionFs := *options.SessionFs - opts.SessionFs = &sessionFs - } - if options.Telemetry != nil { - opts.Telemetry = options.Telemetry - } - if options.CopilotHome != "" { - opts.CopilotHome = options.CopilotHome - } - opts.SessionIdleTimeoutSeconds = options.SessionIdleTimeoutSeconds - opts.Remote = options.Remote + // Validate auth options when connecting to an external runtime. + if client.isExternalServer && (opts.GitHubToken != "" || opts.UseLoggedInUser != nil) { + panic("GitHubToken and UseLoggedInUser cannot be used with UriConnection (external runtime manages its own auth)") } // Default Env to current environment if not set @@ -245,20 +215,29 @@ func NewClient(options *ClientOptions) *Client { } // Check effective environment for CLI path (only if not explicitly set via options) - if opts.CLIPath == "" { + if client.cliPath == "" { if cliPath := getEnvValue(opts.Env, "COPILOT_CLI_PATH"); cliPath != "" { - opts.CLIPath = cliPath + client.cliPath = cliPath } } // Resolve the effective connection token: explicit value if set; else if the SDK - // spawns its own CLI in TCP mode, generate a UUID; otherwise empty. - if options != nil && options.TCPConnectionToken != "" { - client.effectiveConnectionToken = options.TCPConnectionToken + // spawns its own runtime in TCP mode, generate a UUID; otherwise empty. + if client.tcpConnectionToken != "" { + client.effectiveConnectionToken = client.tcpConnectionToken } else if !client.useStdio && !client.isExternalServer { client.effectiveConnectionToken = uuid.NewString() } + if opts.OnListModels != nil { + client.onListModels = opts.OnListModels + } + if opts.SessionFs != nil { + if err := validateSessionFsConfig(opts.SessionFs); err != nil { + panic(err.Error()) + } + } + client.options = opts return client } @@ -342,17 +321,17 @@ func (c *Client) Start(ctx context.Context) error { c.startStopMux.Lock() defer c.startStopMux.Unlock() - if c.state == StateConnected { + if c.state == stateConnected { return nil } - c.state = StateConnecting + c.state = stateConnecting // Only start CLI server process if not connecting to external server if !c.isExternalServer { if err := c.startCLIServer(ctx); err != nil { c.process = nil - c.state = StateError + c.state = stateError return err } } @@ -360,14 +339,14 @@ func (c *Client) Start(ctx context.Context) error { // Connect to the server if err := c.connectToServer(ctx); err != nil { killErr := c.killProcess() - c.state = StateError + c.state = stateError return errors.Join(err, killErr) } // Verify protocol version compatibility if err := c.verifyProtocolVersion(ctx); err != nil { killErr := c.killProcess() - c.state = StateError + c.state = stateError return errors.Join(err, killErr) } @@ -387,12 +366,12 @@ func (c *Client) Start(ctx context.Context) error { _, err := c.RPC.SessionFs.SetProvider(ctx, req) if err != nil { killErr := c.killProcess() - c.state = StateError + c.state = stateError return errors.Join(err, killErr) } } - c.state = StateConnected + c.state = stateConnected return nil } @@ -465,7 +444,7 @@ func (c *Client) Stop() error { c.modelsCache = nil c.modelsCacheMux.Unlock() - c.state = StateDisconnected + c.state = stateDisconnected if !c.isExternalServer { c.actualPort = 0 } @@ -537,7 +516,7 @@ func (c *Client) ForceStop() { c.modelsCache = nil c.modelsCacheMux.Unlock() - c.state = StateDisconnected + c.state = stateDisconnected if !c.isExternalServer { c.actualPort = 0 } @@ -550,17 +529,13 @@ func (c *Client) ensureConnected(ctx context.Context) error { if c.client != nil { return nil } - if c.autoStart { - return c.Start(ctx) - } - return fmt.Errorf("client not connected. Call Start() first") + return c.Start(ctx) } // CreateSession creates a new conversation session with the Copilot CLI. // // Sessions maintain conversation state, handle events, and manage tool execution. -// If the client is not connected and AutoStart is enabled, this will automatically -// start the connection. +// If the client is not connected, this will automatically start the runtime. // // The config parameter is optional. If no OnPermissionRequest handler is provided, // permission requests are surfaced as events for the caller to resolve manually. @@ -664,15 +639,15 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses if config.OnElicitationRequest != nil { req.RequestElicitation = Bool(true) } - if config.OnExitPlanMode != nil { + if config.OnExitPlanModeRequest != nil { req.RequestExitPlanMode = Bool(true) } - if config.OnAutoModeSwitch != nil { + if config.OnAutoModeSwitchRequest != nil { req.RequestAutoModeSwitch = Bool(true) } - if config.Streaming { - req.Streaming = Bool(true) + if config.Streaming != nil { + req.Streaming = config.Streaming } if config.IncludeSubAgentStreamingEvents != nil { req.IncludeSubAgentStreamingEvents = config.IncludeSubAgentStreamingEvents @@ -726,11 +701,11 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses if config.OnElicitationRequest != nil { session.registerElicitationHandler(config.OnElicitationRequest) } - if config.OnExitPlanMode != nil { - session.registerExitPlanModeHandler(config.OnExitPlanMode) + if config.OnExitPlanModeRequest != nil { + session.registerExitPlanModeHandler(config.OnExitPlanModeRequest) } - if config.OnAutoModeSwitch != nil { - session.registerAutoModeSwitchHandler(config.OnAutoModeSwitch) + if config.OnAutoModeSwitchRequest != nil { + session.registerAutoModeSwitchHandler(config.OnAutoModeSwitchRequest) } c.sessionsMux.Lock() @@ -738,13 +713,13 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses c.sessionsMux.Unlock() if c.options.SessionFs != nil { - if config.CreateSessionFsHandler == nil { + if config.CreateSessionFsProvider == nil { c.sessionsMux.Lock() delete(c.sessions, sessionID) c.sessionsMux.Unlock() - return nil, fmt.Errorf("CreateSessionFsHandler is required in session config when SessionFs is enabled in client options") + return nil, fmt.Errorf("CreateSessionFsProvider is required in session config when SessionFs is enabled in client options") } - provider := config.CreateSessionFsHandler(session) + provider := config.CreateSessionFsProvider(session) if c.options.SessionFs.Capabilities != nil && c.options.SessionFs.Capabilities.Sqlite { if _, ok := provider.(SessionFsSqliteProvider); !ok { c.sessionsMux.Lock() @@ -823,8 +798,8 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.ModelCapabilities = config.ModelCapabilities req.AvailableTools = config.AvailableTools req.ExcludedTools = config.ExcludedTools - if config.Streaming { - req.Streaming = Bool(true) + if config.Streaming != nil { + req.Streaming = config.Streaming } if config.IncludeSubAgentStreamingEvents != nil { req.IncludeSubAgentStreamingEvents = config.IncludeSubAgentStreamingEvents @@ -847,7 +822,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, if config.EnableConfigDiscovery { req.EnableConfigDiscovery = Bool(true) } - if config.DisableResume { + if config.SuppressResumeEvent { req.DisableResume = Bool(true) } if config.ContinuePendingWork { @@ -876,10 +851,10 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, if config.OnElicitationRequest != nil { req.RequestElicitation = Bool(true) } - if config.OnExitPlanMode != nil { + if config.OnExitPlanModeRequest != nil { req.RequestExitPlanMode = Bool(true) } - if config.OnAutoModeSwitch != nil { + if config.OnAutoModeSwitchRequest != nil { req.RequestAutoModeSwitch = Bool(true) } @@ -911,11 +886,11 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, if config.OnElicitationRequest != nil { session.registerElicitationHandler(config.OnElicitationRequest) } - if config.OnExitPlanMode != nil { - session.registerExitPlanModeHandler(config.OnExitPlanMode) + if config.OnExitPlanModeRequest != nil { + session.registerExitPlanModeHandler(config.OnExitPlanModeRequest) } - if config.OnAutoModeSwitch != nil { - session.registerAutoModeSwitchHandler(config.OnAutoModeSwitch) + if config.OnAutoModeSwitchRequest != nil { + session.registerAutoModeSwitchHandler(config.OnAutoModeSwitchRequest) } c.sessionsMux.Lock() @@ -923,13 +898,13 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, c.sessionsMux.Unlock() if c.options.SessionFs != nil { - if config.CreateSessionFsHandler == nil { + if config.CreateSessionFsProvider == nil { c.sessionsMux.Lock() delete(c.sessions, sessionID) c.sessionsMux.Unlock() - return nil, fmt.Errorf("CreateSessionFsHandler is required in session config when SessionFs is enabled in client options") + return nil, fmt.Errorf("CreateSessionFsProvider is required in session config when SessionFs is enabled in client options") } - provider := config.CreateSessionFsHandler(session) + provider := config.CreateSessionFsProvider(session) if c.options.SessionFs.Capabilities != nil && c.options.SessionFs.Capabilities.Sqlite { if _, ok := provider.(SessionFsSqliteProvider); !ok { c.sessionsMux.Lock() @@ -1278,26 +1253,9 @@ func (c *Client) handleLifecycleEvent(event SessionLifecycleEvent) { } } -// State returns the current connection state of the client. -// -// Possible states: StateDisconnected, StateConnecting, StateConnected, StateError. -// -// Example: -// -// if client.State() == copilot.StateConnected { -// session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{ -// OnPermissionRequest: copilot.PermissionHandler.ApproveAll, -// }) -// } -func (c *Client) State() ConnectionState { - c.startStopMux.RLock() - defer c.startStopMux.RUnlock() - return c.state -} - -// ActualPort returns the TCP port the CLI server is listening on. +// RuntimePort returns the TCP port the runtime is listening on. // Returns 0 if the client is not connected or using stdio transport. -func (c *Client) ActualPort() int { +func (c *Client) RuntimePort() int { return c.actualPort } @@ -1478,7 +1436,7 @@ const stderrBufferSize = 64 * 1024 // This spawns the CLI server as a subprocess using the configured transport // mode (stdio or TCP). func (c *Client) startCLIServer(ctx context.Context) error { - cliPath := c.options.CLIPath + cliPath := c.cliPath if cliPath == "" { // If no CLI path is provided, attempt to use the embedded CLI if available cliPath = embeddedcli.Path() @@ -1489,14 +1447,19 @@ func (c *Client) startCLIServer(ctx context.Context) error { } // Start with user-provided CLIArgs, then add SDK-managed args - args := append([]string{}, c.options.CLIArgs...) - args = append(args, "--headless", "--no-auto-update", "--log-level", c.options.LogLevel) + args := append([]string{}, c.cliArgs...) + args = append(args, "--headless", "--no-auto-update") + // Only pass --log-level when explicitly configured; otherwise let the + // runtime use its own default. + if c.options.LogLevel != "" { + args = append(args, "--log-level", c.options.LogLevel) + } // Choose transport mode if c.useStdio { args = append(args, "--stdio") - } else if c.options.Port > 0 { - args = append(args, "--port", strconv.Itoa(c.options.Port)) + } else if c.port > 0 { + args = append(args, "--port", strconv.Itoa(c.port)) } // Add auth-related flags @@ -1518,7 +1481,7 @@ func (c *Client) startCLIServer(ctx context.Context) error { args = append(args, "--session-idle-timeout", strconv.Itoa(c.options.SessionIdleTimeoutSeconds)) } - if c.options.Remote { + if c.options.EnableRemoteSessions { args = append(args, "--remote") } @@ -1549,8 +1512,8 @@ func (c *Client) startCLIServer(ctx context.Context) error { c.process.Env = setEnvValue(c.process.Env, "COPILOT_CONNECTION_TOKEN", c.effectiveConnectionToken) } - if c.options.CopilotHome != "" { - c.process.Env = setEnvValue(c.process.Env, "COPILOT_HOME", c.options.CopilotHome) + if c.options.BaseDirectory != "" { + c.process.Env = setEnvValue(c.process.Env, "COPILOT_HOME", c.options.BaseDirectory) } if c.options.Telemetry != nil { @@ -1606,7 +1569,7 @@ func (c *Client) startCLIServer(ctx context.Context) error { go func() { c.startStopMux.Lock() defer c.startStopMux.Unlock() - c.state = StateDisconnected + c.state = stateDisconnected }() }) c.RPC = rpc.NewServerRpc(c.client) @@ -1759,7 +1722,7 @@ func (c *Client) connectViaTcp(ctx context.Context) error { go func() { c.startStopMux.Lock() defer c.startStopMux.Unlock() - c.state = StateDisconnected + c.state = stateDisconnected }() }) c.RPC = rpc.NewServerRpc(c.client) diff --git a/go/client_test.go b/go/client_test.go index 9dd0080cc..f249a8fa6 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -22,9 +22,8 @@ import ( func TestClient_URLParsing(t *testing.T) { t.Run("should parse port-only URL format", func(t *testing.T) { client := NewClient(&ClientOptions{ - CLIUrl: "8080", + Connection: UriConnection{URL: "8080"}, }) - if client.actualPort != 8080 { t.Errorf("Expected port 8080, got %d", client.actualPort) } @@ -38,193 +37,99 @@ func TestClient_URLParsing(t *testing.T) { t.Run("should parse host:port URL format", func(t *testing.T) { client := NewClient(&ClientOptions{ - CLIUrl: "127.0.0.1:9000", + Connection: UriConnection{URL: "127.0.0.1:9000"}, }) - - if client.actualPort != 9000 { - t.Errorf("Expected port 9000, got %d", client.actualPort) - } - if client.actualHost != "127.0.0.1" { - t.Errorf("Expected host 127.0.0.1, got %s", client.actualHost) - } - if !client.isExternalServer { - t.Error("Expected isExternalServer to be true") + if client.actualPort != 9000 || client.actualHost != "127.0.0.1" { + t.Errorf("Expected 127.0.0.1:9000, got %s:%d", client.actualHost, client.actualPort) } }) t.Run("should parse http://host:port URL format", func(t *testing.T) { client := NewClient(&ClientOptions{ - CLIUrl: "http://localhost:7000", + Connection: UriConnection{URL: "http://localhost:7000"}, }) - - if client.actualPort != 7000 { - t.Errorf("Expected port 7000, got %d", client.actualPort) - } - if client.actualHost != "localhost" { - t.Errorf("Expected host localhost, got %s", client.actualHost) - } - if !client.isExternalServer { - t.Error("Expected isExternalServer to be true") + if client.actualPort != 7000 || client.actualHost != "localhost" { + t.Errorf("Expected localhost:7000, got %s:%d", client.actualHost, client.actualPort) } }) t.Run("should parse https://host:port URL format", func(t *testing.T) { client := NewClient(&ClientOptions{ - CLIUrl: "https://example.com:443", + Connection: UriConnection{URL: "https://example.com:443"}, }) - - if client.actualPort != 443 { - t.Errorf("Expected port 443, got %d", client.actualPort) - } - if client.actualHost != "example.com" { - t.Errorf("Expected host example.com, got %s", client.actualHost) - } - if !client.isExternalServer { - t.Error("Expected isExternalServer to be true") + if client.actualPort != 443 || client.actualHost != "example.com" { + t.Errorf("Expected example.com:443, got %s:%d", client.actualHost, client.actualPort) } }) - t.Run("should throw error for invalid URL format", func(t *testing.T) { + t.Run("should panic for invalid URL format", func(t *testing.T) { defer func() { if r := recover(); r == nil { t.Error("Expected panic for invalid URL format") - } else { - matched, _ := regexp.MatchString("Invalid port in CLIUrl", r.(string)) - if !matched { - t.Errorf("Expected panic message to contain 'Invalid port in CLIUrl', got: %v", r) - } } }() - - NewClient(&ClientOptions{ - CLIUrl: "invalid-url", - }) + NewClient(&ClientOptions{Connection: UriConnection{URL: "invalid-url"}}) }) - t.Run("should throw error for invalid port - too high", func(t *testing.T) { + t.Run("should panic for invalid port - too high", func(t *testing.T) { defer func() { if r := recover(); r == nil { - t.Error("Expected panic for invalid port") - } else { - matched, _ := regexp.MatchString("Invalid port in CLIUrl", r.(string)) - if !matched { - t.Errorf("Expected panic message to contain 'Invalid port in CLIUrl', got: %v", r) - } + t.Error("Expected panic") } }() - - NewClient(&ClientOptions{ - CLIUrl: "localhost:99999", - }) + NewClient(&ClientOptions{Connection: UriConnection{URL: "localhost:99999"}}) }) - t.Run("should throw error for invalid port - zero", func(t *testing.T) { + t.Run("should panic for invalid port - zero", func(t *testing.T) { defer func() { if r := recover(); r == nil { - t.Error("Expected panic for invalid port") - } else { - matched, _ := regexp.MatchString("Invalid port in CLIUrl", r.(string)) - if !matched { - t.Errorf("Expected panic message to contain 'Invalid port in CLIUrl', got: %v", r) - } + t.Error("Expected panic") } }() - - NewClient(&ClientOptions{ - CLIUrl: "localhost:0", - }) + NewClient(&ClientOptions{Connection: UriConnection{URL: "localhost:0"}}) }) - t.Run("should throw error for invalid port - negative", func(t *testing.T) { + t.Run("should panic for invalid port - negative", func(t *testing.T) { defer func() { if r := recover(); r == nil { - t.Error("Expected panic for invalid port") - } else { - matched, _ := regexp.MatchString("Invalid port in CLIUrl", r.(string)) - if !matched { - t.Errorf("Expected panic message to contain 'Invalid port in CLIUrl', got: %v", r) - } + t.Error("Expected panic") } }() - - NewClient(&ClientOptions{ - CLIUrl: "localhost:-1", - }) - }) - - t.Run("should throw error when CLIUrl is used with UseStdio", func(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Error("Expected panic for mutually exclusive options") - } else { - matched, _ := regexp.MatchString("CLIUrl is mutually exclusive", r.(string)) - if !matched { - t.Errorf("Expected panic message to contain 'CLIUrl is mutually exclusive', got: %v", r) - } - } - }() - - NewClient(&ClientOptions{ - CLIUrl: "localhost:8080", - UseStdio: Bool(true), - }) + NewClient(&ClientOptions{Connection: UriConnection{URL: "localhost:-1"}}) }) - t.Run("should throw error when CLIUrl is used with CLIPath", func(t *testing.T) { + t.Run("should panic when UriConnection has empty URL", func(t *testing.T) { defer func() { if r := recover(); r == nil { - t.Error("Expected panic for mutually exclusive options") - } else { - matched, _ := regexp.MatchString("CLIUrl is mutually exclusive", r.(string)) - if !matched { - t.Errorf("Expected panic message to contain 'CLIUrl is mutually exclusive', got: %v", r) - } + t.Error("Expected panic for empty URL") } }() - - NewClient(&ClientOptions{ - CLIUrl: "localhost:8080", - CLIPath: "/path/to/cli", - }) + NewClient(&ClientOptions{Connection: UriConnection{}}) }) - t.Run("should set UseStdio to false when CLIUrl is provided", func(t *testing.T) { - client := NewClient(&ClientOptions{ - CLIUrl: "8080", - }) - - if client.useStdio { - t.Error("Expected UseStdio to be false when CLIUrl is provided") - } - }) - - t.Run("should set UseStdio to true when UseStdio is set to true", func(t *testing.T) { - client := NewClient(&ClientOptions{ - UseStdio: Bool(true), - }) - + t.Run("stdio connection uses stdio transport", func(t *testing.T) { + client := NewClient(&ClientOptions{Connection: StdioConnection{}}) if !client.useStdio { - t.Error("Expected UseStdio to be true when UseStdio is set to true") + t.Error("Expected useStdio=true for StdioConnection") } }) - t.Run("should set UseStdio to false when UseStdio is set to false", func(t *testing.T) { - client := NewClient(&ClientOptions{ - UseStdio: Bool(false), - }) - + t.Run("tcp connection uses tcp transport", func(t *testing.T) { + client := NewClient(&ClientOptions{Connection: TcpConnection{Port: 8080}}) if client.useStdio { - t.Error("Expected UseStdio to be false when UseStdio is set to false") + t.Error("Expected useStdio=false for TcpConnection") + } + if client.port != 8080 { + t.Errorf("Expected port=8080, got %d", client.port) } }) - t.Run("should mark client as using external server", func(t *testing.T) { + t.Run("uri connection is treated as external server", func(t *testing.T) { client := NewClient(&ClientOptions{ - CLIUrl: "localhost:8080", + Connection: UriConnection{URL: "localhost:8080"}, }) - if !client.isExternalServer { - t.Error("Expected isExternalServer to be true when CLIUrl is provided") + t.Error("Expected isExternalServer=true for UriConnection") } }) } @@ -311,12 +216,12 @@ func TestClient_AuthOptions(t *testing.T) { } }) - t.Run("should throw error when GitHubToken is used with CLIUrl", func(t *testing.T) { + t.Run("should panic when GitHubToken is used with UriConnection", func(t *testing.T) { defer func() { if r := recover(); r == nil { - t.Error("Expected panic for auth options with CLIUrl") + t.Error("Expected panic for auth options with UriConnection") } else { - matched, _ := regexp.MatchString("GitHubToken and UseLoggedInUser cannot be used with CLIUrl", r.(string)) + matched, _ := regexp.MatchString("GitHubToken and UseLoggedInUser cannot be used with UriConnection", r.(string)) if !matched { t.Errorf("Expected panic message about auth options, got: %v", r) } @@ -324,46 +229,41 @@ func TestClient_AuthOptions(t *testing.T) { }() NewClient(&ClientOptions{ - CLIUrl: "localhost:8080", + Connection: UriConnection{URL: "localhost:8080"}, GitHubToken: "gho_test_token", }) }) - t.Run("should throw error when UseLoggedInUser is used with CLIUrl", func(t *testing.T) { + t.Run("should panic when UseLoggedInUser is used with UriConnection", func(t *testing.T) { defer func() { if r := recover(); r == nil { - t.Error("Expected panic for auth options with CLIUrl") - } else { - matched, _ := regexp.MatchString("GitHubToken and UseLoggedInUser cannot be used with CLIUrl", r.(string)) - if !matched { - t.Errorf("Expected panic message about auth options, got: %v", r) - } + t.Error("Expected panic for auth options with UriConnection") } }() NewClient(&ClientOptions{ - CLIUrl: "localhost:8080", + Connection: UriConnection{URL: "localhost:8080"}, UseLoggedInUser: Bool(false), }) }) } -func TestClient_CopilotHome(t *testing.T) { - t.Run("should accept CopilotHome option", func(t *testing.T) { +func TestClient_BaseDirectory(t *testing.T) { + t.Run("should accept BaseDirectory option", func(t *testing.T) { client := NewClient(&ClientOptions{ - CopilotHome: "/custom/copilot/home", + BaseDirectory: "/custom/copilot/home", }) - if client.options.CopilotHome != "/custom/copilot/home" { - t.Errorf("Expected CopilotHome to be '/custom/copilot/home', got %q", client.options.CopilotHome) + if client.options.BaseDirectory != "/custom/copilot/home" { + t.Errorf("Expected BaseDirectory to be '/custom/copilot/home', got %q", client.options.BaseDirectory) } }) - t.Run("should default CopilotHome to empty string", func(t *testing.T) { + t.Run("should default BaseDirectory to empty string", func(t *testing.T) { client := NewClient(&ClientOptions{}) - if client.options.CopilotHome != "" { - t.Errorf("Expected CopilotHome to be empty, got %q", client.options.CopilotHome) + if client.options.BaseDirectory != "" { + t.Errorf("Expected BaseDirectory to be empty, got %q", client.options.BaseDirectory) } }) } @@ -658,7 +558,7 @@ func TestOverridesBuiltInTool(t *testing.T) { func TestClient_CreateSession_AllowsMissingPermissionHandler(t *testing.T) { t.Run("accepts nil config before connection validation", func(t *testing.T) { - client := NewClient(&ClientOptions{AutoStart: Bool(false)}) + client := NewClient(&ClientOptions{Connection: StdioConnection{Path: "/__nonexistent_copilot_binary__"}}) _, err := client.CreateSession(t.Context(), nil) if err == nil { t.Fatal("Expected error when client is not connected") @@ -669,7 +569,7 @@ func TestClient_CreateSession_AllowsMissingPermissionHandler(t *testing.T) { }) t.Run("accepts missing OnPermissionRequest before connection validation", func(t *testing.T) { - client := NewClient(&ClientOptions{AutoStart: Bool(false)}) + client := NewClient(&ClientOptions{Connection: StdioConnection{Path: "/__nonexistent_copilot_binary__"}}) _, err := client.CreateSession(t.Context(), &SessionConfig{}) if err == nil { t.Fatal("Expected error when client is not connected") @@ -682,7 +582,7 @@ func TestClient_CreateSession_AllowsMissingPermissionHandler(t *testing.T) { func TestClient_ResumeSession_AllowsMissingPermissionHandler(t *testing.T) { t.Run("accepts nil config before connection validation", func(t *testing.T) { - client := NewClient(&ClientOptions{AutoStart: Bool(false)}) + client := NewClient(&ClientOptions{Connection: StdioConnection{Path: "/__nonexistent_copilot_binary__"}}) _, err := client.ResumeSessionWithOptions(t.Context(), "some-id", nil) if err == nil { t.Fatal("Expected error when client is not connected") @@ -758,7 +658,7 @@ func TestClient_StartContextCancellationDoesNotKillProcess(t *testing.T) { t.Skip("CLI not found") } - client := NewClient(&ClientOptions{CLIPath: cliPath}) + client := NewClient(&ClientOptions{Connection: StdioConnection{Path: cliPath}}) t.Cleanup(func() { client.ForceStop() }) // Start with a context, then cancel it after the client is connected. @@ -783,7 +683,7 @@ func TestClient_StartStopRace(t *testing.T) { if cliPath == "" { t.Skip("CLI not found") } - client := NewClient(&ClientOptions{CLIPath: cliPath}) + client := NewClient(&ClientOptions{Connection: StdioConnection{Path: cliPath}}) defer client.ForceStop() errChan := make(chan error) wg := sync.WaitGroup{} diff --git a/go/internal/e2e/abort_e2e_test.go b/go/internal/e2e/abort_e2e_test.go index d71af962e..095345688 100644 --- a/go/internal/e2e/abort_e2e_test.go +++ b/go/internal/e2e/abort_e2e_test.go @@ -22,7 +22,7 @@ func TestAbortE2E(t *testing.T) { session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ OnPermissionRequest: copilot.PermissionHandler.ApproveAll, - Streaming: true, + Streaming: copilot.Bool(true), }) if err != nil { t.Fatalf("Failed to create session: %v", err) diff --git a/go/internal/e2e/agent_and_compact_rpc_e2e_test.go b/go/internal/e2e/agent_and_compact_rpc_e2e_test.go index a0d563c8b..cfb879917 100644 --- a/go/internal/e2e/agent_and_compact_rpc_e2e_test.go +++ b/go/internal/e2e/agent_and_compact_rpc_e2e_test.go @@ -19,8 +19,7 @@ func TestAgentSelectionRpcE2E(t *testing.T) { t.Run("should list available custom agents", func(t *testing.T) { client := copilot.NewClient(&copilot.ClientOptions{ - CLIPath: cliPath, - UseStdio: copilot.Bool(true), + Connection: copilot.StdioConnection{Path: cliPath}, }) t.Cleanup(func() { client.ForceStop() }) @@ -74,8 +73,7 @@ func TestAgentSelectionRpcE2E(t *testing.T) { t.Run("should return null when no agent is selected", func(t *testing.T) { client := copilot.NewClient(&copilot.ClientOptions{ - CLIPath: cliPath, - UseStdio: copilot.Bool(true), + Connection: copilot.StdioConnection{Path: cliPath}, }) t.Cleanup(func() { client.ForceStop() }) @@ -114,8 +112,7 @@ func TestAgentSelectionRpcE2E(t *testing.T) { t.Run("should select and get current agent", func(t *testing.T) { client := copilot.NewClient(&copilot.ClientOptions{ - CLIPath: cliPath, - UseStdio: copilot.Bool(true), + Connection: copilot.StdioConnection{Path: cliPath}, }) t.Cleanup(func() { client.ForceStop() }) @@ -169,8 +166,7 @@ func TestAgentSelectionRpcE2E(t *testing.T) { t.Run("should deselect current agent", func(t *testing.T) { client := copilot.NewClient(&copilot.ClientOptions{ - CLIPath: cliPath, - UseStdio: copilot.Bool(true), + Connection: copilot.StdioConnection{Path: cliPath}, }) t.Cleanup(func() { client.ForceStop() }) @@ -220,8 +216,7 @@ func TestAgentSelectionRpcE2E(t *testing.T) { t.Run("should return no custom agents when none configured", func(t *testing.T) { client := copilot.NewClient(&copilot.ClientOptions{ - CLIPath: cliPath, - UseStdio: copilot.Bool(true), + Connection: copilot.StdioConnection{Path: cliPath}, }) t.Cleanup(func() { client.ForceStop() }) @@ -264,8 +259,7 @@ func TestAgentSelectionRpcE2E(t *testing.T) { } client := copilot.NewClient(&copilot.ClientOptions{ - CLIPath: cliPath, - UseStdio: copilot.Bool(true), + Connection: copilot.StdioConnection{Path: cliPath}, }) t.Cleanup(func() { client.ForceStop() }) diff --git a/go/internal/e2e/client_e2e_test.go b/go/internal/e2e/client_e2e_test.go index 9fda3cd83..e4dfed2d4 100644 --- a/go/internal/e2e/client_e2e_test.go +++ b/go/internal/e2e/client_e2e_test.go @@ -16,8 +16,7 @@ func TestClientE2E(t *testing.T) { t.Run("should start and connect to server using stdio", func(t *testing.T) { client := copilot.NewClient(&copilot.ClientOptions{ - CLIPath: cliPath, - UseStdio: copilot.Bool(true), + Connection: copilot.StdioConnection{Path: cliPath}, }) t.Cleanup(func() { client.ForceStop() }) @@ -25,10 +24,6 @@ func TestClientE2E(t *testing.T) { t.Fatalf("Failed to start client: %v", err) } - if client.State() != copilot.StateConnected { - t.Errorf("Expected state to be 'connected', got %q", client.State()) - } - pong, err := client.Ping(t.Context(), "test message") if err != nil { t.Fatalf("Failed to ping: %v", err) @@ -45,16 +40,11 @@ func TestClientE2E(t *testing.T) { if err := client.Stop(); err != nil { t.Errorf("Expected no errors on stop, got %v", err) } - - if client.State() != copilot.StateDisconnected { - t.Errorf("Expected state to be 'disconnected', got %q", client.State()) - } }) t.Run("should start and connect to server using tcp", func(t *testing.T) { client := copilot.NewClient(&copilot.ClientOptions{ - CLIPath: cliPath, - UseStdio: copilot.Bool(false), + Connection: copilot.TcpConnection{Path: cliPath}, }) t.Cleanup(func() { client.ForceStop() }) @@ -62,10 +52,6 @@ func TestClientE2E(t *testing.T) { t.Fatalf("Failed to start client: %v", err) } - if client.State() != copilot.StateConnected { - t.Errorf("Expected state to be 'connected', got %q", client.State()) - } - pong, err := client.Ping(t.Context(), "test message") if err != nil { t.Fatalf("Failed to ping: %v", err) @@ -82,15 +68,11 @@ func TestClientE2E(t *testing.T) { if err := client.Stop(); err != nil { t.Errorf("Expected no errors on stop, got %v", err) } - - if client.State() != copilot.StateDisconnected { - t.Errorf("Expected state to be 'disconnected', got %q", client.State()) - } }) t.Run("should return errors on failed cleanup", func(t *testing.T) { client := copilot.NewClient(&copilot.ClientOptions{ - CLIPath: cliPath, + Connection: copilot.StdioConnection{Path: cliPath}, }) t.Cleanup(func() { client.ForceStop() }) @@ -108,15 +90,11 @@ func TestClientE2E(t *testing.T) { if err := client.Stop(); err != nil { t.Logf("Got expected errors: %v", err) } - - if client.State() != copilot.StateDisconnected { - t.Errorf("Expected state to be 'disconnected', got %q", client.State()) - } }) t.Run("should forceStop without cleanup", func(t *testing.T) { client := copilot.NewClient(&copilot.ClientOptions{ - CLIPath: cliPath, + Connection: copilot.StdioConnection{Path: cliPath}, }) t.Cleanup(func() { client.ForceStop() }) @@ -128,16 +106,11 @@ func TestClientE2E(t *testing.T) { } client.ForceStop() - - if client.State() != copilot.StateDisconnected { - t.Errorf("Expected state to be 'disconnected', got %q", client.State()) - } }) t.Run("should get status with version and protocol info", func(t *testing.T) { client := copilot.NewClient(&copilot.ClientOptions{ - CLIPath: cliPath, - UseStdio: copilot.Bool(true), + Connection: copilot.StdioConnection{Path: cliPath}, }) t.Cleanup(func() { client.ForceStop() }) @@ -163,8 +136,7 @@ func TestClientE2E(t *testing.T) { t.Run("should get auth status", func(t *testing.T) { client := copilot.NewClient(&copilot.ClientOptions{ - CLIPath: cliPath, - UseStdio: copilot.Bool(true), + Connection: copilot.StdioConnection{Path: cliPath}, }) t.Cleanup(func() { client.ForceStop() }) @@ -192,8 +164,7 @@ func TestClientE2E(t *testing.T) { t.Run("should list models when authenticated", func(t *testing.T) { client := copilot.NewClient(&copilot.ClientOptions{ - CLIPath: cliPath, - UseStdio: copilot.Bool(true), + Connection: copilot.StdioConnection{Path: cliPath}, }) t.Cleanup(func() { client.ForceStop() }) @@ -232,9 +203,10 @@ func TestClientE2E(t *testing.T) { t.Run("should report error when CLI fails to start", func(t *testing.T) { client := copilot.NewClient(&copilot.ClientOptions{ - CLIPath: cliPath, - CLIArgs: []string{"--nonexistent-flag-for-testing"}, - UseStdio: copilot.Bool(true), + Connection: copilot.StdioConnection{ + Path: cliPath, + Args: []string{"--nonexistent-flag-for-testing"}, + }, }) t.Cleanup(func() { client.ForceStop() }) diff --git a/go/internal/e2e/client_lifecycle_e2e_test.go b/go/internal/e2e/client_lifecycle_e2e_test.go index 4fde70081..dca15c615 100644 --- a/go/internal/e2e/client_lifecycle_e2e_test.go +++ b/go/internal/e2e/client_lifecycle_e2e_test.go @@ -129,16 +129,10 @@ func TestClientLifecycleE2E(t *testing.T) { if err := client.Start(t.Context()); err != nil { t.Fatalf("Failed to start client: %v", err) } - if client.State() != copilot.StateConnected { - t.Errorf("Expected state to be connected after Start, got %q", client.State()) - } if err := client.Stop(); err != nil { t.Fatalf("Failed to stop client: %v", err) } - if client.State() != copilot.StateDisconnected { - t.Errorf("Expected state to be disconnected after Stop, got %q", client.State()) - } }) t.Run("force stop disconnects client", func(t *testing.T) { @@ -148,13 +142,7 @@ func TestClientLifecycleE2E(t *testing.T) { if err := client.Start(t.Context()); err != nil { t.Fatalf("Failed to start client: %v", err) } - if client.State() != copilot.StateConnected { - t.Errorf("Expected state to be connected after Start, got %q", client.State()) - } client.ForceStop() - if client.State() != copilot.StateDisconnected { - t.Errorf("Expected state to be disconnected after ForceStop, got %q", client.State()) - } }) } diff --git a/go/internal/e2e/client_options_e2e_test.go b/go/internal/e2e/client_options_e2e_test.go index 3ffdf7693..d8d6399ee 100644 --- a/go/internal/e2e/client_options_e2e_test.go +++ b/go/internal/e2e/client_options_e2e_test.go @@ -17,60 +17,20 @@ import ( // Go's ClientOptions is a plain struct with no setter validation; equivalent behavior is covered // in package-level unit tests. func TestClientOptionsE2E(t *testing.T) { - t.Run("autostart false requires explicit start", func(t *testing.T) { - ctx := testharness.NewTestContext(t) - client := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.AutoStart = copilot.Bool(false) - }) - t.Cleanup(func() { client.ForceStop() }) - - if got := client.State(); got != copilot.StateDisconnected { - t.Errorf("Expected initial state Disconnected, got %v", got) - } - - if _, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - OnPermissionRequest: copilot.PermissionHandler.ApproveAll, - }); err == nil { - t.Fatal("Expected CreateSession to fail when AutoStart=false and Start was not called") - } - - if err := client.Start(t.Context()); err != nil { - t.Fatalf("Start failed: %v", err) - } - if got := client.State(); got != copilot.StateConnected { - t.Errorf("Expected state Connected after Start, got %v", got) - } - - session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - OnPermissionRequest: copilot.PermissionHandler.ApproveAll, - }) - if err != nil { - t.Fatalf("CreateSession failed after Start: %v", err) - } - if session.SessionID == "" { - t.Error("Expected non-empty session id") - } - session.Disconnect() - }) - t.Run("should listen on configured tcp port", func(t *testing.T) { ctx := testharness.NewTestContext(t) port := getAvailableTcpPort(t) client := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.UseStdio = copilot.Bool(false) - opts.Port = port + opts.Connection = copilot.TcpConnection{Path: ctx.CLIPath, Port: port} }) t.Cleanup(func() { client.ForceStop() }) if err := client.Start(t.Context()); err != nil { t.Fatalf("Start failed: %v", err) } - if got := client.State(); got != copilot.StateConnected { - t.Errorf("Expected state Connected, got %v", got) - } - if got := client.ActualPort(); got != port { - t.Errorf("Expected ActualPort=%d, got %d", port, got) + if got := client.RuntimePort(); got != port { + t.Errorf("Expected RuntimePort=%d, got %d", port, got) } // Ping over the connection to confirm it is usable. @@ -138,10 +98,11 @@ func TestClientOptionsE2E(t *testing.T) { } client := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.AutoStart = copilot.Bool(false) - opts.CLIPath = cliPath - opts.CLIArgs = []string{"--capture-file", capturePath} - opts.CopilotHome = filepath.Join(ctx.WorkDir, "copilot-home-from-option") + opts.Connection = copilot.StdioConnection{ + Path: cliPath, + Args: []string{"--capture-file", capturePath}, + } + opts.BaseDirectory = filepath.Join(ctx.WorkDir, "copilot-home-from-option") opts.Env = append([]string{}, opts.Env...) opts.Env = append(opts.Env, "COPILOT_HOME="+filepath.Join(ctx.WorkDir, "copilot-home-from-env")) opts.GitHubToken = "process-option-token" @@ -276,23 +237,19 @@ func TestClientOptionsUnit(t *testing.T) { } }) - t.Run("should panic when GitHubToken used with CliUrl", func(t *testing.T) { - // Mirrors: Should_Throw_When_GitHubToken_Used_With_CliUrl - // Go's NewClient validates mutually exclusive auth + CLIUrl combinations - // with panic() instead of an exception. + t.Run("should panic when GitHubToken used with UriConnection", func(t *testing.T) { assertPanics(t, func() { _ = copilot.NewClient(&copilot.ClientOptions{ - CLIUrl: "localhost:8080", + Connection: copilot.UriConnection{URL: "localhost:8080"}, GitHubToken: "gho_test_token", }) }) }) - t.Run("should panic when UseLoggedInUser used with CliUrl", func(t *testing.T) { - // Mirrors: Should_Throw_When_UseLoggedInUser_Used_With_CliUrl + t.Run("should panic when UseLoggedInUser used with UriConnection", func(t *testing.T) { assertPanics(t, func() { _ = copilot.NewClient(&copilot.ClientOptions{ - CLIUrl: "localhost:8080", + Connection: copilot.UriConnection{URL: "localhost:8080"}, UseLoggedInUser: copilot.Bool(false), }) }) diff --git a/go/internal/e2e/commands_and_elicitation_e2e_test.go b/go/internal/e2e/commands_and_elicitation_e2e_test.go index 501e13813..c38201204 100644 --- a/go/internal/e2e/commands_and_elicitation_e2e_test.go +++ b/go/internal/e2e/commands_and_elicitation_e2e_test.go @@ -14,8 +14,7 @@ import ( func TestCommandsE2E(t *testing.T) { ctx := testharness.NewTestContext(t) client1 := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.UseStdio = copilot.Bool(false) - opts.TCPConnectionToken = sharedTcpToken + opts.Connection = copilot.TcpConnection{Path: opts.Connection.(copilot.StdioConnection).Path, ConnectionToken: sharedTcpToken} }) t.Cleanup(func() { client1.ForceStop() }) @@ -28,14 +27,13 @@ func TestCommandsE2E(t *testing.T) { } initSession.Disconnect() - actualPort := client1.ActualPort() - if actualPort == 0 { + runtimePort := client1.RuntimePort() + if runtimePort == 0 { t.Fatalf("Expected non-zero port from TCP mode client") } client2 := copilot.NewClient(&copilot.ClientOptions{ - CLIUrl: fmt.Sprintf("localhost:%d", actualPort), - TCPConnectionToken: sharedTcpToken, + Connection: copilot.UriConnection{URL: fmt.Sprintf("localhost:%d", runtimePort), ConnectionToken: sharedTcpToken}, }) t.Cleanup(func() { client2.ForceStop() }) @@ -65,7 +63,7 @@ func TestCommandsE2E(t *testing.T) { // Client2 joins with commands session2, err := client2.ResumeSession(t.Context(), session1.SessionID, &copilot.ResumeSessionConfig{ OnPermissionRequest: copilot.PermissionHandler.ApproveAll, - DisableResume: true, + SuppressResumeEvent: true, Commands: []copilot.CommandDefinition{ { Name: "deploy", @@ -367,7 +365,7 @@ func TestUIElicitationCallbackE2E(t *testing.T) { minLen := 1 maxLen := 20 - value, ok, err := session.UI().Input(t.Context(), "Enter value", &copilot.InputOptions{ + value, ok, err := session.UI().Input(t.Context(), "Enter value", &copilot.UiInputOptions{ Title: "Value", Description: "A value to test", MinLength: &minLen, @@ -510,8 +508,7 @@ func schemaHasProperty(schema map[string]any, name string) bool { func TestUIElicitationMultiClientE2E(t *testing.T) { ctx := testharness.NewTestContext(t) client1 := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.UseStdio = copilot.Bool(false) - opts.TCPConnectionToken = sharedTcpToken + opts.Connection = copilot.TcpConnection{Path: opts.Connection.(copilot.StdioConnection).Path, ConnectionToken: sharedTcpToken} }) t.Cleanup(func() { client1.ForceStop() }) @@ -524,8 +521,8 @@ func TestUIElicitationMultiClientE2E(t *testing.T) { } initSession.Disconnect() - actualPort := client1.ActualPort() - if actualPort == 0 { + runtimePort := client1.RuntimePort() + if runtimePort == 0 { t.Fatalf("Expected non-zero port from TCP mode client") } @@ -559,12 +556,11 @@ func TestUIElicitationMultiClientE2E(t *testing.T) { // Client2 joins with elicitation handler — should trigger capabilities.changed client2 := copilot.NewClient(&copilot.ClientOptions{ - CLIUrl: fmt.Sprintf("localhost:%d", actualPort), - TCPConnectionToken: sharedTcpToken, + Connection: copilot.UriConnection{URL: fmt.Sprintf("localhost:%d", runtimePort), ConnectionToken: sharedTcpToken}, }) session2, err := client2.ResumeSession(t.Context(), session1.SessionID, &copilot.ResumeSessionConfig{ OnPermissionRequest: copilot.PermissionHandler.ApproveAll, - DisableResume: true, + SuppressResumeEvent: true, OnElicitationRequest: func(ctx copilot.ElicitationContext) (copilot.ElicitationResult, error) { return copilot.ElicitationResult{Action: "accept", Content: map[string]any{}}, nil }, @@ -620,12 +616,11 @@ func TestUIElicitationMultiClientE2E(t *testing.T) { // Client3 (dedicated for this test) joins with elicitation handler client3 := copilot.NewClient(&copilot.ClientOptions{ - CLIUrl: fmt.Sprintf("localhost:%d", actualPort), - TCPConnectionToken: sharedTcpToken, + Connection: copilot.UriConnection{URL: fmt.Sprintf("localhost:%d", runtimePort), ConnectionToken: sharedTcpToken}, }) _, err = client3.ResumeSession(t.Context(), session1.SessionID, &copilot.ResumeSessionConfig{ OnPermissionRequest: copilot.PermissionHandler.ApproveAll, - DisableResume: true, + SuppressResumeEvent: true, OnElicitationRequest: func(ctx copilot.ElicitationContext) (copilot.ElicitationResult, error) { return copilot.ElicitationResult{Action: "accept", Content: map[string]any{}}, nil }, diff --git a/go/internal/e2e/connection_token_test.go b/go/internal/e2e/connection_token_test.go index 269c5ae5a..f68bb0bf8 100644 --- a/go/internal/e2e/connection_token_test.go +++ b/go/internal/e2e/connection_token_test.go @@ -13,8 +13,10 @@ func TestConnectionToken(t *testing.T) { t.Run("explicit token round-trips successfully", func(t *testing.T) { ctx := testharness.NewTestContext(t) client := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.UseStdio = copilot.Bool(false) - opts.TCPConnectionToken = "right-token" + opts.Connection = copilot.TcpConnection{ + Path: ctx.CLIPath, + ConnectionToken: "right-token", + } }) t.Cleanup(func() { client.ForceStop() }) @@ -34,7 +36,7 @@ func TestConnectionToken(t *testing.T) { t.Run("auto-generated token round-trips successfully", func(t *testing.T) { ctx := testharness.NewTestContext(t) client := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.UseStdio = copilot.Bool(false) + opts.Connection = copilot.TcpConnection{Path: ctx.CLIPath} }) t.Cleanup(func() { client.ForceStop() }) @@ -54,22 +56,26 @@ func TestConnectionToken(t *testing.T) { t.Run("sibling client with wrong token is rejected", func(t *testing.T) { ctx := testharness.NewTestContext(t) good := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.UseStdio = copilot.Bool(false) - opts.TCPConnectionToken = "right-token" + opts.Connection = copilot.TcpConnection{ + Path: ctx.CLIPath, + ConnectionToken: "right-token", + } }) t.Cleanup(func() { good.ForceStop() }) if err := good.Start(t.Context()); err != nil { t.Fatalf("good client Start failed: %v", err) } - port := good.ActualPort() + port := good.RuntimePort() if port == 0 { t.Fatalf("expected non-zero port from TCP mode client") } bad := copilot.NewClient(&copilot.ClientOptions{ - CLIUrl: fmt.Sprintf("localhost:%d", port), - TCPConnectionToken: "wrong", + Connection: copilot.UriConnection{ + URL: fmt.Sprintf("localhost:%d", port), + ConnectionToken: "wrong", + }, }) t.Cleanup(func() { bad.ForceStop() }) @@ -85,21 +91,23 @@ func TestConnectionToken(t *testing.T) { t.Run("sibling client with no token is rejected", func(t *testing.T) { ctx := testharness.NewTestContext(t) good := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.UseStdio = copilot.Bool(false) - opts.TCPConnectionToken = "right-token" + opts.Connection = copilot.TcpConnection{ + Path: ctx.CLIPath, + ConnectionToken: "right-token", + } }) t.Cleanup(func() { good.ForceStop() }) if err := good.Start(t.Context()); err != nil { t.Fatalf("good client Start failed: %v", err) } - port := good.ActualPort() + port := good.RuntimePort() if port == 0 { t.Fatalf("expected non-zero port from TCP mode client") } none := copilot.NewClient(&copilot.ClientOptions{ - CLIUrl: fmt.Sprintf("localhost:%d", port), + Connection: copilot.UriConnection{URL: fmt.Sprintf("localhost:%d", port)}, }) t.Cleanup(func() { none.ForceStop() }) diff --git a/go/internal/e2e/error_resilience_e2e_test.go b/go/internal/e2e/error_resilience_e2e_test.go index 2a0162f2c..056fc79ff 100644 --- a/go/internal/e2e/error_resilience_e2e_test.go +++ b/go/internal/e2e/error_resilience_e2e_test.go @@ -49,8 +49,8 @@ func TestErrorResilienceE2E(t *testing.T) { timeoutCtx, cancel := context.WithTimeout(t.Context(), 10*time.Second) defer cancel() - if _, err := session.GetMessages(timeoutCtx); err == nil { - t.Fatal("Expected GetMessages on disconnected session to fail") + if _, err := session.GetEvents(timeoutCtx); err == nil { + t.Fatal("Expected GetEvents on disconnected session to fail") } }) diff --git a/go/internal/e2e/event_fidelity_e2e_test.go b/go/internal/e2e/event_fidelity_e2e_test.go index f75fcffad..de1145b0a 100644 --- a/go/internal/e2e/event_fidelity_e2e_test.go +++ b/go/internal/e2e/event_fidelity_e2e_test.go @@ -190,9 +190,9 @@ func TestEventFidelityE2E(t *testing.T) { t.Fatalf("SendAndWait failed: %v", err) } - messages, err := session.GetMessages(t.Context()) + messages, err := session.GetEvents(t.Context()) if err != nil { - t.Fatalf("GetMessages failed: %v", err) + t.Fatalf("GetEvents failed: %v", err) } types := make([]copilot.SessionEventType, 0, len(messages)) @@ -225,19 +225,19 @@ func TestEventFidelityE2E(t *testing.T) { } if sessionStartIdx < 0 { - t.Fatalf("Expected session.start event in GetMessages; types=%v", types) + t.Fatalf("Expected session.start event in GetEvents; types=%v", types) } if userMsgIdx < 0 { - t.Fatalf("Expected user.message event in GetMessages; types=%v", types) + t.Fatalf("Expected user.message event in GetEvents; types=%v", types) } if toolStartIdx < 0 { - t.Fatalf("Expected tool.execution_start event in GetMessages; types=%v", types) + t.Fatalf("Expected tool.execution_start event in GetEvents; types=%v", types) } if toolCompleteIdx < 0 { - t.Fatalf("Expected tool.execution_complete event in GetMessages; types=%v", types) + t.Fatalf("Expected tool.execution_complete event in GetEvents; types=%v", types) } if assistantMsgIdx < 0 { - t.Fatalf("Expected assistant.message event in GetMessages; types=%v", types) + t.Fatalf("Expected assistant.message event in GetEvents; types=%v", types) } if sessionStartIdx >= userMsgIdx { diff --git a/go/internal/e2e/mcp_and_agents_e2e_test.go b/go/internal/e2e/mcp_and_agents_e2e_test.go index e7273edf2..b7e4c2400 100644 --- a/go/internal/e2e/mcp_and_agents_e2e_test.go +++ b/go/internal/e2e/mcp_and_agents_e2e_test.go @@ -21,7 +21,7 @@ func TestMCPServersE2E(t *testing.T) { "test-server": copilot.MCPStdioServerConfig{ Command: "echo", Args: []string{"hello"}, - Tools: []string{"*"}, + Tools: &[]string{"*"}, }, } @@ -63,7 +63,7 @@ func TestMCPServersE2E(t *testing.T) { mcpServers := map[string]copilot.MCPServerConfig{ "test-server": copilot.MCPStdioServerConfig{ Command: "echo", - Tools: []string{"*"}, + Tools: &[]string{"*"}, }, } @@ -118,7 +118,7 @@ func TestMCPServersE2E(t *testing.T) { "test-server": copilot.MCPStdioServerConfig{ Command: "echo", Args: []string{"hello"}, - Tools: []string{"*"}, + Tools: &[]string{"*"}, }, } @@ -159,7 +159,7 @@ func TestMCPServersE2E(t *testing.T) { "env-echo": copilot.MCPStdioServerConfig{ Command: "node", Args: []string{mcpServerPath}, - Tools: []string{"*"}, + Tools: &[]string{"*"}, Env: map[string]string{"TEST_SECRET": "hunter2"}, Cwd: mcpServerDir, }, @@ -198,12 +198,12 @@ func TestMCPServersE2E(t *testing.T) { "server1": copilot.MCPStdioServerConfig{ Command: "echo", Args: []string{"server1"}, - Tools: []string{"*"}, + Tools: &[]string{"*"}, }, "server2": copilot.MCPStdioServerConfig{ Command: "echo", Args: []string{"server2"}, - Tools: []string{"*"}, + Tools: &[]string{"*"}, }, } @@ -366,7 +366,7 @@ func TestCustomAgentsE2E(t *testing.T) { "agent-server": copilot.MCPStdioServerConfig{ Command: "echo", Args: []string{"agent-mcp"}, - Tools: []string{"*"}, + Tools: &[]string{"*"}, }, }, }, @@ -437,7 +437,7 @@ func TestCombinedConfigurationE2E(t *testing.T) { "shared-server": copilot.MCPStdioServerConfig{ Command: "echo", Args: []string{"shared"}, - Tools: []string{"*"}, + Tools: &[]string{"*"}, }, } diff --git a/go/internal/e2e/mode_handlers_e2e_test.go b/go/internal/e2e/mode_handlers_e2e_test.go index cdf6800a1..0fa9a3012 100644 --- a/go/internal/e2e/mode_handlers_e2e_test.go +++ b/go/internal/e2e/mode_handlers_e2e_test.go @@ -43,7 +43,7 @@ func TestModeHandlersE2E(t *testing.T) { session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ GitHubToken: modeHandlerToken, OnPermissionRequest: copilot.PermissionHandler.ApproveAll, - OnExitPlanMode: func(request copilot.ExitPlanModeRequest, invocation copilot.ExitPlanModeInvocation) (copilot.ExitPlanModeResult, error) { + OnExitPlanModeRequest: func(request copilot.ExitPlanModeRequest, invocation copilot.ExitPlanModeInvocation) (copilot.ExitPlanModeResult, error) { mu.Lock() exitPlanModeRequests = append(exitPlanModeRequests, request) mu.Unlock() @@ -132,7 +132,7 @@ func TestModeHandlersE2E(t *testing.T) { session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ GitHubToken: modeHandlerToken, OnPermissionRequest: copilot.PermissionHandler.ApproveAll, - OnAutoModeSwitch: func(request copilot.AutoModeSwitchRequest, invocation copilot.AutoModeSwitchInvocation) (copilot.AutoModeSwitchResponse, error) { + OnAutoModeSwitchRequest: func(request copilot.AutoModeSwitchRequest, invocation copilot.AutoModeSwitchInvocation) (copilot.AutoModeSwitchResponse, error) { mu.Lock() autoModeSwitchRequests = append(autoModeSwitchRequests, request) mu.Unlock() diff --git a/go/internal/e2e/multi_client_e2e_test.go b/go/internal/e2e/multi_client_e2e_test.go index 84ad8909e..9dd8a15bf 100644 --- a/go/internal/e2e/multi_client_e2e_test.go +++ b/go/internal/e2e/multi_client_e2e_test.go @@ -17,8 +17,7 @@ func TestMultiClientE2E(t *testing.T) { // Use TCP mode so a second client can connect to the same CLI process ctx := testharness.NewTestContext(t) client1 := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.UseStdio = copilot.Bool(false) - opts.TCPConnectionToken = sharedTcpToken + opts.Connection = copilot.TcpConnection{Path: opts.Connection.(copilot.StdioConnection).Path, ConnectionToken: sharedTcpToken} }) t.Cleanup(func() { client1.ForceStop() }) @@ -31,14 +30,13 @@ func TestMultiClientE2E(t *testing.T) { } initSession.Disconnect() - actualPort := client1.ActualPort() - if actualPort == 0 { + runtimePort := client1.RuntimePort() + if runtimePort == 0 { t.Fatalf("Expected non-zero port from TCP mode client") } client2 := copilot.NewClient(&copilot.ClientOptions{ - CLIUrl: fmt.Sprintf("localhost:%d", actualPort), - TCPConnectionToken: sharedTcpToken, + Connection: copilot.UriConnection{URL: fmt.Sprintf("localhost:%d", runtimePort), ConnectionToken: sharedTcpToken}, }) t.Cleanup(func() { client2.ForceStop() }) @@ -488,8 +486,7 @@ func TestMultiClientE2E(t *testing.T) { // Recreate client2 for cleanup (but don't rejoin the session) client2 = copilot.NewClient(&copilot.ClientOptions{ - CLIUrl: fmt.Sprintf("localhost:%d", actualPort), - TCPConnectionToken: sharedTcpToken, + Connection: copilot.UriConnection{URL: fmt.Sprintf("localhost:%d", runtimePort), ConnectionToken: sharedTcpToken}, }) // Now only stable_tool should be available diff --git a/go/internal/e2e/pending_work_resume_e2e_test.go b/go/internal/e2e/pending_work_resume_e2e_test.go index 170568ff2..41ca83021 100644 --- a/go/internal/e2e/pending_work_resume_e2e_test.go +++ b/go/internal/e2e/pending_work_resume_e2e_test.go @@ -43,9 +43,7 @@ func TestPendingWorkResumeE2E(t *testing.T) { releasePermission := make(chan copilot.PermissionRequestResult, 1) suspendedClient := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.CLIUrl = cliURL - opts.CLIPath = "" - opts.TCPConnectionToken = sharedTcpToken + opts.Connection = copilot.UriConnection{URL: cliURL, ConnectionToken: sharedTcpToken} }) session1, err := suspendedClient.CreateSession(t.Context(), &copilot.SessionConfig{ Tools: []copilot.Tool{originalTool}, @@ -110,9 +108,7 @@ func TestPendingWorkResumeE2E(t *testing.T) { }) resumedClient := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.CLIUrl = cliURL - opts.CLIPath = "" - opts.TCPConnectionToken = sharedTcpToken + opts.Connection = copilot.UriConnection{URL: cliURL, ConnectionToken: sharedTcpToken} }) t.Cleanup(func() { resumedClient.ForceStop() }) @@ -187,9 +183,7 @@ func TestPendingWorkResumeE2E(t *testing.T) { }) suspendedClient := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.CLIUrl = cliURL - opts.CLIPath = "" - opts.TCPConnectionToken = sharedTcpToken + opts.Connection = copilot.UriConnection{URL: cliURL, ConnectionToken: sharedTcpToken} }) session1, err := suspendedClient.CreateSession(t.Context(), &copilot.SessionConfig{ Tools: []copilot.Tool{originalTool}, @@ -225,9 +219,7 @@ func TestPendingWorkResumeE2E(t *testing.T) { suspendedClient.ForceStop() resumedClient := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.CLIUrl = cliURL - opts.CLIPath = "" - opts.TCPConnectionToken = sharedTcpToken + opts.Connection = copilot.UriConnection{URL: cliURL, ConnectionToken: sharedTcpToken} }) t.Cleanup(func() { resumedClient.ForceStop() }) @@ -299,9 +291,7 @@ func TestPendingWorkResumeE2E(t *testing.T) { }) suspendedClient := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.CLIUrl = cliURL - opts.CLIPath = "" - opts.TCPConnectionToken = sharedTcpToken + opts.Connection = copilot.UriConnection{URL: cliURL, ConnectionToken: sharedTcpToken} }) session1, err := suspendedClient.CreateSession(t.Context(), &copilot.SessionConfig{ Tools: []copilot.Tool{originalA, originalB}, @@ -344,9 +334,7 @@ func TestPendingWorkResumeE2E(t *testing.T) { suspendedClient.ForceStop() resumedClient := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.CLIUrl = cliURL - opts.CLIPath = "" - opts.TCPConnectionToken = sharedTcpToken + opts.Connection = copilot.UriConnection{URL: cliURL, ConnectionToken: sharedTcpToken} }) t.Cleanup(func() { resumedClient.ForceStop() }) @@ -394,9 +382,7 @@ func TestPendingWorkResumeE2E(t *testing.T) { var sessionID string func() { firstClient := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.CLIUrl = cliURL - opts.CLIPath = "" - opts.TCPConnectionToken = sharedTcpToken + opts.Connection = copilot.UriConnection{URL: cliURL, ConnectionToken: sharedTcpToken} }) defer firstClient.ForceStop() @@ -422,9 +408,7 @@ func TestPendingWorkResumeE2E(t *testing.T) { }() resumedClient := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.CLIUrl = cliURL - opts.CLIPath = "" - opts.TCPConnectionToken = sharedTcpToken + opts.Connection = copilot.UriConnection{URL: cliURL, ConnectionToken: sharedTcpToken} }) t.Cleanup(func() { resumedClient.ForceStop() }) @@ -470,9 +454,7 @@ func TestPendingWorkResumeE2E(t *testing.T) { }) suspendedClient := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.CLIUrl = cliURL - opts.CLIPath = "" - opts.TCPConnectionToken = sharedTcpToken + opts.Connection = copilot.UriConnection{URL: cliURL, ConnectionToken: sharedTcpToken} }) session1, err := suspendedClient.CreateSession(t.Context(), &copilot.SessionConfig{ Tools: []copilot.Tool{originalTool}, @@ -509,9 +491,7 @@ func TestPendingWorkResumeE2E(t *testing.T) { suspendedClient.ForceStop() resumedClient := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.CLIUrl = cliURL - opts.CLIPath = "" - opts.TCPConnectionToken = sharedTcpToken + opts.Connection = copilot.UriConnection{URL: cliURL, ConnectionToken: sharedTcpToken} }) t.Cleanup(func() { resumedClient.ForceStop() }) @@ -524,9 +504,9 @@ func TestPendingWorkResumeE2E(t *testing.T) { } // Verify resume event reflects ContinuePendingWork=false and SessionWasActive=true - messages, err := session2.GetMessages(t.Context()) + messages, err := session2.GetEvents(t.Context()) if err != nil { - t.Fatalf("GetMessages failed: %v", err) + t.Fatalf("GetEvents failed: %v", err) } var resumeEvent *copilot.SessionResumeData for _, msg := range messages { @@ -577,9 +557,7 @@ func TestPendingWorkResumeE2E(t *testing.T) { var sessionID string func() { firstClient := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.CLIUrl = cliURL - opts.CLIPath = "" - opts.TCPConnectionToken = sharedTcpToken + opts.Connection = copilot.UriConnection{URL: cliURL, ConnectionToken: sharedTcpToken} }) defer firstClient.ForceStop() @@ -605,9 +583,7 @@ func TestPendingWorkResumeE2E(t *testing.T) { }() resumedClient := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.CLIUrl = cliURL - opts.CLIPath = "" - opts.TCPConnectionToken = sharedTcpToken + opts.Connection = copilot.UriConnection{URL: cliURL, ConnectionToken: sharedTcpToken} }) t.Cleanup(func() { resumedClient.ForceStop() }) @@ -620,9 +596,9 @@ func TestPendingWorkResumeE2E(t *testing.T) { } // Verify resume event reflects ContinuePendingWork=true and SessionWasActive=false (cold resume) - messages, err := resumedSession.GetMessages(t.Context()) + messages, err := resumedSession.GetEvents(t.Context()) if err != nil { - t.Fatalf("GetMessages failed: %v", err) + t.Fatalf("GetEvents failed: %v", err) } var resumeEvent *copilot.SessionResumeData for _, msg := range messages { @@ -663,9 +639,9 @@ func TestPendingWorkResumeE2E(t *testing.T) { // test failure if the port is not yet available. func serverCliURL(t *testing.T, server *copilot.Client) string { t.Helper() - port := server.ActualPort() + port := server.RuntimePort() if port == 0 { - t.Fatal("Expected non-zero ActualPort from TCP server client; ensure the server is started before calling serverCliURL") + t.Fatal("Expected non-zero RuntimePort from TCP server client; ensure the server is started before calling serverCliURL") } return fmt.Sprintf("localhost:%d", port) } @@ -677,12 +653,11 @@ func serverCliURL(t *testing.T, server *copilot.Client) string { const sharedTcpToken = "tcp-shared-test-token" // startTcpServer starts a TCP-mode server client and returns its CLI URL. -// It triggers an initial connection so ActualPort is populated. +// It triggers an initial connection so RuntimePort is populated. func startTcpServer(t *testing.T, ctx *testharness.TestContext) (*copilot.Client, string) { t.Helper() server := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.UseStdio = copilot.Bool(false) - opts.TCPConnectionToken = sharedTcpToken + opts.Connection = copilot.TcpConnection{Path: opts.Connection.(copilot.StdioConnection).Path, ConnectionToken: sharedTcpToken} }) t.Cleanup(func() { server.ForceStop() }) // Trigger connection so we can read the port. CreateSession+Disconnect is the diff --git a/go/internal/e2e/per_session_auth_e2e_test.go b/go/internal/e2e/per_session_auth_e2e_test.go index d40546028..66a2768bb 100644 --- a/go/internal/e2e/per_session_auth_e2e_test.go +++ b/go/internal/e2e/per_session_auth_e2e_test.go @@ -101,7 +101,7 @@ func TestPerSessionAuthE2E(t *testing.T) { ctx.ConfigureForTest(t) noTokenClient := copilot.NewClient(&copilot.ClientOptions{ - CLIPath: ctx.CLIPath, + Connection: copilot.StdioConnection{Path: ctx.CLIPath}, Cwd: ctx.WorkDir, Env: withoutAuthEnv(append(ctx.Env(), "COPILOT_DEBUG_GITHUB_API_URL="+ctx.ProxyURL)), UseLoggedInUser: copilot.Bool(false), diff --git a/go/internal/e2e/rpc_e2e_test.go b/go/internal/e2e/rpc_e2e_test.go index 8f73afa9e..ccbf26d1d 100644 --- a/go/internal/e2e/rpc_e2e_test.go +++ b/go/internal/e2e/rpc_e2e_test.go @@ -17,8 +17,7 @@ func TestRpcE2E(t *testing.T) { t.Run("should call RPC.Ping with typed params and result", func(t *testing.T) { client := copilot.NewClient(&copilot.ClientOptions{ - CLIPath: cliPath, - UseStdio: copilot.Bool(true), + Connection: copilot.StdioConnection{Path: cliPath}, }) t.Cleanup(func() { client.ForceStop() }) @@ -46,8 +45,7 @@ func TestRpcE2E(t *testing.T) { t.Run("should call RPC.Models.List with typed result", func(t *testing.T) { client := copilot.NewClient(&copilot.ClientOptions{ - CLIPath: cliPath, - UseStdio: copilot.Bool(true), + Connection: copilot.StdioConnection{Path: cliPath}, }) t.Cleanup(func() { client.ForceStop() }) @@ -83,8 +81,7 @@ func TestRpcE2E(t *testing.T) { t.Skip("account.getQuota not yet implemented in CLI") client := copilot.NewClient(&copilot.ClientOptions{ - CLIPath: cliPath, - UseStdio: copilot.Bool(true), + Connection: copilot.StdioConnection{Path: cliPath}, }) t.Cleanup(func() { client.ForceStop() }) diff --git a/go/internal/e2e/rpc_event_side_effects_e2e_test.go b/go/internal/e2e/rpc_event_side_effects_e2e_test.go index 55f9835f3..765a570a2 100644 --- a/go/internal/e2e/rpc_event_side_effects_e2e_test.go +++ b/go/internal/e2e/rpc_event_side_effects_e2e_test.go @@ -168,7 +168,7 @@ func TestRpcEventSideEffectsE2E(t *testing.T) { t.Fatalf("Failed to create persisted message: %v", err) } - messages, err := session.GetMessages(t.Context()) + messages, err := session.GetEvents(t.Context()) if err != nil { t.Fatalf("Failed to read messages: %v", err) } @@ -203,7 +203,7 @@ func TestRpcEventSideEffectsE2E(t *testing.T) { t.Fatalf("Expected rewind count %d, got %+v", truncateResult.EventsRemoved, rewindData) } - messagesAfter, err := session.GetMessages(t.Context()) + messagesAfter, err := session.GetEvents(t.Context()) if err != nil { t.Fatalf("Failed to read messages after truncate: %v", err) } @@ -224,7 +224,7 @@ func TestRpcEventSideEffectsE2E(t *testing.T) { t.Fatalf("Failed to create persisted message: %v", err) } - messages, err := session.GetMessages(t.Context()) + messages, err := session.GetEvents(t.Context()) if err != nil { t.Fatalf("Failed to read messages: %v", err) } diff --git a/go/internal/e2e/rpc_mcp_and_skills_e2e_test.go b/go/internal/e2e/rpc_mcp_and_skills_e2e_test.go index 9a8ef8ebd..c636201da 100644 --- a/go/internal/e2e/rpc_mcp_and_skills_e2e_test.go +++ b/go/internal/e2e/rpc_mcp_and_skills_e2e_test.go @@ -19,7 +19,9 @@ func TestRpcMcpAndSkillsE2E(t *testing.T) { // --yolo auto-approves extension permission gates at the CLI level, // preventing breakage from new gates (e.g., extension-permission-access). client := ctx.NewClient(func(o *copilot.ClientOptions) { - o.CLIArgs = []string{"--yolo"} + stdio := o.Connection.(copilot.StdioConnection) + stdio.Args = []string{"--yolo"} + o.Connection = stdio }) t.Cleanup(func() { client.ForceStop() }) @@ -110,7 +112,7 @@ func TestRpcMcpAndSkillsE2E(t *testing.T) { serverName: copilot.MCPStdioServerConfig{ Command: "echo", Args: []string{"rpc-list-mcp-server"}, - Tools: []string{"*"}, + Tools: &[]string{"*"}, }, }, }) diff --git a/go/internal/e2e/rpc_session_state_e2e_test.go b/go/internal/e2e/rpc_session_state_e2e_test.go index 8ac041255..cb68651ae 100644 --- a/go/internal/e2e/rpc_session_state_e2e_test.go +++ b/go/internal/e2e/rpc_session_state_e2e_test.go @@ -220,7 +220,7 @@ func TestRpcSessionStateE2E(t *testing.T) { t.Errorf("Expected initial answer to contain FORK_SOURCE_ALPHA, got %v", initialAnswer.Data) } - sourceMessages, err := session.GetMessages(t.Context()) + sourceMessages, err := session.GetEvents(t.Context()) if err != nil { t.Fatalf("Failed to read source messages: %v", err) } @@ -250,7 +250,7 @@ func TestRpcSessionStateE2E(t *testing.T) { t.Fatalf("Failed to resume forked session: %v", err) } - forkedMessages, err := forkedSession.GetMessages(t.Context()) + forkedMessages, err := forkedSession.GetEvents(t.Context()) if err != nil { t.Fatalf("Failed to read forked messages: %v", err) } @@ -272,7 +272,7 @@ func TestRpcSessionStateE2E(t *testing.T) { t.Errorf("Expected forked answer to contain FORK_CHILD_BETA, got %v", forkAnswer.Data) } - sourceAfterFork, err := session.GetMessages(t.Context()) + sourceAfterFork, err := session.GetEvents(t.Context()) if err != nil { t.Fatalf("Failed to read source messages after fork: %v", err) } @@ -282,7 +282,7 @@ func TestRpcSessionStateE2E(t *testing.T) { } } - forkAfterPrompt, err := forkedSession.GetMessages(t.Context()) + forkAfterPrompt, err := forkedSession.GetEvents(t.Context()) if err != nil { t.Fatalf("Failed to read forked messages after prompt: %v", err) } @@ -336,7 +336,7 @@ func TestRpcSessionStateE2E(t *testing.T) { } defer forkedSession.Disconnect() - forkedMessages, err := forkedSession.GetMessages(t.Context()) + forkedMessages, err := forkedSession.GetEvents(t.Context()) if err != nil { t.Fatalf("Failed to read forked messages: %v", err) } @@ -366,7 +366,7 @@ func TestRpcSessionStateE2E(t *testing.T) { t.Fatalf("Failed to send second prompt: %v", err) } - sourceEvents, err := session.GetMessages(t.Context()) + sourceEvents, err := session.GetEvents(t.Context()) if err != nil { t.Fatalf("Failed to read source messages: %v", err) } @@ -406,7 +406,7 @@ func TestRpcSessionStateE2E(t *testing.T) { } defer forkedSession.Disconnect() - forkedEvents, err := forkedSession.GetMessages(t.Context()) + forkedEvents, err := forkedSession.GetEvents(t.Context()) if err != nil { t.Fatalf("Failed to read forked messages: %v", err) } diff --git a/go/internal/e2e/rpc_shell_and_fleet_e2e_test.go b/go/internal/e2e/rpc_shell_and_fleet_e2e_test.go index ff7e545dd..7655d179e 100644 --- a/go/internal/e2e/rpc_shell_and_fleet_e2e_test.go +++ b/go/internal/e2e/rpc_shell_and_fleet_e2e_test.go @@ -196,7 +196,7 @@ func waitForFleetCompletion(t *testing.T, session *copilot.Session, contentNeedl t.Helper() deadline := time.Now().Add(120 * time.Second) for time.Now().Before(deadline) { - messages, err := session.GetMessages(t.Context()) + messages, err := session.GetEvents(t.Context()) if err == nil { for _, evt := range messages { if d, ok := evt.Data.(*copilot.AssistantMessageData); ok && strings.Contains(strings.ToLower(d.Content), contentNeedle) { diff --git a/go/internal/e2e/session_config_e2e_test.go b/go/internal/e2e/session_config_e2e_test.go index de9dad9e2..d932ae31b 100644 --- a/go/internal/e2e/session_config_e2e_test.go +++ b/go/internal/e2e/session_config_e2e_test.go @@ -202,9 +202,9 @@ func TestSessionConfigExtrasE2E(t *testing.T) { t.Errorf("Expected SessionID=%q, got %q", requestedSessionID, session.SessionID) } - messages, err := session.GetMessages(t.Context()) + messages, err := session.GetEvents(t.Context()) if err != nil { - t.Fatalf("GetMessages failed: %v", err) + t.Fatalf("GetEvents failed: %v", err) } if len(messages) == 0 || messages[0].Type() != copilot.SessionEventTypeSessionStart { t.Fatalf("Expected first event to be session.start, got %+v", messages) diff --git a/go/internal/e2e/session_e2e_test.go b/go/internal/e2e/session_e2e_test.go index f0d249422..bddd7e8e1 100644 --- a/go/internal/e2e/session_e2e_test.go +++ b/go/internal/e2e/session_e2e_test.go @@ -33,7 +33,7 @@ func TestSessionE2E(t *testing.T) { t.Errorf("Expected session ID to match UUID pattern, got %q", session.SessionID) } - messages, err := session.GetMessages(t.Context()) + messages, err := session.GetEvents(t.Context()) if err != nil { t.Fatalf("Failed to get messages: %v", err) } @@ -55,9 +55,9 @@ func TestSessionE2E(t *testing.T) { t.Fatalf("Failed to disconnect session: %v", err) } - _, err = session.GetMessages(t.Context()) + _, err = session.GetEvents(t.Context()) if err == nil || !strings.Contains(err.Error(), "not found") { - t.Errorf("Expected GetMessages to fail with 'not found' after disconnect, got %v", err) + t.Errorf("Expected GetEvents to fail with 'not found' after disconnect, got %v", err) } }) @@ -525,7 +525,7 @@ func TestSessionE2E(t *testing.T) { } // When resuming with a new client, we check messages contain expected types - messages, err := session2.GetMessages(t.Context()) + messages, err := session2.GetEvents(t.Context()) if err != nil { t.Fatalf("Failed to get messages: %v", err) } @@ -660,7 +660,7 @@ func TestSessionE2E(t *testing.T) { } // The session should still be alive and usable after abort - messages, err := session.GetMessages(t.Context()) + messages, err := session.GetEvents(t.Context()) if err != nil { t.Fatalf("Failed to get messages after abort: %v", err) } @@ -781,7 +781,7 @@ func TestSessionE2E(t *testing.T) { } // Verify the assistant response contains the expected answer. - // session.idle is ephemeral and not in GetMessages(), but we already + // session.idle is ephemeral and not in GetEvents(), but we already // confirmed idle via the live event handler above. assistantMessage, err := testharness.GetFinalAssistantMessage(t.Context(), session, true) if err != nil { @@ -882,10 +882,10 @@ func TestSessionE2E(t *testing.T) { if sessionData.SessionID == "" { t.Error("Expected sessionId to be non-empty") } - if sessionData.StartTime == "" { + if sessionData.StartTime.IsZero() { t.Error("Expected startTime to be non-empty") } - if sessionData.ModifiedTime == "" { + if sessionData.ModifiedTime.IsZero() { t.Error("Expected modifiedTime to be non-empty") } // isRemote is a boolean, so it's always set @@ -996,11 +996,11 @@ func TestSessionE2E(t *testing.T) { t.Errorf("Expected sessionId %s, got %s", session.SessionID, metadata.SessionID) } - if metadata.StartTime == "" { + if metadata.StartTime.IsZero() { t.Error("Expected startTime to be non-empty") } - if metadata.ModifiedTime == "" { + if metadata.ModifiedTime.IsZero() { t.Error("Expected modifiedTime to be non-empty") } @@ -1295,7 +1295,7 @@ func getEventMessage(evt copilot.SessionEvent) string { // TestSessionAttachments mirrors the C# Should_Send_With_*_Attachment tests in SessionTests.cs. // Each subtest exercises a different UserMessageAttachment shape end-to-end through SendAndWait -// and verifies the resulting user.message event captured by GetMessages. +// and verifies the resulting user.message event captured by GetEvents. func TestSessionAttachmentsE2E(t *testing.T) { ctx := testharness.NewTestContext(t) client := ctx.NewClient() @@ -1501,9 +1501,9 @@ func TestSessionAttachmentsE2E(t *testing.T) { // lastUserAttachment returns the single attachment from the most recent user.message event. func lastUserAttachment(t *testing.T, session *copilot.Session) copilot.Attachment { t.Helper() - messages, err := session.GetMessages(t.Context()) + messages, err := session.GetEvents(t.Context()) if err != nil { - t.Fatalf("GetMessages failed: %v", err) + t.Fatalf("GetEvents failed: %v", err) } for i := len(messages) - 1; i >= 0; i-- { if messages[i].Type() != copilot.SessionEventTypeUserMessage { @@ -1550,9 +1550,9 @@ func TestSessionMessageOptionsE2E(t *testing.T) { t.Fatalf("SendAndWait failed: %v", err) } - messages, err := session.GetMessages(t.Context()) + messages, err := session.GetEvents(t.Context()) if err != nil { - t.Fatalf("GetMessages failed: %v", err) + t.Fatalf("GetEvents failed: %v", err) } var userMsg *copilot.UserMessageData for i := len(messages) - 1; i >= 0; i-- { diff --git a/go/internal/e2e/session_fs_e2e_test.go b/go/internal/e2e/session_fs_e2e_test.go index d56dc14a3..2c014f9e0 100644 --- a/go/internal/e2e/session_fs_e2e_test.go +++ b/go/internal/e2e/session_fs_e2e_test.go @@ -43,8 +43,8 @@ func TestSessionFsE2E(t *testing.T) { ctx.ConfigureForTest(t) session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - OnPermissionRequest: copilot.PermissionHandler.ApproveAll, - CreateSessionFsHandler: createSessionFsHandler, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + CreateSessionFsProvider: createSessionFsHandler, }) if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -80,8 +80,8 @@ func TestSessionFsE2E(t *testing.T) { ctx.ConfigureForTest(t) session1, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - OnPermissionRequest: copilot.PermissionHandler.ApproveAll, - CreateSessionFsHandler: createSessionFsHandler, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + CreateSessionFsProvider: createSessionFsHandler, }) if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -110,8 +110,8 @@ func TestSessionFsE2E(t *testing.T) { } session2, err := client.ResumeSession(t.Context(), sessionID, &copilot.ResumeSessionConfig{ - OnPermissionRequest: copilot.PermissionHandler.ApproveAll, - CreateSessionFsHandler: createSessionFsHandler, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + CreateSessionFsProvider: createSessionFsHandler, }) if err != nil { t.Fatalf("Failed to resume session: %v", err) @@ -139,7 +139,7 @@ func TestSessionFsE2E(t *testing.T) { ctx.ConfigureForTest(t) client1 := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.UseStdio = copilot.Bool(false) + opts.Connection = copilot.TcpConnection{Path: ctx.CLIPath} }) t.Cleanup(func() { client1.ForceStop() }) @@ -149,16 +149,16 @@ func TestSessionFsE2E(t *testing.T) { t.Fatalf("Failed to create initial session: %v", err) } - actualPort := client1.ActualPort() - if actualPort == 0 { + runtimePort := client1.RuntimePort() + if runtimePort == 0 { t.Fatalf("Expected non-zero port from TCP mode client") } client2 := copilot.NewClient(&copilot.ClientOptions{ - CLIUrl: fmt.Sprintf("localhost:%d", actualPort), - LogLevel: "error", - Env: ctx.Env(), - SessionFs: sessionFsConfig, + Connection: copilot.UriConnection{URL: fmt.Sprintf("localhost:%d", runtimePort)}, + LogLevel: "error", + Env: ctx.Env(), + SessionFs: sessionFsConfig, }) t.Cleanup(func() { client2.ForceStop() }) @@ -172,8 +172,8 @@ func TestSessionFsE2E(t *testing.T) { suppliedFileContent := strings.Repeat("x", 100_000) session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - OnPermissionRequest: copilot.PermissionHandler.ApproveAll, - CreateSessionFsHandler: createSessionFsHandler, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + CreateSessionFsProvider: createSessionFsHandler, Tools: []copilot.Tool{ copilot.DefineTool("get_big_string", "Returns a large string", func(_ struct{}, inv copilot.ToolInvocation) (string, error) { @@ -191,7 +191,7 @@ func TestSessionFsE2E(t *testing.T) { t.Fatalf("Failed to send message: %v", err) } - messages, err := session.GetMessages(t.Context()) + messages, err := session.GetEvents(t.Context()) if err != nil { t.Fatalf("Failed to get messages: %v", err) } @@ -217,8 +217,8 @@ func TestSessionFsE2E(t *testing.T) { ctx.ConfigureForTest(t) session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - OnPermissionRequest: copilot.PermissionHandler.ApproveAll, - CreateSessionFsHandler: createSessionFsHandler, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + CreateSessionFsProvider: createSessionFsHandler, }) if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -256,8 +256,8 @@ func TestSessionFsE2E(t *testing.T) { ctx.ConfigureForTest(t) session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - OnPermissionRequest: copilot.PermissionHandler.ApproveAll, - CreateSessionFsHandler: createSessionFsHandler, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + CreateSessionFsProvider: createSessionFsHandler, }) if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -298,8 +298,8 @@ func TestSessionFsE2E(t *testing.T) { ctx.ConfigureForTest(t) session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - OnPermissionRequest: copilot.PermissionHandler.ApproveAll, - CreateSessionFsHandler: createSessionFsHandler, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + CreateSessionFsProvider: createSessionFsHandler, }) if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -408,7 +408,7 @@ func (h *testSessionFsHandler) Stat(path string) (*copilot.SessionFsFileInfo, er }, nil } -func (h *testSessionFsHandler) Mkdir(path string, recursive bool, mode *int) error { +func (h *testSessionFsHandler) MakeDirectory(path string, recursive bool, mode *int) error { fullPath := providerPath(h.root, h.sessionID, path) perm := os.FileMode(0o777) if mode != nil { @@ -420,7 +420,7 @@ func (h *testSessionFsHandler) Mkdir(path string, recursive bool, mode *int) err return os.Mkdir(fullPath, perm) } -func (h *testSessionFsHandler) Readdir(path string) ([]string, error) { +func (h *testSessionFsHandler) ReadDirectory(path string) ([]string, error) { entries, err := os.ReadDir(providerPath(h.root, h.sessionID, path)) if err != nil { return nil, err @@ -432,7 +432,7 @@ func (h *testSessionFsHandler) Readdir(path string) ([]string, error) { return names, nil } -func (h *testSessionFsHandler) ReaddirWithTypes(path string) ([]rpc.SessionFsReaddirWithTypesEntry, error) { +func (h *testSessionFsHandler) ReadDirectoryWithTypes(path string) ([]rpc.SessionFsReaddirWithTypesEntry, error) { entries, err := os.ReadDir(providerPath(h.root, h.sessionID, path)) if err != nil { return nil, err @@ -451,7 +451,7 @@ func (h *testSessionFsHandler) ReaddirWithTypes(path string) ([]rpc.SessionFsRea return result, nil } -func (h *testSessionFsHandler) Rm(path string, recursive bool, force bool) error { +func (h *testSessionFsHandler) Remove(path string, recursive bool, force bool) error { fullPath := providerPath(h.root, h.sessionID, path) var err error if recursive { @@ -533,7 +533,7 @@ func TestSessionFsHandlerOperationsE2E(t *testing.T) { sessionID := "handler-session" handler := &testSessionFsHandler{root: providerRoot, sessionID: sessionID} - if err := handler.Mkdir("/workspace/nested", true, nil); err != nil { + if err := handler.MakeDirectory("/workspace/nested", true, nil); err != nil { t.Fatalf("Mkdir failed: %v", err) } @@ -575,7 +575,7 @@ func TestSessionFsHandlerOperationsE2E(t *testing.T) { t.Errorf("Expected content 'hello world', got %q", content) } - entries, err := handler.Readdir("/workspace/nested") + entries, err := handler.ReadDirectory("/workspace/nested") if err != nil { t.Fatalf("Readdir failed: %v", err) } @@ -583,7 +583,7 @@ func TestSessionFsHandlerOperationsE2E(t *testing.T) { t.Errorf("Expected entries to contain 'file.txt', got %v", entries) } - typedEntries, err := handler.ReaddirWithTypes("/workspace/nested") + typedEntries, err := handler.ReadDirectoryWithTypes("/workspace/nested") if err != nil { t.Fatalf("ReaddirWithTypes failed: %v", err) } @@ -616,7 +616,7 @@ func TestSessionFsHandlerOperationsE2E(t *testing.T) { t.Errorf("Expected renamed content 'hello world', got %q", renamedContent) } - if err := handler.Rm("/workspace/nested/renamed.txt", false, false); err != nil { + if err := handler.Remove("/workspace/nested/renamed.txt", false, false); err != nil { t.Fatalf("Rm failed: %v", err) } removed, err := handler.Exists("/workspace/nested/renamed.txt") @@ -628,7 +628,7 @@ func TestSessionFsHandlerOperationsE2E(t *testing.T) { } // Force removing a missing path should succeed. - if err := handler.Rm("/workspace/nested/missing.txt", false, true); err != nil { + if err := handler.Remove("/workspace/nested/missing.txt", false, true); err != nil { t.Errorf("Rm with force on missing path should not error, got %v", err) } diff --git a/go/internal/e2e/session_fs_sqlite_e2e_test.go b/go/internal/e2e/session_fs_sqlite_e2e_test.go index f73cf2e34..3d453f0a8 100644 --- a/go/internal/e2e/session_fs_sqlite_e2e_test.go +++ b/go/internal/e2e/session_fs_sqlite_e2e_test.go @@ -100,7 +100,7 @@ func (p *inMemorySqliteProvider) Stat(path string) (*copilot.SessionFsFileInfo, return nil, fmt.Errorf("not found: %s", path) } -func (p *inMemorySqliteProvider) Mkdir(path string, recursive bool, mode *int) error { +func (p *inMemorySqliteProvider) MakeDirectory(path string, recursive bool, mode *int) error { p.mu.Lock() defer p.mu.Unlock() if recursive { @@ -114,7 +114,7 @@ func (p *inMemorySqliteProvider) Mkdir(path string, recursive bool, mode *int) e return nil } -func (p *inMemorySqliteProvider) Readdir(path string) ([]string, error) { +func (p *inMemorySqliteProvider) ReadDirectory(path string) ([]string, error) { p.mu.Lock() defer p.mu.Unlock() prefix := strings.TrimRight(path, "/") + "/" @@ -143,7 +143,7 @@ func (p *inMemorySqliteProvider) Readdir(path string) ([]string, error) { return result, nil } -func (p *inMemorySqliteProvider) ReaddirWithTypes(path string) ([]rpc.SessionFsReaddirWithTypesEntry, error) { +func (p *inMemorySqliteProvider) ReadDirectoryWithTypes(path string) ([]rpc.SessionFsReaddirWithTypesEntry, error) { p.mu.Lock() defer p.mu.Unlock() prefix := strings.TrimRight(path, "/") + "/" @@ -176,7 +176,7 @@ func (p *inMemorySqliteProvider) ReaddirWithTypes(path string) ([]rpc.SessionFsR return result, nil } -func (p *inMemorySqliteProvider) Rm(path string, recursive bool, force bool) error { +func (p *inMemorySqliteProvider) Remove(path string, recursive bool, force bool) error { p.mu.Lock() defer p.mu.Unlock() delete(p.files, path) @@ -268,8 +268,8 @@ func TestSessionFsSqliteE2E(t *testing.T) { sqliteCalls = nil session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - OnPermissionRequest: copilot.PermissionHandler.ApproveAll, - CreateSessionFsHandler: createSessionFsHandler, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + CreateSessionFsProvider: createSessionFsHandler, }) if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -306,8 +306,8 @@ func TestSessionFsSqliteE2E(t *testing.T) { sqliteCalls = nil session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - OnPermissionRequest: copilot.PermissionHandler.ApproveAll, - CreateSessionFsHandler: createSessionFsHandler, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + CreateSessionFsProvider: createSessionFsHandler, }) if err != nil { t.Fatalf("Failed to create session: %v", err) diff --git a/go/internal/e2e/streaming_fidelity_e2e_test.go b/go/internal/e2e/streaming_fidelity_e2e_test.go index 2684306d7..189b61bf2 100644 --- a/go/internal/e2e/streaming_fidelity_e2e_test.go +++ b/go/internal/e2e/streaming_fidelity_e2e_test.go @@ -19,7 +19,7 @@ func TestStreamingFidelityE2E(t *testing.T) { session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ OnPermissionRequest: copilot.PermissionHandler.ApproveAll, - Streaming: true, + Streaming: copilot.Bool(true), }) if err != nil { t.Fatalf("Failed to create session with streaming: %v", err) @@ -94,7 +94,7 @@ func TestStreamingFidelityE2E(t *testing.T) { session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ OnPermissionRequest: copilot.PermissionHandler.ApproveAll, - Streaming: false, + Streaming: copilot.Bool(false), }) if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -146,7 +146,7 @@ func TestStreamingFidelityE2E(t *testing.T) { session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ OnPermissionRequest: copilot.PermissionHandler.ApproveAll, - Streaming: false, + Streaming: copilot.Bool(false), }) if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -163,7 +163,7 @@ func TestStreamingFidelityE2E(t *testing.T) { session2, err := newClient.ResumeSession(t.Context(), session.SessionID, &copilot.ResumeSessionConfig{ OnPermissionRequest: copilot.PermissionHandler.ApproveAll, - Streaming: true, + Streaming: copilot.Bool(true), }) if err != nil { t.Fatalf("Failed to resume session: %v", err) @@ -216,7 +216,7 @@ func TestStreamingFidelityE2E(t *testing.T) { session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ OnPermissionRequest: copilot.PermissionHandler.ApproveAll, - Streaming: true, + Streaming: copilot.Bool(true), }) if err != nil { t.Fatalf("Failed to create session with streaming: %v", err) @@ -232,7 +232,7 @@ func TestStreamingFidelityE2E(t *testing.T) { session2, err := newClient.ResumeSession(t.Context(), session.SessionID, &copilot.ResumeSessionConfig{ OnPermissionRequest: copilot.PermissionHandler.ApproveAll, - Streaming: false, + Streaming: copilot.Bool(false), }) if err != nil { t.Fatalf("Failed to resume session: %v", err) @@ -291,7 +291,7 @@ func TestStreamingFidelityE2E(t *testing.T) { // the streaming pipeline — deltas still arrive and complete successfully. session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ OnPermissionRequest: copilot.PermissionHandler.ApproveAll, - Streaming: true, + Streaming: copilot.Bool(true), ReasoningEffort: "high", }) if err != nil { @@ -343,10 +343,10 @@ func TestStreamingFidelityE2E(t *testing.T) { t.Errorf("Expected assistant message to contain '255' (15*17), got %q", lastAssistantContent) } - // Verify the session was created with reasoning effort via GetMessages - messages, err := session.GetMessages(t.Context()) + // Verify the session was created with reasoning effort via GetEvents + messages, err := session.GetEvents(t.Context()) if err != nil { - t.Fatalf("GetMessages failed: %v", err) + t.Fatalf("GetEvents failed: %v", err) } var sessionStartReasoningEffort string for _, msg := range messages { diff --git a/go/internal/e2e/suspend_e2e_test.go b/go/internal/e2e/suspend_e2e_test.go index 957fb58c6..8ce0c1fb1 100644 --- a/go/internal/e2e/suspend_e2e_test.go +++ b/go/internal/e2e/suspend_e2e_test.go @@ -50,9 +50,7 @@ func TestSuspendE2E(t *testing.T) { _, cliURL := startTcpServer(t, ctx) client1 := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.CLIUrl = cliURL - opts.CLIPath = "" - opts.TCPConnectionToken = sharedTcpToken + opts.Connection = copilot.UriConnection{URL: cliURL, ConnectionToken: sharedTcpToken} }) t.Cleanup(func() { client1.ForceStop() }) @@ -76,9 +74,7 @@ func TestSuspendE2E(t *testing.T) { client1.ForceStop() client2 := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.CLIUrl = cliURL - opts.CLIPath = "" - opts.TCPConnectionToken = sharedTcpToken + opts.Connection = copilot.UriConnection{URL: cliURL, ConnectionToken: sharedTcpToken} }) t.Cleanup(func() { client2.ForceStop() }) diff --git a/go/internal/e2e/testharness/context.go b/go/internal/e2e/testharness/context.go index d7d30e090..9055442a9 100644 --- a/go/internal/e2e/testharness/context.go +++ b/go/internal/e2e/testharness/context.go @@ -197,16 +197,17 @@ func (c *TestContext) Env() []string { // Optional overrides can be applied to the default ClientOptions via the opts function. func (c *TestContext) NewClient(opts ...func(*copilot.ClientOptions)) *copilot.Client { options := &copilot.ClientOptions{ - CLIPath: c.CLIPath, - Cwd: c.WorkDir, - Env: c.Env(), + Connection: copilot.StdioConnection{Path: c.CLIPath}, + Cwd: c.WorkDir, + Env: c.Env(), } for _, opt := range opts { opt(options) } - if options.GitHubToken == "" && options.CLIUrl == "" { + _, externalRuntime := options.Connection.(copilot.UriConnection) + if options.GitHubToken == "" && !externalRuntime { options.GitHubToken = defaultGitHubToken } diff --git a/go/internal/e2e/testharness/helper.go b/go/internal/e2e/testharness/helper.go index 27cf77cb5..ca94d03ad 100644 --- a/go/internal/e2e/testharness/helper.go +++ b/go/internal/e2e/testharness/helper.go @@ -90,7 +90,7 @@ func GetNextEventOfType(session *copilot.Session, eventType copilot.SessionEvent } func getExistingFinalResponse(ctx context.Context, session *copilot.Session, alreadyIdle bool) (*copilot.SessionEvent, error) { - messages, err := session.GetMessages(ctx) + messages, err := session.GetEvents(ctx) if err != nil { return nil, err } diff --git a/go/samples/chat.go b/go/samples/chat.go index 1a1b7e203..2f34a243c 100644 --- a/go/samples/chat.go +++ b/go/samples/chat.go @@ -17,7 +17,7 @@ const reset = "\033[0m" func main() { ctx := context.Background() cliPath := filepath.Join("..", "..", "nodejs", "node_modules", "@github", "copilot", "index.js") - client := copilot.NewClient(&copilot.ClientOptions{CLIPath: cliPath}) + client := copilot.NewClient(&copilot.ClientOptions{Connection: copilot.StdioConnection{Path: cliPath}}) if err := client.Start(ctx); err != nil { panic(err) } diff --git a/go/samples/manual_tool_resume/main.go b/go/samples/manual_tool_resume/main.go index 74b891b3a..1e0a23f5b 100644 --- a/go/samples/manual_tool_resume/main.go +++ b/go/samples/manual_tool_resume/main.go @@ -33,7 +33,7 @@ func manualTool() copilot.Tool { func newClient() *copilot.Client { cliPath := filepath.Join("..", "..", "nodejs", "node_modules", "@github", "copilot", "index.js") - return copilot.NewClient(&copilot.ClientOptions{CLIPath: cliPath}) + return copilot.NewClient(&copilot.ClientOptions{Connection: copilot.StdioConnection{Path: cliPath}}) } func watchPermission(session *copilot.Session) (<-chan *copilot.PermissionRequestedData, func()) { diff --git a/go/session.go b/go/session.go index bc7e2ede9..067bd0314 100644 --- a/go/session.go +++ b/go/session.go @@ -63,9 +63,9 @@ type Session struct { permissionMux sync.RWMutex userInputHandler UserInputHandler userInputMux sync.RWMutex - exitPlanModeHandler ExitPlanModeHandler + exitPlanModeHandler ExitPlanModeRequestHandler exitPlanModeMu sync.RWMutex - autoModeSwitchHandler AutoModeSwitchHandler + autoModeSwitchHandler AutoModeSwitchRequestHandler autoModeSwitchMu sync.RWMutex hooks *SessionHooks hooksMux sync.RWMutex @@ -157,6 +157,14 @@ func (s *Session) Send(ctx context.Context, options MessageOptions) (string, err return response.MessageID, nil } +// SendPrompt is a convenience wrapper for [Session.Send] that takes a plain +// prompt string instead of a [MessageOptions] struct. Equivalent to: +// +// session.Send(ctx, copilot.MessageOptions{Prompt: prompt}) +func (s *Session) SendPrompt(ctx context.Context, prompt string) (string, error) { + return s.Send(ctx, MessageOptions{Prompt: prompt}) +} + // SendAndWait sends a message to this session and waits until the session becomes idle. // // This is a convenience method that combines [Session.Send] with waiting for @@ -237,6 +245,15 @@ func (s *Session) SendAndWait(ctx context.Context, options MessageOptions) (*Ses } } +// SendPromptAndWait is a convenience wrapper for [Session.SendAndWait] that +// takes a plain prompt string instead of a [MessageOptions] struct. Equivalent +// to: +// +// session.SendAndWait(ctx, copilot.MessageOptions{Prompt: prompt}) +func (s *Session) SendPromptAndWait(ctx context.Context, prompt string) (*SessionEvent, error) { + return s.SendAndWait(ctx, MessageOptions{Prompt: prompt}) +} + // On subscribes to events from this session. // // Events include assistant messages, tool executions, errors, and session state @@ -363,13 +380,13 @@ func (s *Session) handleUserInputRequest(request UserInputRequest) (UserInputRes return handler(request, invocation) } -func (s *Session) registerExitPlanModeHandler(handler ExitPlanModeHandler) { +func (s *Session) registerExitPlanModeHandler(handler ExitPlanModeRequestHandler) { s.exitPlanModeMu.Lock() defer s.exitPlanModeMu.Unlock() s.exitPlanModeHandler = handler } -func (s *Session) getExitPlanModeHandler() ExitPlanModeHandler { +func (s *Session) getExitPlanModeHandler() ExitPlanModeRequestHandler { s.exitPlanModeMu.RLock() defer s.exitPlanModeMu.RUnlock() return s.exitPlanModeHandler @@ -384,13 +401,13 @@ func (s *Session) handleExitPlanModeRequest(request ExitPlanModeRequest) (ExitPl return handler(request, ExitPlanModeInvocation{SessionID: s.SessionID}) } -func (s *Session) registerAutoModeSwitchHandler(handler AutoModeSwitchHandler) { +func (s *Session) registerAutoModeSwitchHandler(handler AutoModeSwitchRequestHandler) { s.autoModeSwitchMu.Lock() defer s.autoModeSwitchMu.Unlock() s.autoModeSwitchHandler = handler } -func (s *Session) getAutoModeSwitchHandler() AutoModeSwitchHandler { +func (s *Session) getAutoModeSwitchHandler() AutoModeSwitchRequestHandler { s.autoModeSwitchMu.RLock() defer s.autoModeSwitchMu.RUnlock() return s.autoModeSwitchHandler @@ -834,7 +851,7 @@ func (ui *SessionUI) Select(ctx context.Context, message string, options []strin // Input shows a text input dialog. Returns the entered text, or empty string and // false if the user declines/cancels. -func (ui *SessionUI) Input(ctx context.Context, message string, opts *InputOptions) (string, bool, error) { +func (ui *SessionUI) Input(ctx context.Context, message string, opts *UiInputOptions) (string, bool, error) { if err := ui.session.assertElicitation(); err != nil { return "", false, err } @@ -1147,7 +1164,7 @@ func rpcPermissionDecisionFromKind(kind rpc.PermissionDecisionKind) rpc.Permissi } } -// GetMessages retrieves all events and messages from this session's history. +// GetEvents retrieves all events from this session's history. // // This returns the complete conversation history including user messages, // assistant responses, tool executions, and other session events in @@ -1157,9 +1174,9 @@ func rpcPermissionDecisionFromKind(kind rpc.PermissionDecisionKind) rpc.Permissi // // Example: // -// events, err := session.GetMessages(context.Background()) +// events, err := session.GetEvents(context.Background()) // if err != nil { -// log.Printf("Failed to get messages: %v", err) +// log.Printf("Failed to get events: %v", err) // return // } // for _, event := range events { @@ -1167,16 +1184,16 @@ func rpcPermissionDecisionFromKind(kind rpc.PermissionDecisionKind) rpc.Permissi // fmt.Println("Assistant:", d.Content) // } // } -func (s *Session) GetMessages(ctx context.Context) ([]SessionEvent, error) { +func (s *Session) GetEvents(ctx context.Context) ([]SessionEvent, error) { result, err := s.client.Request("session.getMessages", sessionGetMessagesRequest{SessionID: s.SessionID}) if err != nil { - return nil, fmt.Errorf("failed to get messages: %w", err) + return nil, fmt.Errorf("failed to get events: %w", err) } var response sessionGetMessagesResponse if err := json.Unmarshal(result, &response); err != nil { - return nil, fmt.Errorf("failed to unmarshal get messages response: %w", err) + return nil, fmt.Errorf("failed to unmarshal get events response: %w", err) } return response.Events, nil } @@ -1235,14 +1252,6 @@ func (s *Session) Disconnect() error { return nil } -// Deprecated: Use [Session.Disconnect] instead. Destroy will be removed in a future release. -// -// Destroy closes this session and releases all in-memory resources. -// Session data on disk is preserved for later resumption. -func (s *Session) Destroy() error { - return s.Disconnect() -} - // Abort aborts the currently processing message in this session. // // Use this to cancel a long-running request. The session remains valid diff --git a/go/session_fs_provider.go b/go/session_fs_provider.go index f77a5317c..50922d7bc 100644 --- a/go/session_fs_provider.go +++ b/go/session_fs_provider.go @@ -34,16 +34,16 @@ type SessionFsProvider interface { Stat(path string) (*SessionFsFileInfo, error) // Mkdir creates a directory. If recursive is true, create parent directories as needed. // mode is an optional POSIX-style permission mode (e.g., 0o755). Pass nil to use the OS default. - Mkdir(path string, recursive bool, mode *int) error + MakeDirectory(path string, recursive bool, mode *int) error // Readdir lists the names of entries in a directory. // Return os.ErrNotExist if the directory does not exist. - Readdir(path string) ([]string, error) + ReadDirectory(path string) ([]string, error) // ReaddirWithTypes lists entries with type information. // Return os.ErrNotExist if the directory does not exist. - ReaddirWithTypes(path string) ([]rpc.SessionFsReaddirWithTypesEntry, error) + ReadDirectoryWithTypes(path string) ([]rpc.SessionFsReaddirWithTypesEntry, error) // Rm removes a file or directory. If recursive is true, remove contents too. // If force is true, do not return an error when the path does not exist. - Rm(path string, recursive bool, force bool) error + Remove(path string, recursive bool, force bool) error // Rename moves/renames a file or directory. Rename(src string, dest string) error } @@ -152,14 +152,14 @@ func (a *sessionFsAdapter) Mkdir(request *rpc.SessionFsMkdirRequest) (*rpc.Sessi m := int(*request.Mode) mode = &m } - if err := a.provider.Mkdir(request.Path, recursive, mode); err != nil { + if err := a.provider.MakeDirectory(request.Path, recursive, mode); err != nil { return toSessionFsError(err), nil } return nil, nil } func (a *sessionFsAdapter) Readdir(request *rpc.SessionFsReaddirRequest) (*rpc.SessionFsReaddirResult, error) { - entries, err := a.provider.Readdir(request.Path) + entries, err := a.provider.ReadDirectory(request.Path) if err != nil { return &rpc.SessionFsReaddirResult{Error: toSessionFsError(err)}, nil } @@ -167,7 +167,7 @@ func (a *sessionFsAdapter) Readdir(request *rpc.SessionFsReaddirRequest) (*rpc.S } func (a *sessionFsAdapter) ReaddirWithTypes(request *rpc.SessionFsReaddirWithTypesRequest) (*rpc.SessionFsReaddirWithTypesResult, error) { - entries, err := a.provider.ReaddirWithTypes(request.Path) + entries, err := a.provider.ReadDirectoryWithTypes(request.Path) if err != nil { return &rpc.SessionFsReaddirWithTypesResult{Error: toSessionFsError(err)}, nil } @@ -177,7 +177,7 @@ func (a *sessionFsAdapter) ReaddirWithTypes(request *rpc.SessionFsReaddirWithTyp func (a *sessionFsAdapter) Rm(request *rpc.SessionFsRmRequest) (*rpc.SessionFsError, error) { recursive := request.Recursive != nil && *request.Recursive force := request.Force != nil && *request.Force - if err := a.provider.Rm(request.Path, recursive, force); err != nil { + if err := a.provider.Remove(request.Path, recursive, force); err != nil { return toSessionFsError(err), nil } return nil, nil diff --git a/go/types.go b/go/types.go index be3496c89..e97cb5a37 100644 --- a/go/types.go +++ b/go/types.go @@ -8,95 +8,128 @@ import ( "github.com/github/copilot-sdk/go/rpc" ) -// ConnectionState represents the client connection state -type ConnectionState string +// connectionState is the internal client connection state. +type connectionState string const ( - StateDisconnected ConnectionState = "disconnected" - StateConnecting ConnectionState = "connecting" - StateConnected ConnectionState = "connected" - StateError ConnectionState = "error" + stateDisconnected connectionState = "disconnected" + stateConnecting connectionState = "connecting" + stateConnected connectionState = "connected" + stateError connectionState = "error" ) -// ClientOptions configures the CopilotClient +// RuntimeConnection describes how a [Client] connects to the Copilot runtime. +// +// Construct one with a [StdioConnection], [TcpConnection], or [UriConnection] +// literal and pass it via [ClientOptions.Connection]. When [ClientOptions.Connection] +// is nil, the default is an empty [StdioConnection] (the SDK spawns the bundled +// runtime and communicates over stdin/stdout). +type RuntimeConnection interface { + runtimeConnection() +} + +// StdioConnection spawns a runtime child process and communicates over its +// stdin/stdout pipes. This is the default when no connection is configured. +type StdioConnection struct { + // Path is the runtime executable. When empty, the bundled runtime is used. + Path string + // Args are extra command-line arguments inserted before SDK-managed args. + Args []string +} + +func (StdioConnection) runtimeConnection() {} + +// TcpConnection spawns a runtime child process that listens on a TCP socket +// and connects to it. +type TcpConnection struct { + // Port is the TCP port the runtime listens on. 0 (the default) lets the + // runtime pick a free port; the chosen port is then available via + // [Client.RuntimePort] after [Client.Start] returns. + Port int + // ConnectionToken is an optional shared secret sent in the `connect` + // handshake. When empty, a UUID is generated automatically so the + // loopback listener is safe by default. + ConnectionToken string + // Path is the runtime executable. When empty, the bundled runtime is used. + Path string + // Args are extra command-line arguments inserted before SDK-managed args. + Args []string +} + +func (TcpConnection) runtimeConnection() {} + +// UriConnection connects to an already-running runtime at the given URL. +// The SDK does not spawn a process in this mode. +type UriConnection struct { + // URL of the runtime. Accepts "port", "host:port", or a full URL such + // as "http://host:port". + URL string + // ConnectionToken authenticates the connection; must match what the + // remote runtime expects. + ConnectionToken string +} + +func (UriConnection) runtimeConnection() {} + +// ClientOptions configures the [Client]. type ClientOptions struct { - // CLIPath is the path to the Copilot CLI executable (default: "copilot") - CLIPath string - // CLIArgs are extra arguments to pass to the CLI executable (inserted before SDK-managed args) - CLIArgs []string - // Cwd is the working directory for the CLI process (default: "" = inherit from current process) + // Connection describes how to connect to the Copilot runtime. When nil, + // defaults to an empty [StdioConnection] (spawn the bundled runtime over + // stdio). + Connection RuntimeConnection + // Cwd is the working directory for the runtime process. + // If empty, inherits the current process's working directory. Cwd string - // CopilotHome is the base directory for Copilot data (session state, config, etc.). - // Sets the COPILOT_HOME environment variable on the spawned CLI process. - // When empty, the CLI defaults to ~/.copilot. - // This does not affect where the Go SDK extracts the embedded CLI binary; - // use embeddedcli.Config.Dir to control that install/cache location. - // This option is only used when the SDK spawns the CLI process; it is ignored - // when connecting to an external server via CLIUrl. - CopilotHome string - // Port for TCP transport (default: 0 = random port) - Port int - // UseStdio controls whether to use stdio transport instead of TCP. - // Default: nil (use default = true, i.e. stdio). Use Bool(false) to explicitly select TCP. - UseStdio *bool - // TCPConnectionToken is the token sent in the `connect` handshake when using TCP transport. - // Only meaningful in TCP mode. When the SDK spawns its own CLI in TCP mode and this is - // empty, an auto-generated UUID is used so the loopback listener is safe by default. - // Combining this with UseStdio=true is rejected (stdio is pre-authenticated by transport). - TCPConnectionToken string - // CLIUrl is the URL of an existing Copilot CLI server to connect to over TCP - // Format: "host:port", "http://host:port", or just "port" (defaults to localhost) - // Examples: "localhost:8080", "http://127.0.0.1:9000", "8080" - // Mutually exclusive with CLIPath, UseStdio - CLIUrl string - // LogLevel for the CLI server + // BaseDirectory is the base directory for Copilot data (session state, + // config, etc.). Sets the COPILOT_HOME environment variable on the + // spawned runtime. When empty, the runtime defaults to ~/.copilot. + // This does not affect where the Go SDK extracts the embedded CLI + // binary; use embeddedcli.Config.Dir to control that install/cache + // location. + // Ignored when connecting to an existing runtime via [UriConnection]. + BaseDirectory string + // LogLevel for the runtime. When empty (the default), the runtime + // uses its own default level; the SDK does not pass --log-level. + // Recognized values: "none", "error", "warning", "info", "debug", "all". LogLevel string - // AutoStart automatically starts the CLI server on first use (default: true). - // Use Bool(false) to disable. - AutoStart *bool - // Deprecated: AutoRestart has no effect and will be removed in a future release. - AutoRestart *bool - // Env is the environment variables for the CLI process (default: inherits from current process). - // Each entry is of the form "key=value". - // If Env is nil, the new process uses the current process's environment. - // If Env contains duplicate environment keys, only the last value in the - // slice for each duplicate key is used. + // Env are the environment variables for the runtime process (default: + // inherits from current process). Each entry is of the form "KEY=VALUE". + // If Env contains duplicate keys, only the last value for each key is used. Env []string // GitHubToken is the GitHub token to use for authentication. - // When provided, the token is passed to the CLI server via environment variable. - // This takes priority over other authentication methods. + // When provided, the token is passed to the runtime via environment + // variable. This takes priority over other authentication methods. GitHubToken string - // UseLoggedInUser controls whether to use the logged-in user for authentication. - // When true, the CLI server will attempt to use stored OAuth tokens or gh CLI auth. - // When false, only explicit tokens (GitHubToken or environment variables) are used. + // UseLoggedInUser controls whether to use the logged-in user for + // authentication. When true, the runtime attempts to use stored OAuth + // tokens or gh CLI auth. When false, only explicit tokens (GitHubToken + // or environment variables) are used. // Default: true (but defaults to false when GitHubToken is provided). - // Use Bool(false) to explicitly disable. UseLoggedInUser *bool // OnListModels is a custom handler for listing available models. - // When provided, client.ListModels() calls this handler instead of - // querying the CLI server. Useful in BYOK mode to return models - // available from your custom provider. + // When provided, [Client.ListModels] calls this handler instead of + // querying the runtime. Useful in BYOK mode to return models available + // from your custom provider. OnListModels func(ctx context.Context) ([]ModelInfo, error) // SessionFs configures a custom session filesystem provider. // When provided, the client registers as the session filesystem provider - // on connection, routing session-scoped file I/O through per-session handlers. + // on connection, routing session-scoped file I/O through per-session + // handlers. SessionFs *SessionFsConfig - // Telemetry configures OpenTelemetry integration for the Copilot CLI process. - // When non-nil, COPILOT_OTEL_ENABLED=true is set and any populated fields - // are mapped to the corresponding environment variables. + // Telemetry configures OpenTelemetry integration for the runtime. + // When non-nil, COPILOT_OTEL_ENABLED=true is set and any populated + // fields are mapped to the corresponding environment variables. Telemetry *TelemetryConfig - // SessionIdleTimeoutSeconds configures the server-wide session idle timeout in seconds. - // Sessions without activity for this duration are automatically cleaned up. - // Set to 0 or leave unset to disable (sessions live indefinitely). - // This option is only used when the SDK spawns the CLI process; it is ignored - // when connecting to an external server via CLIUrl. + // SessionIdleTimeoutSeconds configures the server-wide session idle + // timeout in seconds. Sessions without activity for this duration are + // automatically cleaned up. Set to 0 or leave unset to disable. + // Ignored when connecting to an existing runtime via [UriConnection]. SessionIdleTimeoutSeconds int - // Remote enables remote session support (Mission Control integration). - // When true, sessions in a GitHub repository working directory are - // accessible from GitHub web and mobile. - // This option is only used when the SDK spawns the CLI process; it is ignored - // when connecting to an external server via CLIUrl. - Remote bool + // EnableRemoteSessions enables remote session support (Mission Control + // integration). When true, sessions in a GitHub repository working + // directory are accessible from GitHub web and mobile. + // Ignored when connecting to an existing runtime via [UriConnection]. + EnableRemoteSessions bool } // CloudSessionRepository is GitHub repository metadata associated with a cloud session. @@ -319,8 +352,8 @@ type ExitPlanModeInvocation struct { SessionID string } -// ExitPlanModeHandler handles exit-plan-mode requests from the agent. -type ExitPlanModeHandler func(request ExitPlanModeRequest, invocation ExitPlanModeInvocation) (ExitPlanModeResult, error) +// ExitPlanModeRequestHandler handles exit-plan-mode requests from the agent. +type ExitPlanModeRequestHandler func(request ExitPlanModeRequest, invocation ExitPlanModeInvocation) (ExitPlanModeResult, error) // AutoModeSwitchRequest represents a request to switch to auto mode after an eligible rate limit. type AutoModeSwitchRequest struct { @@ -333,16 +366,39 @@ type AutoModeSwitchInvocation struct { SessionID string } -// AutoModeSwitchHandler handles auto-mode-switch requests from the agent. -type AutoModeSwitchHandler func(request AutoModeSwitchRequest, invocation AutoModeSwitchInvocation) (AutoModeSwitchResponse, error) +// AutoModeSwitchRequestHandler handles auto-mode-switch requests from the agent. +type AutoModeSwitchRequestHandler func(request AutoModeSwitchRequest, invocation AutoModeSwitchInvocation) (AutoModeSwitchResponse, error) // PreToolUseHookInput is the input for a pre-tool-use hook type PreToolUseHookInput struct { - SessionID string `json:"sessionId"` - Timestamp int64 `json:"timestamp"` - Cwd string `json:"cwd"` - ToolName string `json:"toolName"` - ToolArgs any `json:"toolArgs"` + SessionID string `json:"sessionId"` + Timestamp time.Time `json:"-"` + Cwd string `json:"cwd"` + ToolName string `json:"toolName"` + ToolArgs any `json:"toolArgs"` +} + +// MarshalJSON implements json.Marshaler, emitting Timestamp as Unix milliseconds. +func (h PreToolUseHookInput) MarshalJSON() ([]byte, error) { + type alias PreToolUseHookInput + return json.Marshal(&struct { + Timestamp int64 `json:"timestamp"` + alias + }{Timestamp: h.Timestamp.UnixMilli(), alias: alias(h)}) +} + +// UnmarshalJSON implements json.Unmarshaler, parsing Timestamp from Unix milliseconds. +func (h *PreToolUseHookInput) UnmarshalJSON(data []byte) error { + type alias PreToolUseHookInput + aux := &struct { + Timestamp int64 `json:"timestamp"` + *alias + }{alias: (*alias)(h)} + if err := json.Unmarshal(data, aux); err != nil { + return err + } + h.Timestamp = time.UnixMilli(aux.Timestamp) + return nil } // PreToolUseHookOutput is the output for a pre-tool-use hook @@ -359,12 +415,35 @@ type PreToolUseHandler func(input PreToolUseHookInput, invocation HookInvocation // PostToolUseHookInput is the input for a post-tool-use hook type PostToolUseHookInput struct { - SessionID string `json:"sessionId"` - Timestamp int64 `json:"timestamp"` - Cwd string `json:"cwd"` - ToolName string `json:"toolName"` - ToolArgs any `json:"toolArgs"` - ToolResult any `json:"toolResult"` + SessionID string `json:"sessionId"` + Timestamp time.Time `json:"-"` + Cwd string `json:"cwd"` + ToolName string `json:"toolName"` + ToolArgs any `json:"toolArgs"` + ToolResult any `json:"toolResult"` +} + +// MarshalJSON implements json.Marshaler, emitting Timestamp as Unix milliseconds. +func (h PostToolUseHookInput) MarshalJSON() ([]byte, error) { + type alias PostToolUseHookInput + return json.Marshal(&struct { + Timestamp int64 `json:"timestamp"` + alias + }{Timestamp: h.Timestamp.UnixMilli(), alias: alias(h)}) +} + +// UnmarshalJSON implements json.Unmarshaler, parsing Timestamp from Unix milliseconds. +func (h *PostToolUseHookInput) UnmarshalJSON(data []byte) error { + type alias PostToolUseHookInput + aux := &struct { + Timestamp int64 `json:"timestamp"` + *alias + }{alias: (*alias)(h)} + if err := json.Unmarshal(data, aux); err != nil { + return err + } + h.Timestamp = time.UnixMilli(aux.Timestamp) + return nil } // PostToolUseHookOutput is the output for a post-tool-use hook @@ -379,10 +458,33 @@ type PostToolUseHandler func(input PostToolUseHookInput, invocation HookInvocati // UserPromptSubmittedHookInput is the input for a user-prompt-submitted hook type UserPromptSubmittedHookInput struct { - SessionID string `json:"sessionId"` - Timestamp int64 `json:"timestamp"` - Cwd string `json:"cwd"` - Prompt string `json:"prompt"` + SessionID string `json:"sessionId"` + Timestamp time.Time `json:"-"` + Cwd string `json:"cwd"` + Prompt string `json:"prompt"` +} + +// MarshalJSON implements json.Marshaler, emitting Timestamp as Unix milliseconds. +func (h UserPromptSubmittedHookInput) MarshalJSON() ([]byte, error) { + type alias UserPromptSubmittedHookInput + return json.Marshal(&struct { + Timestamp int64 `json:"timestamp"` + alias + }{Timestamp: h.Timestamp.UnixMilli(), alias: alias(h)}) +} + +// UnmarshalJSON implements json.Unmarshaler, parsing Timestamp from Unix milliseconds. +func (h *UserPromptSubmittedHookInput) UnmarshalJSON(data []byte) error { + type alias UserPromptSubmittedHookInput + aux := &struct { + Timestamp int64 `json:"timestamp"` + *alias + }{alias: (*alias)(h)} + if err := json.Unmarshal(data, aux); err != nil { + return err + } + h.Timestamp = time.UnixMilli(aux.Timestamp) + return nil } // UserPromptSubmittedHookOutput is the output for a user-prompt-submitted hook @@ -397,11 +499,34 @@ type UserPromptSubmittedHandler func(input UserPromptSubmittedHookInput, invocat // SessionStartHookInput is the input for a session-start hook type SessionStartHookInput struct { - SessionID string `json:"sessionId"` - Timestamp int64 `json:"timestamp"` - Cwd string `json:"cwd"` - Source string `json:"source"` // "startup", "resume", "new" - InitialPrompt string `json:"initialPrompt,omitempty"` + SessionID string `json:"sessionId"` + Timestamp time.Time `json:"-"` + Cwd string `json:"cwd"` + Source string `json:"source"` // "startup", "resume", "new" + InitialPrompt string `json:"initialPrompt,omitempty"` +} + +// MarshalJSON implements json.Marshaler, emitting Timestamp as Unix milliseconds. +func (h SessionStartHookInput) MarshalJSON() ([]byte, error) { + type alias SessionStartHookInput + return json.Marshal(&struct { + Timestamp int64 `json:"timestamp"` + alias + }{Timestamp: h.Timestamp.UnixMilli(), alias: alias(h)}) +} + +// UnmarshalJSON implements json.Unmarshaler, parsing Timestamp from Unix milliseconds. +func (h *SessionStartHookInput) UnmarshalJSON(data []byte) error { + type alias SessionStartHookInput + aux := &struct { + Timestamp int64 `json:"timestamp"` + *alias + }{alias: (*alias)(h)} + if err := json.Unmarshal(data, aux); err != nil { + return err + } + h.Timestamp = time.UnixMilli(aux.Timestamp) + return nil } // SessionStartHookOutput is the output for a session-start hook @@ -415,12 +540,35 @@ type SessionStartHandler func(input SessionStartHookInput, invocation HookInvoca // SessionEndHookInput is the input for a session-end hook type SessionEndHookInput struct { - SessionID string `json:"sessionId"` - Timestamp int64 `json:"timestamp"` - Cwd string `json:"cwd"` - Reason string `json:"reason"` // "complete", "error", "abort", "timeout", "user_exit" - FinalMessage string `json:"finalMessage,omitempty"` - Error string `json:"error,omitempty"` + SessionID string `json:"sessionId"` + Timestamp time.Time `json:"-"` + Cwd string `json:"cwd"` + Reason string `json:"reason"` // "complete", "error", "abort", "timeout", "user_exit" + FinalMessage string `json:"finalMessage,omitempty"` + Error string `json:"error,omitempty"` +} + +// MarshalJSON implements json.Marshaler, emitting Timestamp as Unix milliseconds. +func (h SessionEndHookInput) MarshalJSON() ([]byte, error) { + type alias SessionEndHookInput + return json.Marshal(&struct { + Timestamp int64 `json:"timestamp"` + alias + }{Timestamp: h.Timestamp.UnixMilli(), alias: alias(h)}) +} + +// UnmarshalJSON implements json.Unmarshaler, parsing Timestamp from Unix milliseconds. +func (h *SessionEndHookInput) UnmarshalJSON(data []byte) error { + type alias SessionEndHookInput + aux := &struct { + Timestamp int64 `json:"timestamp"` + *alias + }{alias: (*alias)(h)} + if err := json.Unmarshal(data, aux); err != nil { + return err + } + h.Timestamp = time.UnixMilli(aux.Timestamp) + return nil } // SessionEndHookOutput is the output for a session-end hook @@ -435,12 +583,35 @@ type SessionEndHandler func(input SessionEndHookInput, invocation HookInvocation // ErrorOccurredHookInput is the input for an error-occurred hook type ErrorOccurredHookInput struct { - SessionID string `json:"sessionId"` - Timestamp int64 `json:"timestamp"` - Cwd string `json:"cwd"` - Error string `json:"error"` - ErrorContext string `json:"errorContext"` // "model_call", "tool_execution", "system", "user_input" - Recoverable bool `json:"recoverable"` + SessionID string `json:"sessionId"` + Timestamp time.Time `json:"-"` + Cwd string `json:"cwd"` + Error string `json:"error"` + ErrorContext string `json:"errorContext"` // "model_call", "tool_execution", "system", "user_input" + Recoverable bool `json:"recoverable"` +} + +// MarshalJSON implements json.Marshaler, emitting Timestamp as Unix milliseconds. +func (h ErrorOccurredHookInput) MarshalJSON() ([]byte, error) { + type alias ErrorOccurredHookInput + return json.Marshal(&struct { + Timestamp int64 `json:"timestamp"` + alias + }{Timestamp: h.Timestamp.UnixMilli(), alias: alias(h)}) +} + +// UnmarshalJSON implements json.Unmarshaler, parsing Timestamp from Unix milliseconds. +func (h *ErrorOccurredHookInput) UnmarshalJSON(data []byte) error { + type alias ErrorOccurredHookInput + aux := &struct { + Timestamp int64 `json:"timestamp"` + *alias + }{alias: (*alias)(h)} + if err := json.Unmarshal(data, aux); err != nil { + return err + } + h.Timestamp = time.UnixMilli(aux.Timestamp) + return nil } // ErrorOccurredHookOutput is the output for an error-occurred hook @@ -476,8 +647,18 @@ type MCPServerConfig interface { } // MCPStdioServerConfig configures a local/stdio MCP server. +// +// The Tools field controls which tools from the server are exposed: +// - nil (omitted from the wire): all tools (CLI default) +// - &[]string{"*"}: explicit "all tools" +// - &[]string{}: no tools +// - &[]string{"foo","bar"}: only those tools +// +// The pointer-to-slice form is required so that a nil pointer (omitted from +// the wire) is distinguishable from a non-nil pointer to an empty slice +// (sent as `"tools": []`). type MCPStdioServerConfig struct { - Tools []string `json:"tools"` + Tools *[]string `json:"tools,omitempty"` Timeout int `json:"timeout,omitempty"` Command string `json:"command"` Args []string `json:"args,omitempty"` @@ -500,8 +681,10 @@ func (c MCPStdioServerConfig) MarshalJSON() ([]byte, error) { } // MCPHTTPServerConfig configures a remote MCP server (HTTP or SSE). +// +// See [MCPStdioServerConfig] for the semantics of the Tools field. type MCPHTTPServerConfig struct { - Tools []string `json:"tools"` + Tools *[]string `json:"tools,omitempty"` Timeout int `json:"timeout,omitempty"` URL string `json:"url"` Headers map[string]string `json:"headers,omitempty"` @@ -633,9 +816,10 @@ type SessionConfig struct { // Tool operations will be relative to this directory. WorkingDirectory string // Streaming enables streaming of assistant message and reasoning chunks. - // When true, assistant.message_delta and assistant.reasoning_delta events - // with deltaContent are sent as the response is generated. - Streaming bool + // When non-nil and true, assistant.message_delta and assistant.reasoning_delta + // events with deltaContent are sent as the response is generated. + // When nil, the runtime decides (currently defaults to non-streaming). + Streaming *bool // IncludeSubAgentStreamingEvents includes sub-agent streaming events in the // event stream. When true, streaming delta events from sub-agents (e.g., // assistant.message_delta, assistant.reasoning_delta, assistant.streaming_delta @@ -680,9 +864,9 @@ type SessionConfig struct { // handler. Equivalent to calling session.On(handler) immediately after creation, // but executes earlier in the lifecycle so no events are missed. OnEvent SessionEventHandler - // CreateSessionFsHandler supplies a handler for session filesystem operations. + // CreateSessionFsProvider supplies a handler for session filesystem operations. // This takes effect only when ClientOptions.SessionFs is configured. - CreateSessionFsHandler func(session *Session) SessionFsProvider + CreateSessionFsProvider func(session *Session) SessionFsProvider // Commands registers slash-commands for this session. Each command appears as // /name in the CLI TUI for the user to invoke. The Handler is called when the // command is executed. @@ -691,12 +875,12 @@ type SessionConfig struct { // When provided, the server may call back to this client for form-based UI dialogs // (e.g. from MCP tools). Also enables the elicitation capability on the session. OnElicitationRequest ElicitationHandler - // OnExitPlanMode is a handler for exit-plan-mode requests from the server. + // OnExitPlanModeRequest is a handler for exit-plan-mode requests from the server. // When provided, enables exitPlanMode.request callbacks for the session. - OnExitPlanMode ExitPlanModeHandler - // OnAutoModeSwitch is a handler for auto-mode-switch requests from the server. + OnExitPlanModeRequest ExitPlanModeRequestHandler + // OnAutoModeSwitchRequest is a handler for auto-mode-switch requests from the server. // When provided, enables autoModeSwitch.request callbacks for the session. - OnAutoModeSwitch AutoModeSwitchHandler + OnAutoModeSwitchRequest AutoModeSwitchRequestHandler // GitHubToken is an optional per-session GitHub token used for authentication. // When provided, the session authenticates as the token's owner instead of // using the global client-level auth. @@ -817,8 +1001,8 @@ type ElicitationContext struct { // If the handler returns an error the SDK auto-cancels the request. type ElicitationHandler func(ctx ElicitationContext) (ElicitationResult, error) -// InputOptions configures a text input field for the Input convenience method. -type InputOptions struct { +// UiInputOptions configures a text input field for the Input convenience method. +type UiInputOptions struct { // Title label for the input field. Title string // Description text shown below the field. @@ -893,9 +1077,10 @@ type ResumeSessionConfig struct { // always loaded from the working directory regardless of this setting. EnableConfigDiscovery bool // Streaming enables streaming of assistant message and reasoning chunks. - // When true, assistant.message_delta and assistant.reasoning_delta events - // with deltaContent are sent as the response is generated. - Streaming bool + // When non-nil and true, assistant.message_delta and assistant.reasoning_delta + // events with deltaContent are sent as the response is generated. + // When nil, the runtime decides (currently defaults to non-streaming). + Streaming *bool // IncludeSubAgentStreamingEvents includes sub-agent streaming events in the // event stream. When true, streaming delta events from sub-agents (e.g., // assistant.message_delta, assistant.reasoning_delta, assistant.streaming_delta @@ -927,9 +1112,9 @@ type ResumeSessionConfig struct { // RemoteSession controls per-session remote behavior. // See SessionConfig.RemoteSession for details. RemoteSession rpc.RemoteSessionMode - // DisableResume, when true, skips emitting the session.resume event. + // SuppressResumeEvent, when true, skips emitting the session.resume event. // Useful for reconnecting to a session without triggering resume-related side effects. - DisableResume bool + SuppressResumeEvent bool // ContinuePendingWork, when true, instructs the runtime to continue any tool calls // or permission prompts that were still pending when the session was last suspended. // When false (the default), the runtime treats pending work as interrupted on resume. @@ -942,20 +1127,20 @@ type ResumeSessionConfig struct { // OnEvent is an optional event handler registered before the session.resume RPC // is issued, ensuring early events are delivered. See SessionConfig.OnEvent. OnEvent SessionEventHandler - // CreateSessionFsHandler supplies a handler for session filesystem operations. + // CreateSessionFsProvider supplies a handler for session filesystem operations. // This takes effect only when ClientOptions.SessionFs is configured. - CreateSessionFsHandler func(session *Session) SessionFsProvider + CreateSessionFsProvider func(session *Session) SessionFsProvider // Commands registers slash-commands for this session. See SessionConfig.Commands. Commands []CommandDefinition // OnElicitationRequest is a handler for elicitation requests from the server. // See SessionConfig.OnElicitationRequest. OnElicitationRequest ElicitationHandler - // OnExitPlanMode is a handler for exit-plan-mode requests from the server. - // See SessionConfig.OnExitPlanMode. - OnExitPlanMode ExitPlanModeHandler - // OnAutoModeSwitch is a handler for auto-mode-switch requests from the server. - // See SessionConfig.OnAutoModeSwitch. - OnAutoModeSwitch AutoModeSwitchHandler + // OnExitPlanModeRequest is a handler for exit-plan-mode requests from the server. + // See SessionConfig.OnExitPlanModeRequest. + OnExitPlanModeRequest ExitPlanModeRequestHandler + // OnAutoModeSwitchRequest is a handler for auto-mode-switch requests from the server. + // See SessionConfig.OnAutoModeSwitchRequest. + OnAutoModeSwitchRequest AutoModeSwitchRequestHandler } type ProviderConfig struct { // Type is the provider type: "openai", "azure", or "anthropic". Defaults to "openai". @@ -984,11 +1169,11 @@ type ProviderConfig struct { // custom fine-tune name) differs from ModelID. // Falls back to ModelID, then SessionConfig.Model. WireModel string `json:"wireModel,omitempty"` - // MaxInputTokens overrides the resolved model's default max prompt tokens. + // MaxPromptTokens overrides the resolved model's default max prompt tokens. // The runtime triggers conversation compaction before sending a request // when the prompt (system message, history, tool definitions, user // message) would exceed this limit. - MaxInputTokens int `json:"maxPromptTokens,omitempty"` + MaxPromptTokens int `json:"maxPromptTokens,omitempty"` // MaxOutputTokens overrides the resolved model's default max output // tokens. When hit, the model stops generating and returns a truncated // response. @@ -1108,8 +1293,8 @@ type SessionListFilter struct { // SessionMetadata contains metadata about a session type SessionMetadata struct { SessionID string `json:"sessionId"` - StartTime string `json:"startTime"` - ModifiedTime string `json:"modifiedTime"` + StartTime time.Time `json:"startTime"` + ModifiedTime time.Time `json:"modifiedTime"` Summary *string `json:"summary,omitempty"` IsRemote bool `json:"isRemote"` Context *SessionContext `json:"context,omitempty"` @@ -1135,9 +1320,9 @@ type SessionLifecycleEvent struct { // SessionLifecycleEventMetadata contains optional metadata for lifecycle events type SessionLifecycleEventMetadata struct { - StartTime string `json:"startTime"` - ModifiedTime string `json:"modifiedTime"` - Summary *string `json:"summary,omitempty"` + StartTime time.Time `json:"startTime"` + ModifiedTime time.Time `json:"modifiedTime"` + Summary *string `json:"summary,omitempty"` } // SessionLifecycleHandler is a callback for session lifecycle events diff --git a/go/types_test.go b/go/types_test.go index 2d80d206c..1d201d2b8 100644 --- a/go/types_test.go +++ b/go/types_test.go @@ -159,7 +159,7 @@ func TestProviderConfig_JSONIncludesAllFields(t *testing.T) { Headers: map[string]string{"Authorization": "Bearer provider-token"}, ModelID: "gpt-4o", WireModel: "my-finetune-v3", - MaxInputTokens: 100000, + MaxPromptTokens: 100000, MaxOutputTokens: 4096, } diff --git a/test/scenarios/bundling/app-backend-to-server/go/main.go b/test/scenarios/bundling/app-backend-to-server/go/main.go index d1fa1f898..6e0bc7f30 100644 --- a/test/scenarios/bundling/app-backend-to-server/go/main.go +++ b/test/scenarios/bundling/app-backend-to-server/go/main.go @@ -53,7 +53,7 @@ func chatHandler(w http.ResponseWriter, r *http.Request) { } client := copilot.NewClient(&copilot.ClientOptions{ - CLIUrl: cliURL(), + Connection: copilot.UriConnection{URL: cliURL()}, }) ctx := context.Background() diff --git a/test/scenarios/bundling/app-direct-server/go/main.go b/test/scenarios/bundling/app-direct-server/go/main.go index 447e99043..acdbaab76 100644 --- a/test/scenarios/bundling/app-direct-server/go/main.go +++ b/test/scenarios/bundling/app-direct-server/go/main.go @@ -16,7 +16,7 @@ func main() { } client := copilot.NewClient(&copilot.ClientOptions{ - CLIUrl: cliUrl, + Connection: copilot.UriConnection{URL: cliUrl}, }) ctx := context.Background() diff --git a/test/scenarios/bundling/container-proxy/go/main.go b/test/scenarios/bundling/container-proxy/go/main.go index 447e99043..acdbaab76 100644 --- a/test/scenarios/bundling/container-proxy/go/main.go +++ b/test/scenarios/bundling/container-proxy/go/main.go @@ -16,7 +16,7 @@ func main() { } client := copilot.NewClient(&copilot.ClientOptions{ - CLIUrl: cliUrl, + Connection: copilot.UriConnection{URL: cliUrl}, }) ctx := context.Background() diff --git a/test/scenarios/sessions/streaming/go/main.go b/test/scenarios/sessions/streaming/go/main.go index c6df2c28b..8a1c78efa 100644 --- a/test/scenarios/sessions/streaming/go/main.go +++ b/test/scenarios/sessions/streaming/go/main.go @@ -22,7 +22,7 @@ func main() { session, err := client.CreateSession(ctx, &copilot.SessionConfig{ Model: "claude-haiku-4.5", - Streaming: true, + Streaming: copilot.Bool(true), }) if err != nil { log.Fatal(err) diff --git a/test/scenarios/tools/mcp-servers/go/main.go b/test/scenarios/tools/mcp-servers/go/main.go index 72cbdc067..b1a1225f1 100644 --- a/test/scenarios/tools/mcp-servers/go/main.go +++ b/test/scenarios/tools/mcp-servers/go/main.go @@ -33,7 +33,7 @@ func main() { mcpServers["example"] = copilot.MCPStdioServerConfig{ Command: cmd, Args: args, - Tools: []string{"*"}, + Tools: &[]string{"*"}, } } diff --git a/test/scenarios/transport/reconnect/go/main.go b/test/scenarios/transport/reconnect/go/main.go index f7f6cd152..fda142316 100644 --- a/test/scenarios/transport/reconnect/go/main.go +++ b/test/scenarios/transport/reconnect/go/main.go @@ -16,7 +16,7 @@ func main() { } client := copilot.NewClient(&copilot.ClientOptions{ - CLIUrl: cliUrl, + Connection: copilot.UriConnection{URL: cliUrl}, }) ctx := context.Background() diff --git a/test/scenarios/transport/tcp/go/main.go b/test/scenarios/transport/tcp/go/main.go index 447e99043..acdbaab76 100644 --- a/test/scenarios/transport/tcp/go/main.go +++ b/test/scenarios/transport/tcp/go/main.go @@ -16,7 +16,7 @@ func main() { } client := copilot.NewClient(&copilot.ClientOptions{ - CLIUrl: cliUrl, + Connection: copilot.UriConnection{URL: cliUrl}, }) ctx := context.Background()